feat(infisical-pg): completed secret approval policy and services for approval requests

This commit is contained in:
Akhil Mohan
2024-01-27 12:38:23 +05:30
parent a6a60b7bbb
commit 17e61bfc68
40 changed files with 2114 additions and 43 deletions
+2
View File
@@ -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 };
};
@@ -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'>