Merge pull request #2061 from akhilmhdh/feat/secret-approval-grouo

Secret approval with groups
This commit is contained in:
Maidul Islam
2024-07-04 16:18:56 -04:00
committed by GitHub
23 changed files with 561 additions and 253 deletions
@@ -0,0 +1,188 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
// migrate secret approval policy approvers to user id
const hasApproverUserId = await knex.schema.hasColumn(TableName.SecretApprovalPolicyApprover, "approverUserId");
const hasApproverId = await knex.schema.hasColumn(TableName.SecretApprovalPolicyApprover, "approverId");
if (!hasApproverUserId) {
// add the new fields
await knex.schema.alterTable(TableName.SecretApprovalPolicyApprover, (tb) => {
// if (hasApproverId) tb.setNullable("approverId");
tb.uuid("approverUserId");
tb.foreign("approverUserId").references("id").inTable(TableName.Users).onDelete("CASCADE");
});
// convert project membership id => user id
await knex(TableName.SecretApprovalPolicyApprover).update({
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
approverUserId: knex(TableName.ProjectMembership)
.select("userId")
.where("id", knex.raw("??", [`${TableName.SecretApprovalPolicyApprover}.approverId`]))
});
// drop the old field
await knex.schema.alterTable(TableName.SecretApprovalPolicyApprover, (tb) => {
if (hasApproverId) tb.dropColumn("approverId");
tb.uuid("approverUserId").notNullable().alter();
});
}
// migrate secret approval request committer and statusChangeBy to user id
const hasSecretApprovalRequestTable = await knex.schema.hasTable(TableName.SecretApprovalRequest);
const hasCommitterUserId = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "committerUserId");
const hasCommitterId = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "committerId");
const hasStatusChangeBy = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "statusChangeBy");
const hasStatusChangedByUserId = await knex.schema.hasColumn(
TableName.SecretApprovalRequest,
"statusChangedByUserId"
);
if (hasSecretApprovalRequestTable) {
// new fields
await knex.schema.alterTable(TableName.SecretApprovalRequest, (tb) => {
// if (hasCommitterId) tb.setNullable("committerId");
if (!hasCommitterUserId) {
tb.uuid("committerUserId");
tb.foreign("committerUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
}
if (!hasStatusChangedByUserId) {
tb.uuid("statusChangedByUserId");
tb.foreign("statusChangedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
}
});
// copy the assigned project membership => user id to new fields
await knex(TableName.SecretApprovalRequest).update({
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
committerUserId: knex(TableName.ProjectMembership)
.select("userId")
.where("id", knex.raw("??", [`${TableName.SecretApprovalRequest}.committerId`])),
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
statusChangedByUserId: knex(TableName.ProjectMembership)
.select("userId")
.where("id", knex.raw("??", [`${TableName.SecretApprovalRequest}.statusChangeBy`]))
});
// drop old fields
await knex.schema.alterTable(TableName.SecretApprovalRequest, (tb) => {
if (hasStatusChangeBy) tb.dropColumn("statusChangeBy");
if (hasCommitterId) tb.dropColumn("committerId");
tb.uuid("committerUserId").notNullable().alter();
});
}
// migrate secret approval request reviewer to user id
const hasMemberId = await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "member");
const hasReviewerUserId = await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "reviewerUserId");
if (!hasReviewerUserId) {
// new fields
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (tb) => {
// if (hasMemberId) tb.setNullable("member");
tb.uuid("reviewerUserId");
tb.foreign("reviewerUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
});
// copy project membership => user id to new fields
await knex(TableName.SecretApprovalRequestReviewer).update({
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
reviewerUserId: knex(TableName.ProjectMembership)
.select("userId")
.where("id", knex.raw("??", [`${TableName.SecretApprovalRequestReviewer}.member`]))
});
// drop table
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (tb) => {
if (hasMemberId) tb.dropColumn("member");
tb.uuid("reviewerUserId").notNullable().alter();
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasApproverUserId = await knex.schema.hasColumn(TableName.SecretApprovalPolicyApprover, "approverUserId");
const hasApproverId = await knex.schema.hasColumn(TableName.SecretApprovalPolicyApprover, "approverId");
if (hasApproverUserId) {
await knex.schema.alterTable(TableName.SecretApprovalPolicyApprover, (tb) => {
if (!hasApproverId) {
tb.uuid("approverId");
tb.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
}
});
if (!hasApproverId) {
await knex(TableName.SecretApprovalPolicyApprover).update({
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
approverId: knex(TableName.ProjectMembership)
.select("id")
.where("userId", knex.raw("??", [`${TableName.SecretApprovalPolicyApprover}.approverUserId`]))
});
await knex.schema.alterTable(TableName.SecretApprovalPolicyApprover, (tb) => {
tb.dropColumn("approverUserId");
tb.uuid("approverId").notNullable().alter();
});
}
}
const hasSecretApprovalRequestTable = await knex.schema.hasTable(TableName.SecretApprovalRequest);
const hasCommitterUserId = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "committerUserId");
const hasCommitterId = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "committerId");
const hasStatusChangeBy = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "statusChangeBy");
const hasStatusChangedByUser = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "statusChangedByUserId");
if (hasSecretApprovalRequestTable) {
await knex.schema.alterTable(TableName.SecretApprovalRequest, (tb) => {
// if (hasCommitterId) tb.uuid("committerId").notNullable().alter();
if (!hasCommitterId) {
tb.uuid("committerId");
tb.foreign("committerId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
}
if (!hasStatusChangeBy) {
tb.uuid("statusChangeBy");
tb.foreign("statusChangeBy").references("id").inTable(TableName.ProjectMembership).onDelete("SET NULL");
}
});
await knex(TableName.SecretApprovalRequest).update({
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
committerId: knex(TableName.ProjectMembership)
.select("id")
.where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.committerUserId`])),
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
statusChangeBy: knex(TableName.ProjectMembership)
.select("id")
.where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.statusChangedByUserId`]))
});
await knex.schema.alterTable(TableName.SecretApprovalRequest, (tb) => {
if (hasCommitterUserId) tb.dropColumn("committerUserId");
if (hasStatusChangedByUser) tb.dropColumn("statusChangedByUserId");
if (hasCommitterId) tb.uuid("committerId").notNullable().alter();
});
}
const hasMemberId = await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "member");
const hasReviewerUserId = await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "reviewerUserId");
if (hasReviewerUserId) {
if (!hasMemberId) {
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (tb) => {
// if (hasMemberId) tb.uuid("member").notNullable().alter();
tb.uuid("member");
tb.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
});
}
await knex(TableName.SecretApprovalRequestReviewer).update({
// eslint-disable-next-line
// @ts-ignore because generate schema happens after this
member: knex(TableName.ProjectMembership)
.select("id")
.where("userId", knex.raw("??", [`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`]))
});
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (tb) => {
tb.uuid("member").notNullable().alter();
tb.dropColumn("reviewerUserId");
});
}
}
@@ -9,10 +9,10 @@ import { TImmutableDBKeys } from "./models";
export const SecretApprovalPoliciesApproversSchema = z.object({ export const SecretApprovalPoliciesApproversSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
approverId: z.string().uuid(),
policyId: z.string().uuid(), policyId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
approverUserId: z.string().uuid()
}); });
export type TSecretApprovalPoliciesApprovers = z.infer<typeof SecretApprovalPoliciesApproversSchema>; export type TSecretApprovalPoliciesApprovers = z.infer<typeof SecretApprovalPoliciesApproversSchema>;
@@ -9,11 +9,11 @@ import { TImmutableDBKeys } from "./models";
export const SecretApprovalRequestsReviewersSchema = z.object({ export const SecretApprovalRequestsReviewersSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
member: z.string().uuid(),
status: z.string(), status: z.string(),
requestId: z.string().uuid(), requestId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
reviewerUserId: z.string().uuid()
}); });
export type TSecretApprovalRequestsReviewers = z.infer<typeof SecretApprovalRequestsReviewersSchema>; export type TSecretApprovalRequestsReviewers = z.infer<typeof SecretApprovalRequestsReviewersSchema>;
@@ -15,11 +15,11 @@ export const SecretApprovalRequestsSchema = z.object({
conflicts: z.unknown().nullable().optional(), conflicts: z.unknown().nullable().optional(),
slug: z.string(), slug: z.string(),
folderId: z.string().uuid(), folderId: z.string().uuid(),
statusChangeBy: z.string().uuid().nullable().optional(),
committerId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
isReplicated: z.boolean().nullable().optional() isReplicated: z.boolean().nullable().optional(),
committerUserId: z.string().uuid(),
statusChangedByUserId: z.string().uuid().nullable().optional()
}); });
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>; export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;
@@ -25,10 +25,10 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.optional() .optional()
.nullable() .nullable()
.transform((val) => (val ? removeTrailingSlash(val) : val)), .transform((val) => (val ? removeTrailingSlash(val) : val)),
approvers: z.string().array().min(1), approverUserIds: z.string().array().min(1),
approvals: z.number().min(1).default(1) approvals: z.number().min(1).default(1)
}) })
.refine((data) => data.approvals <= data.approvers.length, { .refine((data) => data.approvals <= data.approverUserIds.length, {
path: ["approvals"], path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers." message: "The number of approvals should be lower than the number of approvers."
}), }),
@@ -66,7 +66,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
body: z body: z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
approvers: z.string().array().min(1), approverUserIds: z.string().array().min(1),
approvals: z.number().min(1).default(1), approvals: z.number().min(1).default(1),
secretPath: z secretPath: z
.string() .string()
@@ -74,7 +74,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.nullable() .nullable()
.transform((val) => (val ? removeTrailingSlash(val) : val)) .transform((val) => (val ? removeTrailingSlash(val) : val))
}) })
.refine((data) => data.approvals <= data.approvers.length, { .refine((data) => data.approvals <= data.approverUserIds.length, {
path: ["approvals"], path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers." message: "The number of approvals should be lower than the number of approvers."
}), }),
@@ -139,7 +139,15 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
}), }),
response: { response: {
200: z.object({ 200: z.object({
approvals: sapPubSchema.merge(z.object({ approvers: z.string().array() })).array() approvals: sapPubSchema
.extend({
userApprovers: z
.object({
userId: z.string()
})
.array()
})
.array()
}) })
} }
}, },
@@ -170,7 +178,11 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
}), }),
response: { response: {
200: z.object({ 200: z.object({
policy: sapPubSchema.merge(z.object({ approvers: z.string().array() })).optional() policy: sapPubSchema
.extend({
userApprovers: z.object({ userId: z.string() }).array()
})
.optional()
}) })
} }
}, },
@@ -6,7 +6,8 @@ import {
SecretApprovalRequestsSecretsSchema, SecretApprovalRequestsSecretsSchema,
SecretsSchema, SecretsSchema,
SecretTagsSchema, SecretTagsSchema,
SecretVersionsSchema SecretVersionsSchema,
UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApprovalStatus, RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; import { ApprovalStatus, RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types";
@@ -14,6 +15,15 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
const approvalRequestUser = z.object({ userId: z.string() }).merge(
UsersSchema.pick({
email: true,
firstName: true,
lastName: true,
username: true
})
);
export const registerSecretApprovalRequestRouter = async (server: FastifyZodProvider) => { export const registerSecretApprovalRequestRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "GET", method: "GET",
@@ -41,9 +51,10 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
approvers: z.string().array(), approvers: z.string().array(),
secretPath: z.string().optional().nullable() secretPath: z.string().optional().nullable()
}), }),
committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
environment: z.string(), environment: z.string(),
reviewers: z.object({ member: z.string(), status: z.string() }).array(), reviewers: z.object({ userId: z.string(), status: z.string() }).array(),
approvers: z.string().array() approvers: z.string().array()
}).array() }).array()
}) })
@@ -195,7 +206,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
type: isClosing ? EventType.SECRET_APPROVAL_CLOSED : EventType.SECRET_APPROVAL_REOPENED, type: isClosing ? EventType.SECRET_APPROVAL_CLOSED : EventType.SECRET_APPROVAL_REOPENED,
// eslint-disable-next-line // eslint-disable-next-line
metadata: { metadata: {
[isClosing ? ("closedBy" as const) : ("reopenedBy" as const)]: approval.statusChangeBy as string, [isClosing ? ("closedBy" as const) : ("reopenedBy" as const)]: approval.statusChangedByUserId as string,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
// eslint-disable-next-line // eslint-disable-next-line
@@ -216,6 +227,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
}) })
.array() .array()
.optional(); .optional();
server.route({ server.route({
method: "GET", method: "GET",
url: "/:id", url: "/:id",
@@ -235,12 +247,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
approvals: z.number(), approvals: z.number(),
approvers: z.string().array(), approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable() secretPath: z.string().optional().nullable()
}), }),
environment: z.string(), environment: z.string(),
reviewers: z.object({ member: z.string(), status: z.string() }).array(), statusChangedByUser: approvalRequestUser.optional(),
approvers: z.string().array(), committerUser: approvalRequestUser,
reviewers: approvalRequestUser.extend({ status: z.string() }).array(),
secretPath: z.string(), secretPath: z.string(),
commits: SecretApprovalRequestsSecretsSchema.omit({ secretBlindIndex: true }) commits: SecretApprovalRequestsSecretsSchema.omit({ secretBlindIndex: true })
.merge( .merge(
@@ -1,49 +1,59 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TSecretApprovalPolicies } from "@app/db/schemas"; import { SecretApprovalPoliciesSchema, TableName, TSecretApprovalPolicies } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex";
export type TSecretApprovalPolicyDALFactory = ReturnType<typeof secretApprovalPolicyDALFactory>; export type TSecretApprovalPolicyDALFactory = ReturnType<typeof secretApprovalPolicyDALFactory>;
export const secretApprovalPolicyDALFactory = (db: TDbClient) => { export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
const secretApprovalPolicyOrm = ormify(db, TableName.SecretApprovalPolicy); const secretApprovalPolicyOrm = ormify(db, TableName.SecretApprovalPolicy);
const sapFindQuery = (tx: Knex, filter: TFindFilter<TSecretApprovalPolicies>) => const secretApprovalPolicyFindQuery = (tx: Knex, filter: TFindFilter<TSecretApprovalPolicies>) =>
tx(TableName.SecretApprovalPolicy) tx(TableName.SecretApprovalPolicy)
// eslint-disable-next-line // eslint-disable-next-line
.where(buildFindFilter(filter)) .where(buildFindFilter(filter))
.join(TableName.Environment, `${TableName.SecretApprovalPolicy}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretApprovalPolicy}.envId`, `${TableName.Environment}.id`)
.join( .leftJoin(
TableName.SecretApprovalPolicyApprover, TableName.SecretApprovalPolicyApprover,
`${TableName.SecretApprovalPolicy}.id`, `${TableName.SecretApprovalPolicy}.id`,
`${TableName.SecretApprovalPolicyApprover}.policyId` `${TableName.SecretApprovalPolicyApprover}.policyId`
) )
.select(tx.ref("approverId").withSchema(TableName.SecretApprovalPolicyApprover)) .select(tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover))
.select(tx.ref("name").withSchema(TableName.Environment).as("envName")) .select(
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) tx.ref("name").withSchema(TableName.Environment).as("envName"),
.select(tx.ref("id").withSchema(TableName.Environment).as("envId")) tx.ref("slug").withSchema(TableName.Environment).as("envSlug"),
.select(tx.ref("projectId").withSchema(TableName.Environment)) tx.ref("id").withSchema(TableName.Environment).as("envId"),
tx.ref("projectId").withSchema(TableName.Environment)
)
.select(selectAllTableCols(TableName.SecretApprovalPolicy)) .select(selectAllTableCols(TableName.SecretApprovalPolicy))
.orderBy("createdAt", "asc"); .orderBy("createdAt", "asc");
const findById = async (id: string, tx?: Knex) => { const findById = async (id: string, tx?: Knex) => {
try { try {
const doc = await sapFindQuery(tx || db.replicaNode(), { const doc = await secretApprovalPolicyFindQuery(tx || db.replicaNode(), {
[`${TableName.SecretApprovalPolicy}.id` as "id"]: id [`${TableName.SecretApprovalPolicy}.id` as "id"]: id
}); });
const formatedDoc = mergeOneToManyRelation( const formatedDoc = sqlNestRelationships({
doc, data: doc,
"id", key: "id",
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ parentMapper: (data) => ({
...el, environment: { id: data.envId, name: data.envName, slug: data.envSlug },
envId, projectId: data.projectId,
environment: { id: envId, name, slug } ...SecretApprovalPoliciesSchema.parse(data)
}), }),
({ approverId }) => approverId, childrenMapper: [
"approvers" {
); key: "approverUserId",
label: "userApprovers" as const,
mapper: ({ approverUserId }) => ({
userId: approverUserId
})
}
]
});
return formatedDoc?.[0]; return formatedDoc?.[0];
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindById" }); throw new DatabaseError({ error, name: "FindById" });
@@ -52,18 +62,25 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
const find = async (filter: TFindFilter<TSecretApprovalPolicies & { projectId: string }>, tx?: Knex) => { const find = async (filter: TFindFilter<TSecretApprovalPolicies & { projectId: string }>, tx?: Knex) => {
try { try {
const docs = await sapFindQuery(tx || db.replicaNode(), filter); const docs = await secretApprovalPolicyFindQuery(tx || db.replicaNode(), filter);
const formatedDoc = mergeOneToManyRelation( const formatedDoc = sqlNestRelationships({
docs, data: docs,
"id", key: "id",
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ parentMapper: (data) => ({
...el, environment: { id: data.envId, name: data.envName, slug: data.envSlug },
envId, projectId: data.projectId,
environment: { id: envId, name, slug } ...SecretApprovalPoliciesSchema.parse(data)
}), }),
({ approverId }) => approverId, childrenMapper: [
"approvers" {
); key: "approverUserId",
label: "userApprovers" as const,
mapper: ({ approverUserId }) => ({
userId: approverUserId
})
}
]
});
return formatedDoc; return formatedDoc;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find" }); throw new DatabaseError({ error, name: "Find" });
@@ -7,7 +7,6 @@ import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { containsGlobPatterns } from "@app/lib/picomatch"; import { containsGlobPatterns } from "@app/lib/picomatch";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal";
import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal";
@@ -29,7 +28,6 @@ type TSecretApprovalPolicyServiceFactoryDep = {
secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory; secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
}; };
export type TSecretApprovalPolicyServiceFactory = ReturnType<typeof secretApprovalPolicyServiceFactory>; export type TSecretApprovalPolicyServiceFactory = ReturnType<typeof secretApprovalPolicyServiceFactory>;
@@ -38,8 +36,7 @@ export const secretApprovalPolicyServiceFactory = ({
secretApprovalPolicyDAL, secretApprovalPolicyDAL,
permissionService, permissionService,
secretApprovalPolicyApproverDAL, secretApprovalPolicyApproverDAL,
projectEnvDAL, projectEnvDAL
projectMembershipDAL
}: TSecretApprovalPolicyServiceFactoryDep) => { }: TSecretApprovalPolicyServiceFactoryDep) => {
const createSecretApprovalPolicy = async ({ const createSecretApprovalPolicy = async ({
name, name,
@@ -48,12 +45,12 @@ export const secretApprovalPolicyServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
approvals, approvals,
approvers, approverUserIds,
projectId, projectId,
secretPath, secretPath,
environment environment
}: TCreateSapDTO) => { }: TCreateSapDTO) => {
if (approvals > approvers.length) if (approvals > approverUserIds.length)
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
@@ -70,13 +67,6 @@ export const secretApprovalPolicyServiceFactory = ({
const env = await projectEnvDAL.findOne({ slug: environment, projectId }); const env = await projectEnvDAL.findOne({ slug: environment, projectId });
if (!env) throw new BadRequestError({ message: "Environment not found" }); if (!env) throw new BadRequestError({ message: "Environment not found" });
const secretApprovers = await projectMembershipDAL.find({
projectId,
$in: { id: approvers }
});
if (secretApprovers.length !== approvers.length)
throw new BadRequestError({ message: "Approver not found in project" });
const secretApproval = await secretApprovalPolicyDAL.transaction(async (tx) => { const secretApproval = await secretApprovalPolicyDAL.transaction(async (tx) => {
const doc = await secretApprovalPolicyDAL.create( const doc = await secretApprovalPolicyDAL.create(
{ {
@@ -88,8 +78,8 @@ export const secretApprovalPolicyServiceFactory = ({
tx tx
); );
await secretApprovalPolicyApproverDAL.insertMany( await secretApprovalPolicyApproverDAL.insertMany(
secretApprovers.map(({ id }) => ({ approverUserIds.map((approverUserId) => ({
approverId: id, approverUserId,
policyId: doc.id policyId: doc.id
})), })),
tx tx
@@ -100,7 +90,7 @@ export const secretApprovalPolicyServiceFactory = ({
}; };
const updateSecretApprovalPolicy = async ({ const updateSecretApprovalPolicy = async ({
approvers, approverUserIds,
secretPath, secretPath,
name, name,
actorId, actorId,
@@ -132,22 +122,11 @@ export const secretApprovalPolicyServiceFactory = ({
}, },
tx tx
); );
if (approvers) { if (approverUserIds) {
const secretApprovers = await projectMembershipDAL.find(
{
projectId: secretApprovalPolicy.projectId,
$in: { id: approvers }
},
{ tx }
);
if (secretApprovers.length !== approvers.length)
throw new BadRequestError({ message: "Approver not found in project" });
if (doc.approvals > secretApprovers.length)
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
await secretApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); await secretApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx);
await secretApprovalPolicyApproverDAL.insertMany( await secretApprovalPolicyApproverDAL.insertMany(
secretApprovers.map(({ id }) => ({ approverUserIds.map((approverUserId) => ({
approverId: id, approverUserId,
policyId: doc.id policyId: doc.id
})), })),
tx tx
@@ -4,7 +4,7 @@ export type TCreateSapDTO = {
approvals: number; approvals: number;
secretPath?: string | null; secretPath?: string | null;
environment: string; environment: string;
approvers: string[]; approverUserIds: string[];
projectId: string; projectId: string;
name: string; name: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@@ -13,7 +13,7 @@ export type TUpdateSapDTO = {
secretPolicyId: string; secretPolicyId: string;
approvals?: number; approvals?: number;
secretPath?: string | null; secretPath?: string | null;
approvers: string[]; approverUserIds: string[];
name?: string; name?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@@ -5,7 +5,8 @@ import {
SecretApprovalRequestsSchema, SecretApprovalRequestsSchema,
TableName, TableName,
TSecretApprovalRequests, TSecretApprovalRequests,
TSecretApprovalRequestsSecrets TSecretApprovalRequestsSecrets,
TUsers
} from "@app/db/schemas"; } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships, stripUndefinedInWhere, TFindFilter } from "@app/lib/knex"; import { ormify, selectAllTableCols, sqlNestRelationships, stripUndefinedInWhere, TFindFilter } from "@app/lib/knex";
@@ -16,7 +17,7 @@ export type TSecretApprovalRequestDALFactory = ReturnType<typeof secretApprovalR
type TFindQueryFilter = { type TFindQueryFilter = {
projectId: string; projectId: string;
membershipId: string; userId: string;
status?: RequestState; status?: RequestState;
environment?: string; environment?: string;
committer?: string; committer?: string;
@@ -37,27 +38,63 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalRequest}.policyId`,
`${TableName.SecretApprovalPolicy}.id` `${TableName.SecretApprovalPolicy}.id`
) )
.leftJoin<TUsers>(
db(TableName.Users).as("statusChangedByUser"),
`${TableName.SecretApprovalRequest}.statusChangedByUserId`,
`statusChangedByUser.id`
)
.join<TUsers>(
db(TableName.Users).as("committerUser"),
`${TableName.SecretApprovalRequest}.committerUserId`,
`committerUser.id`
)
.join( .join(
TableName.SecretApprovalPolicyApprover, TableName.SecretApprovalPolicyApprover,
`${TableName.SecretApprovalPolicy}.id`, `${TableName.SecretApprovalPolicy}.id`,
`${TableName.SecretApprovalPolicyApprover}.policyId` `${TableName.SecretApprovalPolicyApprover}.policyId`
) )
.join<TUsers>(
db(TableName.Users).as("secretApprovalPolicyApproverUser"),
`${TableName.SecretApprovalPolicyApprover}.approverUserId`,
"secretApprovalPolicyApproverUser.id"
)
.leftJoin( .leftJoin(
TableName.SecretApprovalRequestReviewer, TableName.SecretApprovalRequestReviewer,
`${TableName.SecretApprovalRequest}.id`, `${TableName.SecretApprovalRequest}.id`,
`${TableName.SecretApprovalRequestReviewer}.requestId` `${TableName.SecretApprovalRequestReviewer}.requestId`
) )
.leftJoin<TUsers>(
db(TableName.Users).as("secretApprovalReviewerUser"),
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
`secretApprovalReviewerUser.id`
)
.select(selectAllTableCols(TableName.SecretApprovalRequest)) .select(selectAllTableCols(TableName.SecretApprovalRequest))
.select( .select(
tx.ref("member").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerMemberId"), tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
tx.ref("firstName").withSchema("secretApprovalPolicyApproverUser").as("approverFirstName"),
tx.ref("lastName").withSchema("secretApprovalPolicyApproverUser").as("approverLastName"),
tx.ref("email").withSchema("statusChangedByUser").as("statusChangedByUserEmail"),
tx.ref("username").withSchema("statusChangedByUser").as("statusChangedByUserUsername"),
tx.ref("firstName").withSchema("statusChangedByUser").as("statusChangedByUserFirstName"),
tx.ref("lastName").withSchema("statusChangedByUser").as("statusChangedByUserLastName"),
tx.ref("email").withSchema("committerUser").as("committerUserEmail"),
tx.ref("username").withSchema("committerUser").as("committerUserUsername"),
tx.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
tx.ref("lastName").withSchema("committerUser").as("committerUserLastName"),
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
tx.ref("projectId").withSchema(TableName.Environment), tx.ref("projectId").withSchema(TableName.Environment),
tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")
tx.ref("approverId").withSchema(TableName.SecretApprovalPolicyApprover)
); );
const findById = async (id: string, tx?: Knex) => { const findById = async (id: string, tx?: Knex) => {
@@ -71,6 +108,22 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
...SecretApprovalRequestsSchema.parse(el), ...SecretApprovalRequestsSchema.parse(el),
projectId: el.projectId, projectId: el.projectId,
environment: el.environment, environment: el.environment,
statusChangedByUser: el.statusChangedByUserId
? {
userId: el.statusChangedByUserId,
email: el.statusChangedByUserEmail,
firstName: el.statusChangedByUserFirstName,
lastName: el.statusChangedByUserLastName,
username: el.statusChangedByUserUsername
}
: undefined,
committerUser: {
userId: el.committerUserId,
email: el.committerUserEmail,
firstName: el.committerUserFirstName,
lastName: el.committerUserLastName,
username: el.committerUserUsername
},
policy: { policy: {
id: el.policyId, id: el.policyId,
name: el.policyName, name: el.policyName,
@@ -80,11 +133,34 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
}), }),
childrenMapper: [ childrenMapper: [
{ {
key: "reviewerMemberId", key: "reviewerUserId",
label: "reviewers" as const, label: "reviewers" as const,
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) mapper: ({
reviewerUserId: userId,
reviewerStatus: status,
reviewerEmail: email,
reviewerLastName: lastName,
reviewerUsername: username,
reviewerFirstName: firstName
}) => (userId ? { userId, status, email, firstName, lastName, username } : undefined)
}, },
{ key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } {
key: "approverUserId",
label: "approvers" as const,
mapper: ({
approverUserId,
approverEmail: email,
approverUsername: username,
approverLastName: lastName,
approverFirstName: firstName
}) => ({
userId: approverUserId,
email,
firstName,
lastName,
username
})
}
] ]
}); });
if (!formatedDoc?.[0]) return; if (!formatedDoc?.[0]) return;
@@ -97,7 +173,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
} }
}; };
const findProjectRequestCount = async (projectId: string, membershipId: string, tx?: Knex) => { const findProjectRequestCount = async (projectId: string, userId: string, tx?: Knex) => {
try { try {
const docs = await (tx || db) const docs = await (tx || db)
.with( .with(
@@ -114,8 +190,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.andWhere( .andWhere(
(bd) => (bd) =>
void bd void bd
.where(`${TableName.SecretApprovalPolicyApprover}.approverId`, membershipId) .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
) )
.select("status", `${TableName.SecretApprovalRequest}.id`) .select("status", `${TableName.SecretApprovalRequest}.id`)
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
@@ -142,7 +218,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
}; };
const findByProjectId = async ( const findByProjectId = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, membershipId }: TFindQueryFilter, { status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter,
tx?: Knex tx?: Knex
) => { ) => {
try { try {
@@ -161,6 +237,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalPolicy}.id`, `${TableName.SecretApprovalPolicy}.id`,
`${TableName.SecretApprovalPolicyApprover}.policyId` `${TableName.SecretApprovalPolicyApprover}.policyId`
) )
.join<TUsers>(
db(TableName.Users).as("committerUser"),
`${TableName.SecretApprovalRequest}.committerUserId`,
`committerUser.id`
)
.leftJoin( .leftJoin(
TableName.SecretApprovalRequestReviewer, TableName.SecretApprovalRequestReviewer,
`${TableName.SecretApprovalRequest}.id`, `${TableName.SecretApprovalRequest}.id`,
@@ -176,20 +257,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
projectId, projectId,
[`${TableName.Environment}.slug` as "slug"]: environment, [`${TableName.Environment}.slug` as "slug"]: environment,
[`${TableName.SecretApprovalRequest}.status`]: status, [`${TableName.SecretApprovalRequest}.status`]: status,
committerId: committer committerUserId: committer
}) })
) )
.andWhere( .andWhere(
(bd) => (bd) =>
void bd void bd
.where(`${TableName.SecretApprovalPolicyApprover}.approverId`, membershipId) .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
) )
.select(selectAllTableCols(TableName.SecretApprovalRequest)) .select(selectAllTableCols(TableName.SecretApprovalRequest))
.select( .select(
db.ref("projectId").withSchema(TableName.Environment), db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("environment"), db.ref("slug").withSchema(TableName.Environment).as("environment"),
db.ref("id").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerMemberId"), db.ref("id").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerId"),
db.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
db.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), db.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
db.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), db.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
db.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), db.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
@@ -201,7 +283,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
), ),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("approverId").withSchema(TableName.SecretApprovalPolicyApprover) db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
db.ref("email").withSchema("committerUser").as("committerUserEmail"),
db.ref("username").withSchema("committerUser").as("committerUserUsername"),
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
) )
.orderBy("createdAt", "desc"); .orderBy("createdAt", "desc");
@@ -223,18 +309,26 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath secretPath: el.policySecretPath
},
committerUser: {
userId: el.committerUserId,
email: el.committerUserEmail,
firstName: el.committerUserFirstName,
lastName: el.committerUserLastName,
username: el.committerUserUsername
} }
}), }),
childrenMapper: [ childrenMapper: [
{ {
key: "reviewerMemberId", key: "reviewerId",
label: "reviewers" as const, label: "reviewers" as const,
mapper: ({ reviewerMemberId: member, reviewerStatus: s }) => (member ? { member, status: s } : undefined) mapper: ({ reviewerUserId, reviewerStatus: s }) =>
reviewerUserId ? { userId: reviewerUserId, status: s } : undefined
}, },
{ {
key: "approverId", key: "approverUserId",
label: "approvers" as const, label: "approvers" as const,
mapper: ({ approverId }) => approverId mapper: ({ approverUserId }) => approverUserId
}, },
{ {
key: "commitId", key: "commitId",
@@ -87,7 +87,7 @@ export const secretApprovalRequestServiceFactory = ({
const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => {
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
const { membership } = await permissionService.getProjectPermission( await permissionService.getProjectPermission(
actor as ActorType.USER, actor as ActorType.USER,
actorId, actorId,
projectId, projectId,
@@ -95,7 +95,7 @@ export const secretApprovalRequestServiceFactory = ({
actorOrgId actorOrgId
); );
const count = await secretApprovalRequestDAL.findProjectRequestCount(projectId, membership.id); const count = await secretApprovalRequestDAL.findProjectRequestCount(projectId, actorId);
return count; return count;
}; };
@@ -113,19 +113,13 @@ export const secretApprovalRequestServiceFactory = ({
}: TListApprovalsDTO) => { }: TListApprovalsDTO) => {
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
const { membership } = await permissionService.getProjectPermission( await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
const approvals = await secretApprovalRequestDAL.findByProjectId({ const approvals = await secretApprovalRequestDAL.findByProjectId({
projectId, projectId,
committer, committer,
environment, environment,
status, status,
membershipId: membership.id, userId: actorId,
limit, limit,
offset offset
}); });
@@ -145,7 +139,7 @@ export const secretApprovalRequestServiceFactory = ({
if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership, hasRole } = await permissionService.getProjectPermission( const { hasRole } = await permissionService.getProjectPermission(
actor, actor,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
@@ -154,8 +148,8 @@ export const secretApprovalRequestServiceFactory = ({
); );
if ( if (
!hasRole(ProjectMembershipRole.Admin) && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerUserId !== actorId &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find(({ userId }) => userId === actorId)
) { ) {
throw new UnauthorizedError({ message: "User has no access" }); throw new UnauthorizedError({ message: "User has no access" });
} }
@@ -180,7 +174,7 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership, hasRole } = await permissionService.getProjectPermission( const { hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
@@ -189,8 +183,8 @@ export const secretApprovalRequestServiceFactory = ({
); );
if ( if (
!hasRole(ProjectMembershipRole.Admin) && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerUserId !== actorId &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find(({ userId }) => userId === actorId)
) { ) {
throw new UnauthorizedError({ message: "User has no access" }); throw new UnauthorizedError({ message: "User has no access" });
} }
@@ -198,7 +192,7 @@ export const secretApprovalRequestServiceFactory = ({
const review = await secretApprovalRequestReviewerDAL.findOne( const review = await secretApprovalRequestReviewerDAL.findOne(
{ {
requestId: secretApprovalRequest.id, requestId: secretApprovalRequest.id,
member: membership.id reviewerUserId: actorId
}, },
tx tx
); );
@@ -207,7 +201,7 @@ export const secretApprovalRequestServiceFactory = ({
{ {
status, status,
requestId: secretApprovalRequest.id, requestId: secretApprovalRequest.id,
member: membership.id reviewerUserId: actorId
}, },
tx tx
); );
@@ -230,7 +224,7 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership, hasRole } = await permissionService.getProjectPermission( const { hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
@@ -239,8 +233,8 @@ export const secretApprovalRequestServiceFactory = ({
); );
if ( if (
!hasRole(ProjectMembershipRole.Admin) && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerUserId !== actorId &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find(({ userId }) => userId === actorId)
) { ) {
throw new UnauthorizedError({ message: "User has no access" }); throw new UnauthorizedError({ message: "User has no access" });
} }
@@ -253,7 +247,7 @@ export const secretApprovalRequestServiceFactory = ({
const updatedRequest = await secretApprovalRequestDAL.updateById(secretApprovalRequest.id, { const updatedRequest = await secretApprovalRequestDAL.updateById(secretApprovalRequest.id, {
status, status,
statusChangeBy: membership.id statusChangedByUserId: actorId
}); });
return { ...secretApprovalRequest, ...updatedRequest }; return { ...secretApprovalRequest, ...updatedRequest };
}; };
@@ -270,7 +264,7 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy, folderId, projectId } = secretApprovalRequest; const { policy, folderId, projectId } = secretApprovalRequest;
const { membership, hasRole } = await permissionService.getProjectPermission( const { hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
projectId, projectId,
@@ -280,19 +274,19 @@ export const secretApprovalRequestServiceFactory = ({
if ( if (
!hasRole(ProjectMembershipRole.Admin) && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerUserId !== actorId &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find(({ userId }) => userId === actorId)
) { ) {
throw new UnauthorizedError({ message: "User has no access" }); throw new UnauthorizedError({ message: "User has no access" });
} }
const reviewers = secretApprovalRequest.reviewers.reduce<Record<string, ApprovalStatus>>( const reviewers = secretApprovalRequest.reviewers.reduce<Record<string, ApprovalStatus>>(
(prev, curr) => ({ ...prev, [curr.member.toString()]: curr.status as ApprovalStatus }), (prev, curr) => ({ ...prev, [curr.userId.toString()]: curr.status as ApprovalStatus }),
{} {}
); );
const hasMinApproval = const hasMinApproval =
secretApprovalRequest.policy.approvals <= secretApprovalRequest.policy.approvals <=
secretApprovalRequest.policy.approvers.filter( secretApprovalRequest.policy.approvers.filter(
(approverId) => reviewers[approverId.toString()] === ApprovalStatus.APPROVED ({ userId: approverId }) => reviewers[approverId.toString()] === ApprovalStatus.APPROVED
).length; ).length;
if (!hasMinApproval) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); if (!hasMinApproval) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" });
@@ -472,7 +466,7 @@ export const secretApprovalRequestServiceFactory = ({
conflicts: JSON.stringify(conflicts), conflicts: JSON.stringify(conflicts),
hasMerged: true, hasMerged: true,
status: RequestState.Closed, status: RequestState.Closed,
statusChangeBy: membership.id statusChangedByUserId: actorId
}, },
tx tx
); );
@@ -509,7 +503,7 @@ export const secretApprovalRequestServiceFactory = ({
}: TGenerateSecretApprovalRequestDTO) => { }: TGenerateSecretApprovalRequestDTO) => {
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
const { permission, membership } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
actorId, actorId,
projectId, projectId,
@@ -663,7 +657,7 @@ export const secretApprovalRequestServiceFactory = ({
policyId: policy.id, policyId: policy.id,
status: "open", status: "open",
hasMerged: false, hasMerged: false,
committerId: membership.id committerUserId: actorId
}, },
tx tx
); );
@@ -11,7 +11,6 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
import { QueueName, TQueueServiceFactory } from "@app/queue"; import { QueueName, TQueueServiceFactory } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal"; import { TSecretDALFactory } from "@app/services/secret/secret-dal";
import { fnSecretBulkInsert, fnSecretBulkUpdate } from "@app/services/secret/secret-fns"; import { fnSecretBulkInsert, fnSecretBulkUpdate } from "@app/services/secret/secret-fns";
import { TSecretQueueFactory, uniqueSecretQueueKey } from "@app/services/secret/secret-queue"; import { TSecretQueueFactory, uniqueSecretQueueKey } from "@app/services/secret/secret-queue";
@@ -46,7 +45,6 @@ type TSecretReplicationServiceFactoryDep = {
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">; secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret" | "find">; secretTagDAL: Pick<TSecretTagDALFactory, "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret" | "find">;
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">; secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findOne">;
secretApprovalRequestSecretDAL: Pick< secretApprovalRequestSecretDAL: Pick<
TSecretApprovalRequestSecretDALFactory, TSecretApprovalRequestSecretDALFactory,
"insertMany" | "insertApprovalSecretTags" "insertMany" | "insertApprovalSecretTags"
@@ -92,7 +90,6 @@ export const secretReplicationServiceFactory = ({
secretApprovalRequestSecretDAL, secretApprovalRequestSecretDAL,
secretApprovalRequestDAL, secretApprovalRequestDAL,
secretQueueService, secretQueueService,
projectMembershipDAL,
projectBotService projectBotService
}: TSecretReplicationServiceFactoryDep) => { }: TSecretReplicationServiceFactoryDep) => {
const getReplicatedSecrets = ( const getReplicatedSecrets = (
@@ -297,12 +294,6 @@ export const secretReplicationServiceFactory = ({
); );
// this means it should be a approval request rather than direct replication // this means it should be a approval request rather than direct replication
if (policy && actor === ActorType.USER) { if (policy && actor === ActorType.USER) {
const membership = await projectMembershipDAL.findOne({ projectId, userId: actorId });
if (!membership) {
logger.error("Project membership not found in %s for user %s", projectId, actorId);
return;
}
const localSecretsLatestVersions = destinationLocalSecrets.map(({ id }) => id); const localSecretsLatestVersions = destinationLocalSecrets.map(({ id }) => id);
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany( const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(
destinationReplicationFolderId, destinationReplicationFolderId,
@@ -316,7 +307,7 @@ export const secretReplicationServiceFactory = ({
policyId: policy.id, policyId: policy.id,
status: "open", status: "open",
hasMerged: false, hasMerged: false,
committerId: membership.id, committerUserId: actorId,
isReplicated: true isReplicated: true
}, },
tx tx
-2
View File
@@ -319,7 +319,6 @@ export const registerRoutes = async (
auditLogStreamDAL auditLogStreamDAL
}); });
const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({
projectMembershipDAL,
projectEnvDAL, projectEnvDAL,
secretApprovalPolicyApproverDAL: sapApproverDAL, secretApprovalPolicyApproverDAL: sapApproverDAL,
permissionService, permissionService,
@@ -768,7 +767,6 @@ export const registerRoutes = async (
secretApprovalRequestDAL, secretApprovalRequestDAL,
secretApprovalRequestSecretDAL, secretApprovalRequestSecretDAL,
secretQueueService, secretQueueService,
projectMembershipDAL,
projectBotService projectBotService
}); });
const secretRotationQueue = secretRotationQueueFactory({ const secretRotationQueue = secretRotationQueueFactory({
@@ -949,7 +949,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.SECRET_APPROVAL_REQUEST, type: EventType.SECRET_APPROVAL_REQUEST,
metadata: { metadata: {
committedBy: approval.committerId, committedBy: approval.committerUserId,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
} }
@@ -1133,7 +1133,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.SECRET_APPROVAL_REQUEST, type: EventType.SECRET_APPROVAL_REQUEST,
metadata: { metadata: {
committedBy: approval.committerId, committedBy: approval.committerUserId,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
} }
@@ -1271,7 +1271,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.SECRET_APPROVAL_REQUEST, type: EventType.SECRET_APPROVAL_REQUEST,
metadata: { metadata: {
committedBy: approval.committerId, committedBy: approval.committerUserId,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
} }
@@ -1397,7 +1397,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.SECRET_APPROVAL_REQUEST, type: EventType.SECRET_APPROVAL_REQUEST,
metadata: { metadata: {
committedBy: approval.committerId, committedBy: approval.committerUserId,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
} }
@@ -1524,7 +1524,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.SECRET_APPROVAL_REQUEST, type: EventType.SECRET_APPROVAL_REQUEST,
metadata: { metadata: {
committedBy: approval.committerId, committedBy: approval.committerUserId,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
} }
@@ -1638,7 +1638,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
event: { event: {
type: EventType.SECRET_APPROVAL_REQUEST, type: EventType.SECRET_APPROVAL_REQUEST,
metadata: { metadata: {
committedBy: approval.committerId, committedBy: approval.committerUserId,
secretApprovalRequestId: approval.id, secretApprovalRequestId: approval.id,
secretApprovalRequestSlug: approval.slug secretApprovalRequestSlug: approval.slug
} }
@@ -9,12 +9,12 @@ export const useCreateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretPolicyDTO>({ return useMutation<{}, {}, TCreateSecretPolicyDTO>({
mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name }) => { mutationFn: async ({ environment, workspaceId, approvals, approverUserIds, secretPath, name }) => {
const { data } = await apiRequest.post("/api/v1/secret-approvals", { const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment, environment,
workspaceId, workspaceId,
approvals, approvals,
approvers, approverUserIds,
secretPath, secretPath,
name name
}); });
@@ -30,10 +30,10 @@ export const useUpdateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ return useMutation<{}, {}, TUpdateSecretPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, secretPath, name }) => { mutationFn: async ({ id, approverUserIds, approvals, secretPath, name }) => {
const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, {
approvals, approvals,
approvers, approverUserIds,
secretPath, secretPath,
name name
}); });
@@ -7,8 +7,8 @@ export type TSecretApprovalPolicy = {
envId: string; envId: string;
environment: WorkspaceEnv; environment: WorkspaceEnv;
secretPath?: string; secretPath?: string;
approvers: string[];
approvals: number; approvals: number;
userApprovers: { userId: string }[];
}; };
export type TGetSecretApprovalPoliciesDTO = { export type TGetSecretApprovalPoliciesDTO = {
@@ -26,14 +26,14 @@ export type TCreateSecretPolicyDTO = {
name?: string; name?: string;
environment: string; environment: string;
secretPath?: string | null; secretPath?: string | null;
approvers?: string[]; approverUserIds?: string[];
approvals?: number; approvals?: number;
}; };
export type TUpdateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = {
id: string; id: string;
name?: string; name?: string;
approvers?: string[]; approverUserIds?: string[];
secretPath?: string | null; secretPath?: string | null;
approvals?: number; approvals?: number;
// for invalidating list // for invalidating list
@@ -47,10 +47,14 @@ export type TSecretApprovalRequest<J extends unknown = EncryptedSecret> = {
isReplicated?: boolean; isReplicated?: boolean;
slug: string; slug: string;
createdAt: string; createdAt: string;
committerId: string; committerUserId: string;
reviewers: { reviewers: {
member: string; userId: string;
status: ApprovalStatus; status: ApprovalStatus;
email: string;
firstName: string;
lastName: string;
username: string;
}[]; }[];
workspace: string; workspace: string;
environment: string; environment: string;
@@ -58,8 +62,30 @@ export type TSecretApprovalRequest<J extends unknown = EncryptedSecret> = {
secretPath: string; secretPath: string;
hasMerged: boolean; hasMerged: boolean;
status: "open" | "close"; status: "open" | "close";
policy: TSecretApprovalPolicy; policy: Omit<TSecretApprovalPolicy, "approvers"> & {
statusChangeBy: string; approvers: {
userId: string;
email: string;
firstName: string;
lastName: string;
username: string;
}[];
};
statusChangedByUserId: string;
statusChangedByUser?: {
userId: string;
email: string;
firstName: string;
lastName: string;
username: string;
};
committerUser: {
userId: string;
email: string;
firstName: string;
lastName: string;
username: string;
};
conflicts: Array<{ secretId: string; op: CommitType.UPDATE }>; conflicts: Array<{ secretId: string; op: CommitType.UPDATE }>;
commits: ({ commits: ({
// if there is no secret means it was creation // if there is no secret means it was creation
@@ -7,6 +7,8 @@ import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
Modal,
ModalContent,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -145,13 +147,20 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
<SecretPolicyForm <Modal
workspaceId={workspaceId}
isOpen={popUp.secretPolicyForm.isOpen} isOpen={popUp.secretPolicyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
members={members} >
editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy} <ModalContent title={popUp.secretPolicyForm.data ? "Edit policy" : "Create policy"}>
/> <SecretPolicyForm
workspaceId={workspaceId}
isOpen={popUp.secretPolicyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
members={members}
editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy}
/>
</ModalContent>
</Modal>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deletePolicy.isOpen} isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -51,7 +51,7 @@ export const SecretApprovalPolicyRow = ({
{ {
workspaceId, workspaceId,
id: policy.id, id: policy.id,
approvers: selectedApprovers approverUserIds: selectedApprovers
}, },
{ {
onSettled: () => { onSettled: () => {
@@ -60,7 +60,7 @@ export const SecretApprovalPolicyRow = ({
} }
); );
} else { } else {
setSelectedApprovers(policy.approvers); setSelectedApprovers(policy.userApprovers.map(({ userId }) => userId));
} }
}} }}
> >
@@ -73,7 +73,9 @@ export const SecretApprovalPolicyRow = ({
> >
<Input <Input
isReadOnly isReadOnly
value={policy.approvers?.length ? `${policy.approvers.length} selected` : "None"} value={
policy?.userApprovers.length ? `${policy.userApprovers.length} selected` : "None"
}
className="text-left" className="text-left"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -84,17 +86,17 @@ export const SecretApprovalPolicyRow = ({
<DropdownMenuLabel> <DropdownMenuLabel>
Select members that are allowed to approve changes Select members that are allowed to approve changes
</DropdownMenuLabel> </DropdownMenuLabel>
{members?.map(({ id, user }) => { {members?.map(({ user }) => {
const isChecked = selectedApprovers.includes(id); const isChecked = selectedApprovers.includes(user.id);
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
evt.preventDefault(); evt.preventDefault();
setSelectedApprovers((state) => setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el !== id) : [...state, id] isChecked ? state.filter((el) => el !== user.id) : [...state, user.id]
); );
}} }}
key={`create-policy-members-${id}`} key={`create-policy-members-${user.id}`}
iconPos="right" iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />} icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
> >
@@ -1,4 +1,3 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -15,8 +14,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
FormControl, FormControl,
Input, Input,
Modal,
ModalContent,
Select, Select,
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
@@ -40,9 +37,9 @@ const formSchema = z
name: z.string().optional(), name: z.string().optional(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
approvals: z.number().min(1), approvals: z.number().min(1),
approvers: z.string().array().min(1) approverUserIds: z.string().array().min(1)
}) })
.refine((data) => data.approvals <= data.approvers.length, { .refine((data) => data.approvals <= data.approverUserIds.length, {
path: ["approvals"], path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers." message: "The number of approvals should be lower than the number of approvers."
}); });
@@ -50,7 +47,6 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
export const SecretPolicyForm = ({ export const SecretPolicyForm = ({
isOpen,
onToggle, onToggle,
members = [], members = [],
workspaceId, workspaceId,
@@ -59,20 +55,22 @@ export const SecretPolicyForm = ({
const { const {
control, control,
handleSubmit, handleSubmit,
reset,
watch, watch,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined values: editValues
? {
...editValues,
approverUserIds: editValues.userApprovers.map(({ userId }) => userId),
environment: editValues.environment.slug
}
: undefined
}); });
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const selectedEnvironment = watch("environment"); const selectedEnvironment = watch("environment");
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
useEffect(() => {
if (!isOpen) reset({});
}, [isOpen]);
const isEditMode = Boolean(editValues); const isEditMode = Boolean(editValues);
@@ -131,8 +129,6 @@ export const SecretPolicyForm = ({
}; };
return ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? "Edit policy" : "Create policy"}>
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller <Controller
control={control} control={control}
@@ -155,6 +151,7 @@ export const SecretPolicyForm = ({
errorText={error?.message} errorText={error?.message}
> >
<Select <Select
isDisabled={isEditMode}
value={value} value={value}
onValueChange={(val) => onChange(val)} onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500" className="w-full border border-mineshaft-500"
@@ -186,7 +183,7 @@ export const SecretPolicyForm = ({
/> />
<Controller <Controller
control={control} control={control}
name="approvers" name="approverUserIds"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Approvers Required" label="Approvers Required"
@@ -208,17 +205,19 @@ export const SecretPolicyForm = ({
<DropdownMenuLabel> <DropdownMenuLabel>
Select members that are allowed to approve changes Select members that are allowed to approve changes
</DropdownMenuLabel> </DropdownMenuLabel>
{members.map(({ id, user }) => { {members.map(({ user }) => {
const isChecked = value?.includes(id); const isChecked = value?.includes(user.id);
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
evt.preventDefault(); evt.preventDefault();
onChange( onChange(
isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] isChecked
? value?.filter((el) => el !== user.id)
: [...(value || []), user.id]
); );
}} }}
key={`create-policy-members-${id}`} key={`create-policy-members-${user.id}`}
iconPos="right" iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />} icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
> >
@@ -258,7 +257,6 @@ export const SecretPolicyForm = ({
</Button> </Button>
</div> </div>
</form> </form>
</ModalContent>
</Modal>
); );
}; };
@@ -31,7 +31,7 @@ import {
useGetSecretApprovalRequests, useGetSecretApprovalRequests,
useGetWorkspaceUsers useGetWorkspaceUsers
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ApprovalStatus, TSecretApprovalRequest, TWorkspaceUser } from "@app/hooks/api/types"; import { ApprovalStatus, TSecretApprovalRequest } from "@app/hooks/api/types";
import { import {
generateCommitText, generateCommitText,
@@ -63,14 +63,9 @@ export const SecretApprovalRequest = () => {
}); });
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId }); useGetSecretApprovalRequestCount({ workspaceId });
const { user: presentUser } = useUser(); const { user: userSession } = useUser();
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { data: members } = useGetWorkspaceUsers(workspaceId); const { data: members } = useGetWorkspaceUsers(workspaceId);
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
(prev, curr) => ({ ...prev, [curr.id]: curr }),
{}
);
const myMembershipId = members?.find(({ user }) => user.id === presentUser?.id)?.id;
const isSecretApprovalScreen = Boolean(selectedApproval); const isSecretApprovalScreen = Boolean(selectedApproval);
const handleGoBackSecretRequestDetail = () => { const handleGoBackSecretRequestDetail = () => {
@@ -93,10 +88,8 @@ export const SecretApprovalRequest = () => {
> >
<SecretApprovalRequestChanges <SecretApprovalRequestChanges
workspaceId={workspaceId} workspaceId={workspaceId}
members={membersGroupById}
approvalRequestId={selectedApproval?.id || ""} approvalRequestId={selectedApproval?.id || ""}
onGoBack={handleGoBackSecretRequestDetail} onGoBack={handleGoBackSecretRequestDetail}
committer={membersGroupById?.[selectedApproval?.committerId || ""]}
/> />
</motion.div> </motion.div>
) : ( ) : (
@@ -182,10 +175,12 @@ export const SecretApprovalRequest = () => {
{members?.map(({ user, id }) => ( {members?.map(({ user, id }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
setCommitterFilter((state) => (state === id ? undefined : id)) setCommitterFilter((state) => (state === user.id ? undefined : user.id))
} }
key={`request-filter-member-${id}`} key={`request-filter-member-${id}`}
icon={committerFilter === id && <FontAwesomeIcon icon={faCheckCircle} />} icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right" iconPos="right"
> >
{user.username} {user.username}
@@ -208,19 +203,16 @@ export const SecretApprovalRequest = () => {
const { const {
id: reqId, id: reqId,
commits, commits,
committerId,
createdAt, createdAt,
policy,
reviewers, reviewers,
status, status,
committerUser,
isReplicated: isReplication isReplicated: isReplication
} = secretApproval; } = secretApproval;
const isApprover = policy?.approvers?.indexOf(myMembershipId || "") !== -1; const isReviewed = reviewers.some(
const isReviewed = ({ status: reviewStatus, userId }) =>
reviewers.findIndex( userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
({ member, status: reviewStatus }) => );
member === myMembershipId && reviewStatus === ApprovalStatus.APPROVED
) !== -1;
return ( return (
<div <div
key={reqId} key={reqId}
@@ -239,11 +231,9 @@ export const SecretApprovalRequest = () => {
</div> </div>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{membersGroupById?.[committerId]?.user?.firstName}{" "} {committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{membersGroupById?.[committerId]?.user?.lastName} ( {committerUser?.email}){isReplication && " via replication"}
{membersGroupById?.[committerId]?.user?.email}) {!isReviewed && status === "open" && " - Review required"}
{isReplication && " via replication"}
{isApprover && !isReviewed && status === "open" && " - Review required"}
</span> </span>
</div> </div>
); );
@@ -23,7 +23,7 @@ type Props = {
status: "close" | "open"; status: "close" | "open";
approvals: number; approvals: number;
canApprove?: boolean; canApprove?: boolean;
statusChangeByEmail: string; statusChangeByEmail?: string;
workspaceId: string; workspaceId: string;
}; };
@@ -19,7 +19,7 @@ import {
useGetUserWsKey, useGetUserWsKey,
useUpdateSecretApprovalReviewStatus useUpdateSecretApprovalReviewStatus
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; import { ApprovalStatus, CommitType } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string"; import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
@@ -73,18 +73,14 @@ type Props = {
workspaceId: string; workspaceId: string;
approvalRequestId: string; approvalRequestId: string;
onGoBack: () => void; onGoBack: () => void;
committer?: TWorkspaceUser;
members?: Record<string, TWorkspaceUser>;
}; };
export const SecretApprovalRequestChanges = ({ export const SecretApprovalRequestChanges = ({
approvalRequestId, approvalRequestId,
onGoBack, onGoBack,
committer, workspaceId
workspaceId,
members = {}
}: Props) => { }: Props) => {
const { user } = useUser(); const { user: userSession } = useUser();
const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
const { const {
data: secretApprovalRequestDetails, data: secretApprovalRequestDetails,
@@ -105,22 +101,20 @@ export const SecretApprovalRequestChanges = ({
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
// membership of present user // membership of present user
const myMembership = Object.values(members).find( const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some(
({ user: membershipUser }) => membershipUser.email === user.email ({ userId }) => userId === userSession.id
); );
const myMembershipId = myMembership?.id || ""; const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce<
const canApprove = secretApprovalRequestDetails?.policy?.approvers?.includes(myMembershipId);
const reviewedMembers = secretApprovalRequestDetails?.reviewers?.reduce<
Record<string, ApprovalStatus> Record<string, ApprovalStatus>
>( >(
(prev, curr) => ({ (prev, curr) => ({
...prev, ...prev,
[curr.member]: curr.status [curr.userId]: curr.status
}), }),
{} {}
); );
const hasApproved = reviewedMembers?.[myMembershipId] === ApprovalStatus.APPROVED; const hasApproved = reviewedUsers?.[userSession.id] === ApprovalStatus.APPROVED;
const hasRejected = reviewedMembers?.[myMembershipId] === ApprovalStatus.REJECTED; const hasRejected = reviewedUsers?.[userSession.id] === ApprovalStatus.REJECTED;
const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => { const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => {
try { try {
@@ -159,7 +153,7 @@ export const SecretApprovalRequestChanges = ({
const isMergable = const isMergable =
secretApprovalRequestDetails?.policy?.approvals <= secretApprovalRequestDetails?.policy?.approvals <=
secretApprovalRequestDetails?.policy?.approvers?.filter( secretApprovalRequestDetails?.policy?.approvers?.filter(
(approverId) => reviewedMembers?.[approverId] === ApprovalStatus.APPROVED ({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED
).length; ).length;
const hasMerged = secretApprovalRequestDetails?.hasMerged; const hasMerged = secretApprovalRequestDetails?.hasMerged;
@@ -191,8 +185,9 @@ export const SecretApprovalRequestChanges = ({
)} )}
</div> </div>
<div className="flex items-center text-sm text-bunker-300"> <div className="flex items-center text-sm text-bunker-300">
{committer?.user?.firstName} {secretApprovalRequestDetails?.committerUser?.firstName || ""}
{committer?.user?.lastName} ({committer?.user?.email}) wants to change{" "} {secretApprovalRequestDetails?.committerUser?.lastName || ""} (
{secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "}
{secretApprovalRequestDetails.commits.length} secret values in {secretApprovalRequestDetails.commits.length} secret values in
<span className="mx-1 rounded bg-primary-600/60 px-1 text-primary-300"> <span className="mx-1 rounded bg-primary-600/60 px-1 text-primary-300">
{secretApprovalRequestDetails.environment} {secretApprovalRequestDetails.environment}
@@ -256,9 +251,7 @@ export const SecretApprovalRequestChanges = ({
approvals={secretApprovalRequestDetails.policy.approvals || 0} approvals={secretApprovalRequestDetails.policy.approvals || 0}
status={secretApprovalRequestDetails.status} status={secretApprovalRequestDetails.status}
isMergable={isMergable} isMergable={isMergable}
statusChangeByEmail={ statusChangeByEmail={secretApprovalRequestDetails.statusChangedByUser?.email}
members[secretApprovalRequestDetails?.statusChangeBy || ""]?.user?.email || ""
}
workspaceId={workspaceId} workspaceId={workspaceId}
/> />
</div> </div>
@@ -266,17 +259,19 @@ export const SecretApprovalRequestChanges = ({
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}> <div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div> <div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm"> <div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApproverId) => { {secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => {
const userDetails = members?.[requiredApproverId]?.user; const status = reviewedUsers?.[requiredApprover.userId];
const status = reviewedMembers?.[requiredApproverId];
return ( return (
<div <div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1" className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${requiredApproverId}`} key={`required-approver-${requiredApprover.userId}`}
> >
<div className="flex-grow text-sm"> <div className="flex-grow text-sm">
<Tooltip content={`${userDetails.firstName} ${userDetails.lastName}`}> <Tooltip
<span>{userDetails?.email} </span> content={`${requiredApprover.firstName || ""} ${requiredApprover.lastName || ""
}`}
>
<span>{requiredApprover?.email} </span>
</Tooltip> </Tooltip>
<span className="text-red">*</span> <span className="text-red">*</span>
</div> </div>
@@ -290,19 +285,21 @@ export const SecretApprovalRequestChanges = ({
})} })}
{secretApprovalRequestDetails?.reviewers {secretApprovalRequestDetails?.reviewers
.filter( .filter(
({ member }) => !secretApprovalRequestDetails?.policy?.approvers?.includes(member) (reviewer) =>
!secretApprovalRequestDetails?.policy?.approvers?.some(
({ userId }) => userId === reviewer.userId
)
) )
.map((reviewer) => { .map((reviewer) => {
const userDetails = members?.[reviewer.member]?.user; const status = reviewedUsers?.[reviewer.userId];
const status = reviewedMembers?.[reviewer.status];
return ( return (
<div <div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1" className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${reviewer.member}`} key={`required-approver-${reviewer.userId}`}
> >
<div className="flex-grow text-sm"> <div className="flex-grow text-sm">
<Tooltip content={`${userDetails.firstName} ${userDetails.lastName}`}> <Tooltip content={`${reviewer.firstName || ""} ${reviewer.lastName || ""}`}>
<span>{userDetails?.email} </span> <span>{reviewer?.email} </span>
</Tooltip> </Tooltip>
<span className="text-red">*</span> <span className="text-red">*</span>
</div> </div>