mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 11:28:43 +00:00
feat(infisical-pg): completed secret approval policy and services for approval requests
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { registerOrgRoleRouter } from "./org-role-router";
|
||||
import { registerProjectRoleRouter } from "./project-role-router";
|
||||
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
|
||||
|
||||
export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
// org role starts with organization
|
||||
await server.register(registerOrgRoleRouter, { prefix: "/organization" });
|
||||
await server.register(registerProjectRoleRouter, { prefix: "/workspace" });
|
||||
await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
|
||||
export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
name: z.string().optional(),
|
||||
environment: z.string(),
|
||||
secretPath: z.string().optional().nullable(),
|
||||
approvers: z.string().array().min(1),
|
||||
approvals: z.number().min(1).default(1)
|
||||
})
|
||||
.refine((data) => data.approvals <= data.approvers.length, {
|
||||
path: ["approvals"],
|
||||
message: "The number of approvals should be lower than the number of approvers."
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
approval: sapPubSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const approval = await server.services.secretApprovalPolicy.createSap({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
projectId: req.body.workspaceId,
|
||||
...req.body,
|
||||
name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`
|
||||
});
|
||||
return { approval };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:sapId",
|
||||
method: "PATCH",
|
||||
schema: {
|
||||
params: z.object({
|
||||
sapId: z.string()
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
approvers: z.string().array().min(1),
|
||||
approvals: z.number().min(1).default(1),
|
||||
secretPath: z.string().optional().nullable()
|
||||
})
|
||||
.refine((data) => data.approvals <= data.approvers.length, {
|
||||
path: ["approvals"],
|
||||
message: "The number of approvals should be lower than the number of approvers."
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
approval: sapPubSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const approval = await server.services.secretApprovalPolicy.updateSap({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
...req.body,
|
||||
secretPolicyId: req.params.sapId
|
||||
});
|
||||
return { approval };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:sapId",
|
||||
method: "DELETE",
|
||||
schema: {
|
||||
params: z.object({
|
||||
sapId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
approval: sapPubSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const approval = await server.services.secretApprovalPolicy.deleteSap({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
secretPolicyId: req.params.sapId
|
||||
});
|
||||
return { approval };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
workspaceId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
approvals: sapPubSchema.merge(z.object({approvers:z.string().array()})).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const approvals = await server.services.secretApprovalPolicy.getSapByProjectId({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
projectId: req.query.workspaceId
|
||||
});
|
||||
return { approvals };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/board",
|
||||
method: "GET",
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
policy: sapPubSchema.merge(z.object({approvers:z.string().array()})).optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const policy = await server.services.secretApprovalPolicy.getSapOfFolder({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
projectId: req.query.workspaceId,
|
||||
...req.query
|
||||
});
|
||||
return { policy };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSapApproverDalFactory = ReturnType<typeof sapApproverDalFactory>;
|
||||
|
||||
export const sapApproverDalFactory = (db: TDbClient) => {
|
||||
const sapApproverOrm = ormify(db, TableName.SapApprover);
|
||||
return sapApproverOrm;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TSecretApprovalPolicies, TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import {
|
||||
TFindFilter,
|
||||
buildFindFilter,
|
||||
mergeOneToManyRelation,
|
||||
ormify,
|
||||
selectAllTableCols
|
||||
} from "@app/lib/knex";
|
||||
import { Knex } from "knex";
|
||||
|
||||
export type TSecretApprovalPolicyDalFactory = ReturnType<typeof secretApprovalPolicyDalFactory>;
|
||||
|
||||
export const secretApprovalPolicyDalFactory = (db: TDbClient) => {
|
||||
const secretApprovalPolicyOrm = ormify(db, TableName.SecretApprovalPolicy);
|
||||
|
||||
const sapFindQuery = (tx: Knex, filter: TFindFilter<TSecretApprovalPolicies>) =>
|
||||
tx(TableName.SecretApprovalPolicy)
|
||||
.where(buildFindFilter(filter))
|
||||
.join(
|
||||
TableName.Environment,
|
||||
`${TableName.SecretApprovalPolicy}.envId`,
|
||||
`${TableName.Environment}.id`
|
||||
)
|
||||
.join(
|
||||
TableName.SapApprover,
|
||||
`${TableName.SecretApprovalPolicy}.id`,
|
||||
`${TableName.SapApprover}.policyId`
|
||||
)
|
||||
.select(tx.ref("approverId").withSchema(TableName.SapApprover))
|
||||
.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.SecretApprovalPolicy))
|
||||
.orderBy("createdAt", "asc");
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const doc = await sapFindQuery(tx || db, {
|
||||
[`${TableName.SecretApprovalPolicy}.id` as "id"]: id
|
||||
});
|
||||
const formatedDoc = mergeOneToManyRelation(
|
||||
doc,
|
||||
"id",
|
||||
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
|
||||
...el,
|
||||
envId,
|
||||
environment: { id: envId, name, slug }
|
||||
}),
|
||||
({ approverId }) => approverId,
|
||||
"approvers"
|
||||
);
|
||||
return formatedDoc?.[0];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindById" });
|
||||
}
|
||||
};
|
||||
|
||||
const find = async (
|
||||
filter: TFindFilter<TSecretApprovalPolicies & { projectId: string }>,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const docs = await sapFindQuery(tx || db, filter);
|
||||
const formatedDoc = mergeOneToManyRelation(
|
||||
docs,
|
||||
"id",
|
||||
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
|
||||
...el,
|
||||
envId,
|
||||
environment: { id: envId, name, slug }
|
||||
}),
|
||||
({ approverId }) => approverId,
|
||||
"approvers"
|
||||
);
|
||||
return formatedDoc;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretApprovalPolicyOrm, findById, find };
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { TSecretApprovalPolicyDalFactory } from "./secret-approval-policy-dal";
|
||||
import { TSapApproverDalFactory } from "./sap-approver-dal";
|
||||
import {
|
||||
TCreateSapDTO,
|
||||
TDeleteSapDTO,
|
||||
TGetBoardSapDTO,
|
||||
TListSapDTO,
|
||||
TUpdateSapDTO
|
||||
} from "./secret-approval-policy-types";
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import picomatch from "picomatch";
|
||||
import { containsGlobPatterns } from "@app/lib/picomatch";
|
||||
import { TProjectMembershipDalFactory } from "@app/services/project-membership/project-membership-dal";
|
||||
import { TProjectEnvDalFactory } from "@app/services/project-env/project-env-dal";
|
||||
|
||||
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;
|
||||
|
||||
type TSecretApprovalPolicyServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
secretApprovalPolicyDal: TSecretApprovalPolicyDalFactory;
|
||||
projectEnvDal: Pick<TProjectEnvDalFactory, "findOne">;
|
||||
sapApproverDal: TSapApproverDalFactory;
|
||||
projectMembershipDal: Pick<TProjectMembershipDalFactory, "find">;
|
||||
};
|
||||
|
||||
export type TSecretApprovalPolicyServiceFactory = ReturnType<
|
||||
typeof secretApprovalPolicyServiceFactory
|
||||
>;
|
||||
|
||||
export const secretApprovalPolicyServiceFactory = ({
|
||||
secretApprovalPolicyDal,
|
||||
permissionService,
|
||||
sapApproverDal,
|
||||
projectEnvDal,
|
||||
projectMembershipDal
|
||||
}: TSecretApprovalPolicyServiceFactoryDep) => {
|
||||
const createSap = async ({
|
||||
name,
|
||||
actor,
|
||||
actorId,
|
||||
approvals,
|
||||
approvers,
|
||||
projectId,
|
||||
secretPath,
|
||||
environment
|
||||
}: TCreateSapDTO) => {
|
||||
if (approvals > approvers.length)
|
||||
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
const env = await projectEnvDal.findOne({ slug: environment, projectId });
|
||||
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 doc = await secretApprovalPolicyDal.create(
|
||||
{
|
||||
envId: env.id,
|
||||
approvals,
|
||||
secretPath,
|
||||
name
|
||||
},
|
||||
tx
|
||||
);
|
||||
await sapApproverDal.insertMany(
|
||||
secretApprovers.map(({ id }) => ({
|
||||
approverId: id,
|
||||
policyId: doc.id
|
||||
})),
|
||||
tx
|
||||
);
|
||||
return doc;
|
||||
});
|
||||
return { ...secretApproval, environment: env, projectId };
|
||||
};
|
||||
|
||||
const updateSap = async ({
|
||||
approvers,
|
||||
secretPath,
|
||||
name,
|
||||
actorId,
|
||||
actor,
|
||||
approvals,
|
||||
secretPolicyId
|
||||
}: TUpdateSapDTO) => {
|
||||
const secretApprovalPolicy = await secretApprovalPolicyDal.findById(secretPolicyId);
|
||||
if (!secretApprovalPolicy)
|
||||
throw new BadRequestError({ message: "Secret approval policy not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
secretApprovalPolicy.projectId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const updatedSap = await secretApprovalPolicyDal.transaction(async (tx) => {
|
||||
const doc = await secretApprovalPolicyDal.updateById(
|
||||
secretApprovalPolicy.id,
|
||||
{
|
||||
approvals,
|
||||
secretPath,
|
||||
name
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (approvers) {
|
||||
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 sapApproverDal.delete({ policyId: doc.id }, tx);
|
||||
await sapApproverDal.insertMany(
|
||||
secretApprovers.map(({ id }) => ({
|
||||
approverId: id,
|
||||
policyId: doc.id
|
||||
})),
|
||||
tx
|
||||
);
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
return {
|
||||
...updatedSap,
|
||||
environment: secretApprovalPolicy.environment,
|
||||
projectId: secretApprovalPolicy.projectId
|
||||
};
|
||||
};
|
||||
|
||||
const deleteSap = async ({ secretPolicyId, actor, actorId }: TDeleteSapDTO) => {
|
||||
const sapPolicy = await secretApprovalPolicyDal.findById(secretPolicyId);
|
||||
if (!sapPolicy) throw new BadRequestError({ message: "Secret approval policy not found" });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
sapPolicy.projectId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
await secretApprovalPolicyDal.deleteById(secretPolicyId);
|
||||
return sapPolicy;
|
||||
};
|
||||
|
||||
const getSapByProjectId = async ({ actorId, actor, projectId }: TListSapDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const sapPolicies = await secretApprovalPolicyDal.find({ projectId });
|
||||
return sapPolicies;
|
||||
};
|
||||
|
||||
const getSapPolicy = async (projectId: string, environment: string, secretPath: string) => {
|
||||
const env = await projectEnvDal.findOne({ slug: environment, projectId });
|
||||
if (!env) throw new BadRequestError({ message: "Environment not found" });
|
||||
|
||||
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 })
|
||||
);
|
||||
// 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 finalPolicy = policiesByPriority.shift();
|
||||
return finalPolicy;
|
||||
};
|
||||
|
||||
const getSapOfFolder = async ({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
environment,
|
||||
secretPath
|
||||
}: TGetBoardSapDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
return getSapPolicy(projectId, environment, secretPath);
|
||||
};
|
||||
|
||||
return {
|
||||
createSap,
|
||||
updateSap,
|
||||
deleteSap,
|
||||
getSapPolicy,
|
||||
getSapByProjectId,
|
||||
getSapOfFolder
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export type TCreateSapDTO = {
|
||||
approvals: number;
|
||||
secretPath?: string | null;
|
||||
environment: string;
|
||||
approvers: string[];
|
||||
projectId: string;
|
||||
name: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateSapDTO = {
|
||||
secretPolicyId: string;
|
||||
approvals?: number;
|
||||
secretPath?: string | null;
|
||||
approvers: string[];
|
||||
name?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDeleteSapDTO = {
|
||||
secretPolicyId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TListSapDTO = TProjectPermission;
|
||||
|
||||
export type TGetBoardSapDTO = {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSarReviewerDalFactory = ReturnType<typeof sarReviewerDalFactory>;
|
||||
|
||||
export const sarReviewerDalFactory = (db: TDbClient) => {
|
||||
const sarReviewerOrm = ormify(db, TableName.SarReviewer);
|
||||
return sarReviewerOrm;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { Knex } from "knex";
|
||||
|
||||
export type TSarSecretDalFactory = ReturnType<typeof sarSecretDalFactory>;
|
||||
|
||||
export const sarSecretDalFactory = (db: TDbClient) => {
|
||||
const sarSecretOrm = ormify(db, TableName.SarSecret);
|
||||
|
||||
const findByRequestId = (requestId: string, tx?: Knex) => {
|
||||
try {
|
||||
const doc = (tx || db)(TableName.SarSecret)
|
||||
.where({ requestId })
|
||||
.leftJoin(TableName.Secret, `${TableName.SarSecret}.secretId`, `${TableName.Secret}.id`)
|
||||
.select(selectAllTableCols(TableName.SarSecret))
|
||||
.select(
|
||||
db.ref("secretBlindIndex").withSchema(TableName.Secret).as("latestSecretBlindIndex"),
|
||||
db.ref("version").withSchema(TableName.Secret).as("latestSecretVersion")
|
||||
);
|
||||
return doc;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByRequestId" });
|
||||
}
|
||||
};
|
||||
return { ...sarSecretOrm, findByRequestId };
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TSecretApprovalRequests, TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { TFindFilter, ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
import { Knex } from "knex";
|
||||
import { RequestState } from "./secret-approval-request-types";
|
||||
|
||||
export type TSecretApprovalRequestDalFactory = ReturnType<typeof secretApprovalRequestDalFactory>;
|
||||
|
||||
type TFindQueryFilter = {
|
||||
projectId: string;
|
||||
membershipId: string;
|
||||
status?: RequestState;
|
||||
environment?: string;
|
||||
committer?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export const secretApprovalRequestDalFactory = (db: TDbClient) => {
|
||||
const secretApprovalRequestOrm = ormify(db, TableName.SecretApprovalRequest);
|
||||
|
||||
const findQuery = (filter: TFindFilter<TSecretApprovalRequests>, tx: Knex) =>
|
||||
tx(TableName.SecretApprovalRequest)
|
||||
.where(filter)
|
||||
.join(
|
||||
TableName.SecretApprovalPolicy,
|
||||
`${TableName.SecretApprovalRequest}.policyId`,
|
||||
`${TableName.SecretApprovalPolicy}.id`
|
||||
)
|
||||
.join(
|
||||
TableName.SarReviewer,
|
||||
`${TableName.SecretApprovalRequest}.id`,
|
||||
`${TableName.SarReviewer}.requestId`
|
||||
)
|
||||
.join(
|
||||
TableName.SapApprover,
|
||||
`${TableName.SecretApprovalPolicy}.id`,
|
||||
`${TableName.SapApprover}.policyId`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.SecretApprovalRequest))
|
||||
.select(tx.ref("id").withSchema(TableName.SarReviewer).as("reviewerMemberId"))
|
||||
.select(tx.ref("statue").withSchema(TableName.SarReviewer).as("reviewerStatus"))
|
||||
.select(tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"))
|
||||
.select(tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"))
|
||||
.select(tx.ref("projectId").withSchema(TableName.SecretApprovalPolicy))
|
||||
.select(
|
||||
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath")
|
||||
)
|
||||
.select(tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"))
|
||||
.select(tx.ref("approverId").withSchema(TableName.SapApprover));
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await findQuery({ id }, tx || db);
|
||||
const formatedDoc = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "id",
|
||||
parentMapper: ({
|
||||
id: pk,
|
||||
projectId,
|
||||
hasMerged,
|
||||
status,
|
||||
conflicts,
|
||||
slug,
|
||||
folderId,
|
||||
statusChangeBy,
|
||||
committerId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
policyId,
|
||||
policyName,
|
||||
policyApprovals,
|
||||
policySecretPath
|
||||
}) => ({
|
||||
id: pk,
|
||||
hasMerged,
|
||||
projectId,
|
||||
status,
|
||||
conflicts,
|
||||
slug,
|
||||
folderId,
|
||||
statusChangeBy,
|
||||
committerId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
policyId,
|
||||
policy: {
|
||||
id: policyId,
|
||||
name: policyName,
|
||||
approvals: policyApprovals,
|
||||
secretPath: policySecretPath
|
||||
}
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "reviewerMemberId",
|
||||
label: "reviewers",
|
||||
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => ({ member, status })
|
||||
},
|
||||
{ key: "approverId", label: "approvers", mapper: ({ approverId }) => approverId }
|
||||
] as const
|
||||
});
|
||||
if (!formatedDoc?.[0]) return;
|
||||
return {
|
||||
...formatedDoc[0],
|
||||
policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers }
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByIdSAR" });
|
||||
}
|
||||
};
|
||||
|
||||
const findProjectRequestCount = async (projectId: string, membershipId: string, tx?: Knex) => {
|
||||
try {
|
||||
const doc = await (tx || db)(TableName.SecretApprovalRequest)
|
||||
.join(
|
||||
TableName.SecretFolder,
|
||||
`${TableName.SecretApprovalRequest}.folderId`,
|
||||
`${TableName.SecretFolder}.id`
|
||||
)
|
||||
.join(
|
||||
TableName.Environment,
|
||||
`${TableName.SecretFolder}.envId`,
|
||||
`${TableName.Environment}.id`
|
||||
)
|
||||
.where({ projectId })
|
||||
.join(
|
||||
TableName.SapApprover,
|
||||
`${TableName.SecretApprovalRequest}.policyId`,
|
||||
`${TableName.SapApprover}.policyId`
|
||||
)
|
||||
.where(`${TableName.SapApprover}.approverId`, membershipId)
|
||||
.orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId)
|
||||
.groupBy("status")
|
||||
.count("status");
|
||||
console.log(JSON.stringify(doc, null, 4));
|
||||
return { open: 0, closed: 0 };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindRequestCount" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectId = async (
|
||||
{ status, limit, offset, projectId, committer, environment, membershipId }: TFindQueryFilter,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const docs = await (tx || db)(TableName.SecretApprovalRequest)
|
||||
.join(
|
||||
TableName.SecretFolder,
|
||||
`${TableName.SecretApprovalRequest}.folderId`,
|
||||
`${TableName.SecretFolder}.id`
|
||||
)
|
||||
.join(
|
||||
TableName.Environment,
|
||||
`${TableName.SecretFolder}.envId`,
|
||||
`${TableName.Environment}.id`
|
||||
)
|
||||
.where({ projectId, slug: environment, status, committerId: committer })
|
||||
.join(
|
||||
TableName.SecretApprovalPolicy,
|
||||
`${TableName.SecretApprovalRequest}.policyId`,
|
||||
`${TableName.SecretApprovalPolicy}.id`
|
||||
)
|
||||
.join(
|
||||
TableName.SapApprover,
|
||||
`${TableName.SecretApprovalPolicy}.id`,
|
||||
`${TableName.SapApprover}.policyId`
|
||||
)
|
||||
.where(`${TableName.SapApprover}.approverId`, membershipId)
|
||||
.orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId)
|
||||
.select(selectAllTableCols(TableName.SecretApprovalRequest))
|
||||
.select(db.ref("id").withSchema(TableName.SarReviewer).as("reviewerMemberId"))
|
||||
.select(db.ref("statue").withSchema(TableName.SarReviewer).as("reviewerStatus"))
|
||||
.select(db.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"))
|
||||
.select(db.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"))
|
||||
.select(
|
||||
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath")
|
||||
)
|
||||
.select(
|
||||
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")
|
||||
)
|
||||
.select(db.ref("approverId").withSchema(TableName.SapApprover));
|
||||
const formatedDoc = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "id",
|
||||
parentMapper: ({
|
||||
id: pk,
|
||||
hasMerged,
|
||||
status: pStatus,
|
||||
conflicts,
|
||||
slug,
|
||||
folderId,
|
||||
statusChangeBy,
|
||||
committerId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
policyId,
|
||||
policyName,
|
||||
policyApprovals,
|
||||
policySecretPath
|
||||
}) => ({
|
||||
id: pk,
|
||||
hasMerged,
|
||||
projectId,
|
||||
status: pStatus,
|
||||
conflicts,
|
||||
slug,
|
||||
folderId,
|
||||
statusChangeBy,
|
||||
committerId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
policyId,
|
||||
policy: {
|
||||
id: policyId,
|
||||
name: policyName,
|
||||
approvals: policyApprovals,
|
||||
secretPath: policySecretPath
|
||||
}
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "reviewerMemberId",
|
||||
label: "reviewers",
|
||||
mapper: ({ reviewerMemberId: member, reviewerStatus: s }) => ({ member, status: s })
|
||||
},
|
||||
{ key: "approverId", label: "approvers", mapper: ({ approverId }) => approverId }
|
||||
] as const
|
||||
});
|
||||
return formatedDoc.map((el) => ({
|
||||
...el,
|
||||
policy: { ...el.policy, approvers: el.approvers }
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindSAR" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretApprovalRequestOrm, findById, findProjectRequestCount, findByProjectId };
|
||||
};
|
||||
+673
@@ -0,0 +1,673 @@
|
||||
import { TSecretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretApprovalRequestDalFactory } from "./secret-approval-request-dal";
|
||||
import {
|
||||
ApprovalStatus,
|
||||
CommitType,
|
||||
TApprovalRequestCountDTO,
|
||||
TGenerateSecretApprovalRequestDTO,
|
||||
TListApprovalsDTO,
|
||||
TMergeSecretApprovalRequestDTO,
|
||||
TReviewRequestDTO,
|
||||
TSecretApprovalDetailsDTO,
|
||||
TStatusChangeDTO
|
||||
} from "./secret-approval-request-types";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { TSecretBlindIndexDalFactory } from "@app/services/secret/secret-blind-index-dal";
|
||||
import { generateSecretBlindIndexBySalt } from "@app/services/secret/secret-service";
|
||||
import {
|
||||
ProjectMembershipRole,
|
||||
SecretEncryptionAlgo,
|
||||
SecretKeyEncoding,
|
||||
SecretType,
|
||||
TSaRequestSecretsInsert,
|
||||
TSecrets
|
||||
} from "@app/db/schemas";
|
||||
import { TSecretDalFactory } from "@app/services/secret/secret-dal";
|
||||
import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { TSarSecretDalFactory } from "./sar-secret-dal";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { TSarReviewerDalFactory } from "./sar-reviewer-dal";
|
||||
|
||||
type TSecretApprovalRequestServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
secretApprovalRequestDal: TSecretApprovalRequestDalFactory;
|
||||
secretDal: TSecretDalFactory;
|
||||
sarSecretDal: TSarSecretDalFactory;
|
||||
sarReviewerDal: TSarReviewerDalFactory;
|
||||
secretVersionDal: Pick<TSecretVersionDalFactory, "findLatestVersionMany" | "insertMany">;
|
||||
folderDal: Pick<TSecretFolderDalFactory, "findBySecretPath">;
|
||||
secretBlindIndexDal: Pick<TSecretBlindIndexDalFactory, "findOne">;
|
||||
};
|
||||
|
||||
export type TSecretApprovalRequestServiceFactory = ReturnType<
|
||||
typeof secretApprovalRequestServiceFactory
|
||||
>;
|
||||
|
||||
export const secretApprovalRequestServiceFactory = ({
|
||||
secretApprovalRequestDal,
|
||||
folderDal,
|
||||
secretDal,
|
||||
sarReviewerDal,
|
||||
sarSecretDal,
|
||||
secretVersionDal,
|
||||
secretBlindIndexDal,
|
||||
permissionService
|
||||
}: TSecretApprovalRequestServiceFactoryDep) => {
|
||||
const requestCount = async ({ projectId, actor, actorId }: TApprovalRequestCountDTO) => {
|
||||
const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
const count = await secretApprovalRequestDal.findProjectRequestCount(projectId, membership.id);
|
||||
return count;
|
||||
};
|
||||
|
||||
const getSecretApprovals = async ({
|
||||
projectId,
|
||||
actorId,
|
||||
actor,
|
||||
status,
|
||||
environment,
|
||||
committer
|
||||
}: TListApprovalsDTO) => {
|
||||
const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
const approvals = await secretApprovalRequestDal.findByProjectId({
|
||||
projectId,
|
||||
committer,
|
||||
environment,
|
||||
status,
|
||||
membershipId: membership.id
|
||||
});
|
||||
return approvals;
|
||||
};
|
||||
|
||||
const getSecretApprovalDetails = async ({ actor, actorId, id }: TSecretApprovalDetailsDTO) => {
|
||||
const secretApprovalRequest = await secretApprovalRequestDal.findById(id);
|
||||
if (!secretApprovalRequest)
|
||||
throw new BadRequestError({ message: "Secret approval request not found" });
|
||||
|
||||
const { policy } = secretApprovalRequest;
|
||||
const { membership } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
secretApprovalRequest.projectId
|
||||
);
|
||||
if (
|
||||
membership.role !== ProjectMembershipRole.Admin &&
|
||||
secretApprovalRequest.committerId !== membership.id &&
|
||||
!policy.approvers.find((approverId) => approverId === membership.id)
|
||||
) {
|
||||
throw new UnauthorizedError({ message: "User has no access" });
|
||||
}
|
||||
|
||||
const secrets = await sarSecretDal.findByRequestId(secretApprovalRequest.id);
|
||||
return { ...secretApprovalRequest, commits: secrets };
|
||||
};
|
||||
|
||||
const reviewApproval = async ({ approvalId, actor, status, actorId }: TReviewRequestDTO) => {
|
||||
const secretApprovalRequest = await secretApprovalRequestDal.findById(approvalId);
|
||||
if (!secretApprovalRequest)
|
||||
throw new BadRequestError({ message: "Secret approval request not found" });
|
||||
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
|
||||
|
||||
const { policy } = secretApprovalRequest;
|
||||
const { membership } = await permissionService.getProjectPermission(
|
||||
ActorType.USER,
|
||||
actorId,
|
||||
secretApprovalRequest.projectId
|
||||
);
|
||||
if (
|
||||
membership.role !== ProjectMembershipRole.Admin &&
|
||||
secretApprovalRequest.committerId !== membership.id &&
|
||||
!policy.approvers.find((approverId) => approverId === membership.id)
|
||||
) {
|
||||
throw new UnauthorizedError({ message: "User has no access" });
|
||||
}
|
||||
const reviewStatus = await sarReviewerDal.transaction(async (tx) => {
|
||||
const review = await sarReviewerDal.findOne(
|
||||
{
|
||||
requestId: secretApprovalRequest.id,
|
||||
member: membership.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (!review) {
|
||||
return sarReviewerDal.create(
|
||||
{
|
||||
status,
|
||||
requestId: secretApprovalRequest.id,
|
||||
member: membership.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
return sarReviewerDal.updateById(review.id, { status }, tx);
|
||||
});
|
||||
return reviewStatus;
|
||||
};
|
||||
|
||||
const updateApprovalStatus = async ({ actorId, status, approvalId, actor }: TStatusChangeDTO) => {
|
||||
const secretApprovalRequest = await secretApprovalRequestDal.findById(approvalId);
|
||||
if (!secretApprovalRequest)
|
||||
throw new BadRequestError({ message: "Secret approval request not found" });
|
||||
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
|
||||
|
||||
const { policy } = secretApprovalRequest;
|
||||
const { membership } = await permissionService.getProjectPermission(
|
||||
ActorType.USER,
|
||||
actorId,
|
||||
secretApprovalRequest.projectId
|
||||
);
|
||||
if (
|
||||
membership.role !== ProjectMembershipRole.Admin &&
|
||||
secretApprovalRequest.committerId !== membership.id &&
|
||||
!policy.approvers.find((approverId) => approverId === membership.id)
|
||||
) {
|
||||
throw new UnauthorizedError({ message: "User has no access" });
|
||||
}
|
||||
|
||||
if (secretApprovalRequest.hasMerged)
|
||||
throw new BadRequestError({ message: "Approval request has been merged" });
|
||||
if (secretApprovalRequest.status === "close" && status === "close")
|
||||
throw new BadRequestError({ message: "Approval request is already closed" });
|
||||
if (secretApprovalRequest.status === "open" && status === "open")
|
||||
throw new BadRequestError({ message: "Approval request is already open" });
|
||||
|
||||
const updatedRequest = await secretApprovalRequestDal.updateById(secretApprovalRequest.id, {
|
||||
status,
|
||||
statusChangeBy: membership.id
|
||||
});
|
||||
return updatedRequest;
|
||||
};
|
||||
|
||||
const mergeSecretApprovalRequest = async ({
|
||||
approvalId,
|
||||
actor,
|
||||
actorId
|
||||
}: TMergeSecretApprovalRequestDTO) => {
|
||||
const secretApprovalRequest = await secretApprovalRequestDal.findById(approvalId);
|
||||
if (!secretApprovalRequest)
|
||||
throw new BadRequestError({ message: "Secret approval request not found" });
|
||||
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
|
||||
|
||||
const { policy, folderId } = secretApprovalRequest;
|
||||
const { membership } = await permissionService.getProjectPermission(
|
||||
ActorType.USER,
|
||||
actorId,
|
||||
secretApprovalRequest.projectId
|
||||
);
|
||||
if (
|
||||
membership.role !== ProjectMembershipRole.Admin &&
|
||||
secretApprovalRequest.committerId !== membership.id &&
|
||||
!policy.approvers.find((approverId) => approverId === membership.id)
|
||||
) {
|
||||
throw new UnauthorizedError({ message: "User has no access" });
|
||||
}
|
||||
const reviewers = secretApprovalRequest.reviewers.reduce<Record<string, ApprovalStatus>>(
|
||||
(prev, curr) => ({ ...prev, [curr.member.toString()]: curr.status }),
|
||||
{}
|
||||
);
|
||||
const hasMinApproval =
|
||||
secretApprovalRequest.policy.approvals <=
|
||||
secretApprovalRequest.policy.approvers.filter(
|
||||
(approverId) => reviewers[approverId.toString()] === ApprovalStatus.APPROVED
|
||||
).length;
|
||||
|
||||
if (!hasMinApproval)
|
||||
throw new BadRequestError({ message: "Doesn't have minimum approvals needed" });
|
||||
const secretApprovalSecrets = await sarSecretDal.findByRequestId(secretApprovalRequest.id);
|
||||
if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" });
|
||||
|
||||
const conflicts: Array<{ secretId: string; op: CommitType }> = [];
|
||||
let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Create);
|
||||
if (secretCreationCommits.length) {
|
||||
const conflictedSecrets = await secretDal.findByBlindIndexes(
|
||||
folderId,
|
||||
secretCreationCommits.map(({ secretBlindIndex }) => ({
|
||||
type: SecretType.Shared,
|
||||
blindIndex: secretBlindIndex
|
||||
}))
|
||||
);
|
||||
const conflictGroupByBlindIndex = conflictedSecrets.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.secretBlindIndex || ""]: true }),
|
||||
{}
|
||||
);
|
||||
secretCreationCommits
|
||||
.filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""])
|
||||
.forEach((el) => {
|
||||
conflicts.push({ op: CommitType.Create, secretId: el.id });
|
||||
});
|
||||
secretCreationCommits = secretCreationCommits.filter(
|
||||
({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""]
|
||||
);
|
||||
}
|
||||
|
||||
let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Update);
|
||||
if (secretUpdationCommits.length) {
|
||||
const conflictedByNewBlindIndex = await secretDal.findByBlindIndexes(
|
||||
folderId,
|
||||
secretUpdationCommits.map(({ secretBlindIndex }) => ({
|
||||
type: SecretType.Shared,
|
||||
blindIndex: secretBlindIndex
|
||||
}))
|
||||
);
|
||||
const conflictGroupByBlindIndex = conflictedByNewBlindIndex.reduce<Record<string, boolean>>(
|
||||
(prev, curr) =>
|
||||
curr?.secretBlindIndex ? { ...prev, [curr.secretBlindIndex]: true } : prev,
|
||||
{}
|
||||
);
|
||||
secretUpdationCommits
|
||||
.filter(
|
||||
({ secretBlindIndex, secretId }) =>
|
||||
(secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId
|
||||
)
|
||||
.forEach((el) => {
|
||||
conflicts.push({ op: CommitType.Update, secretId: el.id });
|
||||
});
|
||||
|
||||
secretUpdationCommits = secretUpdationCommits.filter(
|
||||
({ secretBlindIndex, secretId }) =>
|
||||
Boolean(secretId) &&
|
||||
(secretBlindIndex ? !conflictGroupByBlindIndex[secretBlindIndex] : true)
|
||||
);
|
||||
}
|
||||
|
||||
const secretDeletionCommits = secretApprovalSecrets.filter(
|
||||
({ op }) => op === CommitType.Delete
|
||||
);
|
||||
|
||||
const mergeStatus = await secretDal.transaction(async (tx) => {
|
||||
const newSecrets = await secretDal.insertMany(
|
||||
secretCreationCommits.map(
|
||||
({
|
||||
secretBlindIndex,
|
||||
metadata,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretValueCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding,
|
||||
secretReminderNotice,
|
||||
secretReminderRepeatDays
|
||||
}) => ({
|
||||
secretBlindIndex,
|
||||
metadata,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretValueCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding,
|
||||
secretReminderNotice,
|
||||
secretReminderRepeatDays,
|
||||
version: 1,
|
||||
folderId,
|
||||
type: SecretType.Shared,
|
||||
algorithm: SecretEncryptionAlgo.AES_256_GCM,
|
||||
keyEncoding: SecretKeyEncoding.UTF8
|
||||
})
|
||||
),
|
||||
tx
|
||||
);
|
||||
const updatedSecrets = await secretDal.bulkUpdate(
|
||||
secretUpdationCommits.map(
|
||||
({
|
||||
secretId,
|
||||
secretBlindIndex,
|
||||
metadata,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretValueCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding,
|
||||
secretReminderNotice,
|
||||
secretReminderRepeatDays
|
||||
}) => ({
|
||||
folderId,
|
||||
id: secretId as string,
|
||||
type: SecretType.Shared,
|
||||
secretBlindIndex,
|
||||
metadata,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretValueCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding,
|
||||
secretReminderNotice,
|
||||
secretReminderRepeatDays
|
||||
})
|
||||
),
|
||||
tx
|
||||
);
|
||||
const deletedSecret = await secretDal.deleteMany(
|
||||
secretDeletionCommits.map(({ secretBlindIndex }) => ({
|
||||
blindIndex: secretBlindIndex,
|
||||
type: SecretType.Shared
|
||||
})),
|
||||
folderId,
|
||||
actorId,
|
||||
tx
|
||||
);
|
||||
await secretVersionDal.insertMany(
|
||||
newSecrets
|
||||
.map(({ id, updatedAt, createdAt, ...el }) => ({
|
||||
...el,
|
||||
secretId: id
|
||||
}))
|
||||
.concat(
|
||||
updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
|
||||
...el,
|
||||
secretId: id
|
||||
}))
|
||||
),
|
||||
tx
|
||||
);
|
||||
|
||||
const updatedSecretApproval = await secretApprovalRequestDal.updateById(
|
||||
secretApprovalRequest.id,
|
||||
{
|
||||
conflicts,
|
||||
hasMerged: true,
|
||||
status: "close",
|
||||
statusChangeBy: actorId
|
||||
},
|
||||
tx
|
||||
);
|
||||
return {
|
||||
secrets: { created: newSecrets, updated: updatedSecrets, deleted: deletedSecret },
|
||||
approval: updatedSecretApproval
|
||||
};
|
||||
});
|
||||
return mergeStatus;
|
||||
};
|
||||
|
||||
// function to save secret change to secret approval
|
||||
// this will keep a copy to do merge later when accepting
|
||||
const generateSecretApprovalRequest = async ({
|
||||
data,
|
||||
policy,
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
commiterMembershipId
|
||||
}: TGenerateSecretApprovalRequestDTO) => {
|
||||
const folder = await folderDal.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder)
|
||||
throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" });
|
||||
const folderId = folder.id;
|
||||
|
||||
const blindIndexDoc = await secretBlindIndexDal.findOne({ projectId });
|
||||
if (!blindIndexDoc)
|
||||
throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
|
||||
|
||||
const commits: Omit<TSaRequestSecretsInsert, "requestId">[] = [];
|
||||
// for created secret approval change
|
||||
const createdSecrets = data[CommitType.Create];
|
||||
if (createdSecrets && createdSecrets?.length) {
|
||||
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
|
||||
const secretBlindIndexes = await Promise.all(
|
||||
createdSecrets.map(({ secretName }) =>
|
||||
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
// eslint-disable-next-line
|
||||
prev[createdSecrets[i].secretName] = curr;
|
||||
secretBlindIndexToKey[curr] = createdSecrets[i].secretName;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const exists = await secretDal.findByBlindIndexes(
|
||||
folderId,
|
||||
createdSecrets.map(({ secretName }) => ({
|
||||
blindIndex: secretBlindIndexes[secretName],
|
||||
type: SecretType.Shared
|
||||
}))
|
||||
);
|
||||
if (exists.length) throw new BadRequestError({ message: "Secret already exist" });
|
||||
commits.push(
|
||||
...createdSecrets.map((el) => ({
|
||||
...el,
|
||||
op: CommitType.Create as const,
|
||||
version: 0,
|
||||
secretBlindIndex: secretBlindIndexes[el.secretName]
|
||||
}))
|
||||
);
|
||||
}
|
||||
// not secret approval for update operations
|
||||
const updatedSecrets = data[CommitType.Update];
|
||||
if (updatedSecrets && updatedSecrets?.length) {
|
||||
// get all blind index
|
||||
// Find all those secrets
|
||||
// if not throw not found
|
||||
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
|
||||
const secretBlindIndexes = await Promise.all(
|
||||
updatedSecrets.map(({ secretName }) =>
|
||||
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
// eslint-disable-next-line
|
||||
prev[updatedSecrets[i].secretName] = curr;
|
||||
secretBlindIndexToKey[curr] = updatedSecrets[i].secretName;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const secretsToBeUpdated = await secretDal.findByBlindIndexes(
|
||||
folderId,
|
||||
updatedSecrets.map(({ secretName }) => ({
|
||||
blindIndex: secretBlindIndexes[secretName],
|
||||
type: SecretType.Shared
|
||||
}))
|
||||
);
|
||||
if (secretsToBeUpdated.length !== updatedSecrets.length)
|
||||
throw new BadRequestError({ message: "Secret not found" });
|
||||
|
||||
// now find any secret that needs to update its name
|
||||
// same process as above
|
||||
const nameUpdatedSecrets = updatedSecrets.filter(({ newSecretName }) =>
|
||||
Boolean(newSecretName)
|
||||
);
|
||||
const newSecretBlindIndexes = await Promise.all(
|
||||
nameUpdatedSecrets.map(({ newSecretName }) =>
|
||||
generateSecretBlindIndexBySalt(newSecretName as string, blindIndexDoc)
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
// eslint-disable-next-line
|
||||
prev[nameUpdatedSecrets[i].secretName] = curr;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
const secretsWithNewName = await secretDal.findByBlindIndexes(
|
||||
folderId,
|
||||
nameUpdatedSecrets.map(({ newSecretName }) => ({
|
||||
blindIndex: newSecretBlindIndexes[newSecretName as string],
|
||||
type: SecretType.Shared
|
||||
}))
|
||||
);
|
||||
if (secretsWithNewName.length)
|
||||
throw new BadRequestError({ message: "Secret with new name already exist" });
|
||||
|
||||
const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce<Record<string, TSecrets>>(
|
||||
(prev, curr) => {
|
||||
// eslint-disable-next-line
|
||||
if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr;
|
||||
return prev;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const updatedSecretIds = updatedSecrets.map(
|
||||
(el) => secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id
|
||||
);
|
||||
const latestSecretVersions = await secretVersionDal.findLatestVersionMany(
|
||||
folderId,
|
||||
updatedSecretIds
|
||||
);
|
||||
commits.push(
|
||||
...updatedSecrets.map((el) => {
|
||||
const secretId = secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id;
|
||||
return {
|
||||
...latestSecretVersions[secretId],
|
||||
op: CommitType.Update as const,
|
||||
secret: secretId,
|
||||
secretVersion: latestSecretVersions[secretId].id,
|
||||
...el,
|
||||
secretBlindIndex: newSecretBlindIndexes?.[el.secretName],
|
||||
version: secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].version || 1
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
// deleted secrets
|
||||
const deletedSecrets = data[CommitType.Delete];
|
||||
if (deletedSecrets && deletedSecrets.length) {
|
||||
// get all blind index
|
||||
// Find all those secrets
|
||||
// if not throw not found
|
||||
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
|
||||
const secretBlindIndexes = await Promise.all(
|
||||
deletedSecrets.map(({ secretName }) =>
|
||||
generateSecretBlindIndexBySalt(secretName, blindIndexDoc)
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
// eslint-disable-next-line
|
||||
prev[deletedSecrets[i].secretName] = curr;
|
||||
secretBlindIndexToKey[curr] = deletedSecrets[i].secretName;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
// not find those secrets. if any of them not found throw an not found error
|
||||
const secretsToBeDeleted = await secretDal.findByBlindIndexes(
|
||||
folderId,
|
||||
deletedSecrets.map(({ secretName }) => ({
|
||||
blindIndex: secretBlindIndexes[secretName],
|
||||
type: SecretType.Shared
|
||||
}))
|
||||
);
|
||||
if (secretsToBeDeleted.length !== deletedSecrets.length)
|
||||
throw new BadRequestError({ message: "Secret not found" });
|
||||
const secretsGroupedByBlindIndex = secretsToBeDeleted.reduce<Record<string, TSecrets>>(
|
||||
(prev, curr) => {
|
||||
// eslint-disable-next-line
|
||||
if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr;
|
||||
return prev;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const deletedSecretIds = deletedSecrets.map(
|
||||
(el) => secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id
|
||||
);
|
||||
const latestSecretVersions = await secretVersionDal.findLatestVersionMany(
|
||||
folderId,
|
||||
deletedSecretIds
|
||||
);
|
||||
commits.push(
|
||||
...deletedSecrets.map((el) => {
|
||||
const secretId = secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].id;
|
||||
return {
|
||||
op: CommitType.Delete as const,
|
||||
...latestSecretVersions[secretId],
|
||||
secret: secretId,
|
||||
secretVersion: latestSecretVersions[secretId].id
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const secretApprovalRequest = await secretApprovalRequestDal.transaction(async (tx) => {
|
||||
const doc = await secretApprovalRequestDal.create(
|
||||
{
|
||||
folderId,
|
||||
slug: alphaNumericNanoId(),
|
||||
policyId: policy.id,
|
||||
status: "open",
|
||||
hasMerged: false,
|
||||
committerId: commiterMembershipId
|
||||
},
|
||||
tx
|
||||
);
|
||||
const approvalCommits = await sarSecretDal.insertMany(
|
||||
commits.map(
|
||||
({
|
||||
version,
|
||||
op,
|
||||
secretKeyTag,
|
||||
secretKeyIV,
|
||||
keyEncoding,
|
||||
secretId,
|
||||
metadata,
|
||||
algorithm,
|
||||
secretBlindIndex,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretVersion,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueCiphertext,
|
||||
secretReminderNotice,
|
||||
skipMultilineEncoding,
|
||||
secretCommentCiphertext,
|
||||
secretReminderRepeatDays
|
||||
}) => ({
|
||||
version,
|
||||
requestId: doc.id,
|
||||
op,
|
||||
secretKeyTag,
|
||||
secretKeyIV,
|
||||
keyEncoding,
|
||||
secretId,
|
||||
metadata,
|
||||
algorithm,
|
||||
secretBlindIndex,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretVersion,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueCiphertext,
|
||||
secretReminderNotice,
|
||||
skipMultilineEncoding,
|
||||
secretCommentCiphertext,
|
||||
secretReminderRepeatDays
|
||||
})
|
||||
),
|
||||
tx
|
||||
);
|
||||
return { ...doc, commits: approvalCommits };
|
||||
});
|
||||
return secretApprovalRequest;
|
||||
};
|
||||
return {
|
||||
generateSecretApprovalRequest,
|
||||
mergeSecretApprovalRequest,
|
||||
reviewApproval,
|
||||
updateApprovalStatus,
|
||||
getSecretApprovals,
|
||||
getSecretApprovalDetails,
|
||||
requestCount
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { TImmutableDBKeys, TSaRequestSecrets, TSecretApprovalPolicies } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum CommitType {
|
||||
Create = "create",
|
||||
Update = "update",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum RequestState {
|
||||
Open = "open",
|
||||
Closed = "closed"
|
||||
}
|
||||
|
||||
export enum ApprovalStatus {
|
||||
PENDING = "pending",
|
||||
APPROVED = "approved",
|
||||
REJECTED = "rejected"
|
||||
}
|
||||
|
||||
type TApprovalCreateSecret = Omit<TSaRequestSecrets, TImmutableDBKeys | "version"> & {
|
||||
secretName: string;
|
||||
tagIds?: string[];
|
||||
};
|
||||
type TApprovalUpdateSecret = Partial<Omit<TSaRequestSecrets, TImmutableDBKeys | "version">> & {
|
||||
secretName: string;
|
||||
newSecretName?: string;
|
||||
tagIds?: string[];
|
||||
};
|
||||
|
||||
export type TGenerateSecretApprovalRequestDTO = {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
policy: TSecretApprovalPolicies;
|
||||
commiterMembershipId: string;
|
||||
data: {
|
||||
[CommitType.Create]: TApprovalCreateSecret[];
|
||||
[CommitType.Update]: TApprovalUpdateSecret[];
|
||||
[CommitType.Delete]: { secretName: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
export type TMergeSecretApprovalRequestDTO = {
|
||||
approvalId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TStatusChangeDTO = {
|
||||
approvalId: string;
|
||||
status: "open" | "close";
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TReviewRequestDTO = {
|
||||
approvalId: string;
|
||||
status: ApprovalStatus;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TApprovalRequestCountDTO = TProjectPermission;
|
||||
|
||||
export type TListApprovalsDTO = {
|
||||
projectId: string;
|
||||
status?: RequestState;
|
||||
environment?: string;
|
||||
committer?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TSecretApprovalDetailsDTO = {
|
||||
id:string;
|
||||
} & Omit<TProjectPermission, 'projectId'>
|
||||
Reference in New Issue
Block a user