feat(infisical-pg): completed secret rotation with queue service

This commit is contained in:
Akhil Mohan
2024-01-27 12:38:23 +05:30
parent 17e61bfc68
commit 784cdb4201
64 changed files with 3093 additions and 459 deletions
@@ -1,14 +1,14 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TSecretApprovalPolicies, TableName } from "@app/db/schemas";
import { TableName,TSecretApprovalPolicies } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import {
TFindFilter,
buildFindFilter,
mergeOneToManyRelation,
ormify,
selectAllTableCols
} from "@app/lib/knex";
import { Knex } from "knex";
selectAllTableCols,
TFindFilter} from "@app/lib/knex";
export type TSecretApprovalPolicyDalFactory = ReturnType<typeof secretApprovalPolicyDalFactory>;
@@ -1,6 +1,18 @@
import { ForbiddenError } from "@casl/ability";
import picomatch from "picomatch";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { TSecretApprovalPolicyDalFactory } from "./secret-approval-policy-dal";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { containsGlobPatterns } from "@app/lib/picomatch";
import { TProjectEnvDalFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDalFactory } from "@app/services/project-membership/project-membership-dal";
import { TSapApproverDalFactory } from "./sap-approver-dal";
import { TSecretApprovalPolicyDalFactory } from "./secret-approval-policy-dal";
import {
TCreateSapDTO,
TDeleteSapDTO,
@@ -8,16 +20,6 @@ import {
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
@@ -1,25 +1,121 @@
import { Knex } from "knex";
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) => {
const findByRequestId = async (requestId: string, tx?: Knex) => {
try {
const doc = (tx || db)(TableName.SarSecret)
const doc = await (tx || db)(TableName.SarSecret)
.where({ requestId })
.leftJoin(TableName.Secret, `${TableName.SarSecret}.secretId`, `${TableName.Secret}.id`)
.leftJoin(
TableName.SecretVersion,
`${TableName.SecretVersion}.id`,
`${TableName.SarSecret}.secretVersion`
)
.select(selectAllTableCols(TableName.SarSecret))
.select(
db.ref("secretBlindIndex").withSchema(TableName.Secret).as("latestSecretBlindIndex"),
db.ref("version").withSchema(TableName.Secret).as("latestSecretVersion")
db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindIndex"),
db.ref("version").withSchema(TableName.Secret).as("orgSecVersion"),
db.ref("secretKeyIV").withSchema(TableName.Secret).as("orgSecKeyIV"),
db.ref("secretKeyTag").withSchema(TableName.Secret).as("orgSecKeyTag"),
db.ref("secretKeyCiphertext").withSchema(TableName.Secret).as("orgSecKeyCiphertext"),
db.ref("secretValueIV").withSchema(TableName.Secret).as("orgSecValueIV"),
db.ref("secretValueTag").withSchema(TableName.Secret).as("orgSecValueTag"),
db.ref("secretValueCiphertext").withSchema(TableName.Secret).as("orgSecValueCiphertext"),
db.ref("secretCommentIV").withSchema(TableName.Secret).as("orgSecCommentIV"),
db.ref("secretCommentTag").withSchema(TableName.Secret).as("orgSecCommentTag"),
db
.ref("secretCommentCiphertext")
.withSchema(TableName.Secret)
.as("orgSecCommentCiphertext")
)
.select(
// db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindInex"),
db.ref("version").withSchema(TableName.SecretVersion).as("secVerVersion"),
db.ref("secretKeyIV").withSchema(TableName.SecretVersion).as("secVerKeyIV"),
db.ref("secretKeyTag").withSchema(TableName.SecretVersion).as("secVerKeyTag"),
db
.ref("secretKeyCiphertext")
.withSchema(TableName.SecretVersion)
.as("secVerKeyCiphertext"),
db.ref("secretValueIV").withSchema(TableName.SecretVersion).as("secVerValueIV"),
db.ref("secretValueTag").withSchema(TableName.SecretVersion).as("secVerValueTag"),
db
.ref("secretValueCiphertext")
.withSchema(TableName.SecretVersion)
.as("secVerValueCiphertext"),
db.ref("secretCommentIV").withSchema(TableName.SecretVersion).as("secVerCommentIV"),
db.ref("secretCommentTag").withSchema(TableName.SecretVersion).as("secVerCommentTag"),
db
.ref("secretCommentCiphertext")
.withSchema(TableName.SecretVersion)
.as("secVerCommentCiphertext")
);
return doc;
return doc.map(
({
orgSecKeyIV,
orgSecKeyTag,
orgSecValueIV,
orgSecVersion,
orgSecValueTag,
orgSecCommentIV,
orgSecBlindIndex,
orgSecCommentTag,
orgSecKeyCiphertext,
orgSecValueCiphertext,
orgSecCommentCiphertext,
secVerCommentIV,
secVerCommentCiphertext,
secVerCommentTag,
secVerValueCiphertext,
secVerKeyIV,
secVerKeyTag,
secVerValueIV,
secVerVersion,
secVerValueTag,
secVerKeyCiphertext,
...el
}) => ({
...el,
secret: el.secretId
? {
id: el.secretId,
secretBlindIndex: orgSecBlindIndex,
secretKeyIV: orgSecKeyIV,
secretKeyTag: orgSecKeyTag,
secretKeyCiphertext: orgSecKeyCiphertext,
secretValueIV: orgSecValueIV,
secretValueTag: orgSecValueTag,
secretValueCiphertext: orgSecValueCiphertext,
secretCommentIV: orgSecCommentIV,
secretCommentTag: orgSecCommentTag,
secretCommentCiphertext: orgSecCommentCiphertext
}
: undefined,
secretVersion: el.secretVersion
? {
id: el.secretVersion,
secretKeyIV: secVerKeyIV,
secretKeyTag: secVerKeyTag,
secretKeyCiphertext: secVerKeyCiphertext,
secretValueIV: secVerValueIV,
secretValueTag: secVerValueTag,
secretValueCiphertext: secVerValueCiphertext,
secretCommentIV: secVerCommentIV,
secretCommentTag: secVerCommentTag,
secretCommentCiphertext: secVerCommentCiphertext
}
: undefined
})
);
} catch (error) {
throw new DatabaseError({ error, name: "FindByRequestId" });
}
@@ -1,8 +1,16 @@
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 { TDbClient } from "@app/db";
import { SecretApprovalRequestsSchema, TableName, TSecretApprovalRequests } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import {
ormify,
selectAllTableCols,
sqlNestRelationships,
stripUndefinedInWhere,
TFindFilter
} from "@app/lib/knex";
import { RequestState } from "./secret-approval-request-types";
export type TSecretApprovalRequestDalFactory = ReturnType<typeof secretApprovalRequestDalFactory>;
@@ -23,27 +31,33 @@ export const secretApprovalRequestDalFactory = (db: TDbClient) => {
const findQuery = (filter: TFindFilter<TSecretApprovalRequests>, tx: Knex) =>
tx(TableName.SecretApprovalRequest)
.where(filter)
.join(
TableName.SecretFolder,
`${TableName.SecretApprovalRequest}.folderId`,
`${TableName.SecretFolder}.id`
)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.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`
)
.leftJoin(
TableName.SarReviewer,
`${TableName.SecretApprovalRequest}.id`,
`${TableName.SarReviewer}.requestId`
)
.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("member").withSchema(TableName.SarReviewer).as("reviewerMemberId"))
.select(tx.ref("status").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("projectId").withSchema(TableName.Environment))
.select(
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath")
)
@@ -52,51 +66,27 @@ export const secretApprovalRequestDalFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex) => {
try {
const docs = await findQuery({ id }, tx || db);
const sql = findQuery({ [`${TableName.SecretApprovalRequest}.id` as "id"]: id }, tx || db);
const docs = await sql;
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,
parentMapper: (el) => ({
...SecretApprovalRequestsSchema.parse(el),
projectId: el.projectId,
policy: {
id: policyId,
name: policyName,
approvals: policyApprovals,
secretPath: policySecretPath
id: el.policyId,
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath
}
}),
childrenMapper: [
{
key: "reviewerMemberId",
label: "reviewers",
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => ({ member, status })
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) =>
member ? { member, status } : undefined
},
{ key: "approverId", label: "approvers", mapper: ({ approverId }) => approverId }
] as const
@@ -124,18 +114,27 @@ export const secretApprovalRequestDalFactory = (db: TDbClient) => {
`${TableName.SecretFolder}.envId`,
`${TableName.Environment}.id`
)
.where({ projectId })
.join(
TableName.SapApprover,
`${TableName.SecretApprovalRequest}.policyId`,
`${TableName.SapApprover}.policyId`
)
.where({ projectId })
.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 };
.count("status")
.select("status");
return {
open: parseInt(
(doc.find(({ status }) => status === RequestState.Open)?.count as string) || "0",
10
),
closed: parseInt(
(doc.find(({ status }) => status === RequestState.Closed)?.count as string) || "0",
10
)
};
} catch (error) {
throw new DatabaseError({ error, name: "FindRequestCount" });
}
@@ -157,7 +156,6 @@ export const secretApprovalRequestDalFactory = (db: TDbClient) => {
`${TableName.SecretFolder}.envId`,
`${TableName.Environment}.id`
)
.where({ projectId, slug: environment, status, committerId: committer })
.join(
TableName.SecretApprovalPolicy,
`${TableName.SecretApprovalRequest}.policyId`,
@@ -168,11 +166,28 @@ export const secretApprovalRequestDalFactory = (db: TDbClient) => {
`${TableName.SecretApprovalPolicy}.id`,
`${TableName.SapApprover}.policyId`
)
.where(`${TableName.SapApprover}.approverId`, membershipId)
.orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId)
.leftJoin(
TableName.SarReviewer,
`${TableName.SecretApprovalRequest}.id`,
`${TableName.SarReviewer}.requestId`
)
.where(
stripUndefinedInWhere({
projectId,
slug: environment,
[`${TableName.SecretApprovalRequest}.status`]: status,
committerId: committer
})
)
.andWhere((bd) =>
bd
.where(`${TableName.SapApprover}.approverId`, membershipId)
.orWhere(`${TableName.SecretApprovalRequest}.committerId`, membershipId)
)
.select(selectAllTableCols(TableName.SecretApprovalRequest))
.select(db.ref("projectId").withSchema(TableName.Environment))
.select(db.ref("id").withSchema(TableName.SarReviewer).as("reviewerMemberId"))
.select(db.ref("statue").withSchema(TableName.SarReviewer).as("reviewerStatus"))
.select(db.ref("status").withSchema(TableName.SarReviewer).as("reviewerStatus"))
.select(db.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"))
.select(db.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"))
.select(
@@ -185,46 +200,22 @@ export const secretApprovalRequestDalFactory = (db: TDbClient) => {
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,
parentMapper: (el) => ({
...SecretApprovalRequestsSchema.parse(el),
projectId: el.projectId,
policy: {
id: policyId,
name: policyName,
approvals: policyApprovals,
secretPath: policySecretPath
id: el.policyId,
name: el.policyName,
approvals: el.policyApprovals,
secretPath: el.policySecretPath
}
}),
childrenMapper: [
{
key: "reviewerMemberId",
label: "reviewers",
mapper: ({ reviewerMemberId: member, reviewerStatus: s }) => ({ member, status: s })
mapper: ({ reviewerMemberId: member, reviewerStatus: s }) =>
member ? { member, status: s } : undefined
},
{ key: "approverId", label: "approvers", mapper: ({ approverId }) => approverId }
] as const
@@ -1,19 +1,5 @@
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 { ForbiddenError, subject } from "@casl/ability";
import {
ProjectMembershipRole,
SecretEncryptionAlgo,
@@ -22,13 +8,32 @@ import {
TSaRequestSecretsInsert,
TSecrets
} from "@app/db/schemas";
import { TSecretDalFactory } from "@app/services/secret/secret-dal";
import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TSarSecretDalFactory } from "./sar-secret-dal";
import { ActorType } from "@app/services/auth/auth-type";
import { TSecretBlindIndexDalFactory } from "@app/services/secret/secret-blind-index-dal";
import { TSecretDalFactory } from "@app/services/secret/secret-dal";
import { generateSecretBlindIndexBySalt } from "@app/services/secret/secret-service";
import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal";
import { TSecretFolderDalFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TSarReviewerDalFactory } from "./sar-reviewer-dal";
import { TSarSecretDalFactory } from "./sar-secret-dal";
import { TSecretApprovalRequestDalFactory } from "./secret-approval-request-dal";
import {
ApprovalStatus,
CommitType,
RequestState,
TApprovalRequestCountDTO,
TGenerateSecretApprovalRequestDTO,
TListApprovalsDTO,
TMergeSecretApprovalRequestDTO,
TReviewRequestDTO,
TSecretApprovalDetailsDTO,
TStatusChangeDTO
} from "./secret-approval-request-types";
type TSecretApprovalRequestServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@@ -167,9 +172,9 @@ export const secretApprovalRequestServiceFactory = ({
if (secretApprovalRequest.hasMerged)
throw new BadRequestError({ message: "Approval request has been merged" });
if (secretApprovalRequest.status === "close" && status === "close")
if (secretApprovalRequest.status === RequestState.Closed && status === RequestState.Closed)
throw new BadRequestError({ message: "Approval request is already closed" });
if (secretApprovalRequest.status === "open" && status === "open")
if (secretApprovalRequest.status === RequestState.Open && status === RequestState.Open)
throw new BadRequestError({ message: "Approval request is already open" });
const updatedRequest = await secretApprovalRequestDal.updateById(secretApprovalRequest.id, {
@@ -203,7 +208,7 @@ export const secretApprovalRequestServiceFactory = ({
throw new UnauthorizedError({ message: "User has no access" });
}
const reviewers = secretApprovalRequest.reviewers.reduce<Record<string, ApprovalStatus>>(
(prev, curr) => ({ ...prev, [curr.member.toString()]: curr.status }),
(prev, curr) => ({ ...prev, [curr.member.toString()]: curr.status as ApprovalStatus }),
{}
);
const hasMinApproval =
@@ -245,10 +250,14 @@ export const secretApprovalRequestServiceFactory = ({
if (secretUpdationCommits.length) {
const conflictedByNewBlindIndex = await secretDal.findByBlindIndexes(
folderId,
secretUpdationCommits.map(({ secretBlindIndex }) => ({
type: SecretType.Shared,
blindIndex: secretBlindIndex
}))
secretUpdationCommits
.filter(
({ secretBlindIndex, secret }) => secret && secret.secretBlindIndex !== secretBlindIndex
)
.map(({ secretBlindIndex }) => ({
type: SecretType.Shared,
blindIndex: secretBlindIndex
}))
);
const conflictGroupByBlindIndex = conflictedByNewBlindIndex.reduce<Record<string, boolean>>(
(prev, curr) =>
@@ -276,118 +285,128 @@ export const secretApprovalRequestServiceFactory = ({
);
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,
const newSecrets = secretCreationCommits.length
? 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 = secretUpdationCommits.length
? await secretDal.bulkUpdate(
secretUpdationCommits.map(
({
version,
secretId,
secretBlindIndex,
metadata,
secretKeyIV,
secretKeyTag,
secretKeyCiphertext,
secretValueIV,
secretValueTag,
secretValueCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretReminderNotice,
secretReminderRepeatDays
}) => ({
folderId,
version: (version || 0) + 1,
id: secretId as string,
type: SecretType.Shared,
secretBlindIndex,
metadata,
secretKeyIV,
secretKeyTag,
secretKeyCiphertext,
secretValueIV,
secretValueTag,
secretValueCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext,
skipMultilineEncoding,
secretReminderNotice,
secretReminderRepeatDays
})
),
tx
)
: [];
const deletedSecret = secretDeletionCommits.length
? await secretDal.deleteMany(
secretDeletionCommits.map(({ secretBlindIndex }) => ({
blindIndex: secretBlindIndex,
type: SecretType.Shared
})),
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 }) => ({
actorId,
tx
)
: [];
if (newSecrets.length || updatedSecrets.length) {
await secretVersionDal.insertMany(
newSecrets
.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
}))
),
tx
);
.concat(
updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
}))
),
tx
);
}
const updatedSecretApproval = await secretApprovalRequestDal.updateById(
secretApprovalRequest.id,
{
conflicts,
conflicts: JSON.stringify(conflicts),
hasMerged: true,
status: "close",
statusChangeBy: actorId
status: RequestState.Closed,
statusChangeBy: membership.id
},
tx
);
@@ -403,12 +422,23 @@ export const secretApprovalRequestServiceFactory = ({
// this will keep a copy to do merge later when accepting
const generateSecretApprovalRequest = async ({
data,
actorId,
actor,
policy,
projectId,
secretPath,
environment,
commiterMembershipId
environment
}: TGenerateSecretApprovalRequestDTO) => {
const { permission, membership } = await permissionService.getProjectPermission(
actor,
actorId,
projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const folder = await folderDal.findBySecretPath(projectId, environment, secretPath);
if (!folder)
throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" });
@@ -449,7 +479,9 @@ export const secretApprovalRequestServiceFactory = ({
...el,
op: CommitType.Create as const,
version: 0,
secretBlindIndex: secretBlindIndexes[el.secretName]
secretBlindIndex: secretBlindIndexes[el.secretName],
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.BASE64
}))
);
}
@@ -533,7 +565,8 @@ export const secretApprovalRequestServiceFactory = ({
secret: secretId,
secretVersion: latestSecretVersions[secretId].id,
...el,
secretBlindIndex: newSecretBlindIndexes?.[el.secretName],
secretBlindIndex:
newSecretBlindIndexes?.[el.secretName] || secretBlindIndexes[el.secretName],
version: secretsGroupedByBlindIndex[secretBlindIndexes[el.secretName]].version || 1
};
})
@@ -596,6 +629,7 @@ export const secretApprovalRequestServiceFactory = ({
);
}
if (!commits.length) throw new BadRequestError({ message: "Empty commits" });
const secretApprovalRequest = await secretApprovalRequestDal.transaction(async (tx) => {
const doc = await secretApprovalRequestDal.create(
{
@@ -604,7 +638,7 @@ export const secretApprovalRequestServiceFactory = ({
policyId: policy.id,
status: "open",
hasMerged: false,
committerId: commiterMembershipId
committerId: membership.id
},
tx
);
@@ -9,7 +9,7 @@ export enum CommitType {
export enum RequestState {
Open = "open",
Closed = "closed"
Closed = "close"
}
export enum ApprovalStatus {
@@ -18,28 +18,36 @@ export enum ApprovalStatus {
REJECTED = "rejected"
}
type TApprovalCreateSecret = Omit<TSaRequestSecrets, TImmutableDBKeys | "version"> & {
type TApprovalCreateSecret = Omit<
TSaRequestSecrets,
| TImmutableDBKeys
| "version"
| "algorithm"
| "keyEncoding"
| "requestId"
| "op"
| "secretVersion"
| "secretBlindIndex"
> & {
secretName: string;
tagIds?: string[];
};
type TApprovalUpdateSecret = Partial<Omit<TSaRequestSecrets, TImmutableDBKeys | "version">> & {
type TApprovalUpdateSecret = Partial<TApprovalCreateSecret> & {
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 }[];
[CommitType.Create]?: TApprovalCreateSecret[];
[CommitType.Update]?: TApprovalUpdateSecret[];
[CommitType.Delete]?: { secretName: string }[];
};
};
} & TProjectPermission;
export type TMergeSecretApprovalRequestDTO = {
approvalId: string;
@@ -47,7 +55,7 @@ export type TMergeSecretApprovalRequestDTO = {
export type TStatusChangeDTO = {
approvalId: string;
status: "open" | "close";
status: RequestState;
} & Omit<TProjectPermission, "projectId">;
export type TReviewRequestDTO = {
@@ -67,5 +75,5 @@ export type TListApprovalsDTO = {
} & TProjectPermission;
export type TSecretApprovalDetailsDTO = {
id:string;
} & Omit<TProjectPermission, 'projectId'>
id: string;
} & Omit<TProjectPermission, "projectId">;
@@ -0,0 +1,138 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { SecretRotationsSchema, TableName, TSecretRotations } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex";
export type TSecretRotationDalFactory = ReturnType<typeof secretRotationDalFactory>;
export const secretRotationDalFactory = (db: TDbClient) => {
const secretRotationOrm = ormify(db, TableName.SecretRotation);
const secretRotationOutputOrm = ormify(db, TableName.SecretRotationOutput);
const findQuery = (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx: Knex) =>
tx(TableName.SecretRotation)
.where(filter)
.join(
TableName.Environment,
`${TableName.SecretRotation}.envId`,
`${TableName.Environment}.id`
)
.leftJoin(
TableName.SecretRotationOutput,
`${TableName.SecretRotation}.id`,
`${TableName.SecretRotationOutput}.rotationId`
)
.join(
TableName.Secret,
`${TableName.SecretRotationOutput}.secretId`,
`${TableName.Secret}.id`
)
.select(selectAllTableCols(TableName.SecretRotation))
.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(tx.ref("key").withSchema(TableName.SecretRotationOutput).as("outputKey"))
.select(tx.ref("id").withSchema(TableName.Secret).as("secId"))
.select(tx.ref("version").withSchema(TableName.Secret).as("secVersion"))
.select(tx.ref("secretKeyIV").withSchema(TableName.Secret))
.select(tx.ref("secretKeyTag").withSchema(TableName.Secret))
.select(tx.ref("secretKeyCiphertext").withSchema(TableName.Secret))
.select(tx.ref("secretValueIV").withSchema(TableName.Secret))
.select(tx.ref("secretValueTag").withSchema(TableName.Secret))
.select(tx.ref("secretValueCiphertext").withSchema(TableName.Secret))
.select(tx.ref("secretCommentIV").withSchema(TableName.Secret))
.select(tx.ref("secretCommentTag").withSchema(TableName.Secret))
.select(tx.ref("secretCommentCiphertext").withSchema(TableName.Secret));
const find = async (filter: TFindFilter<TSecretRotations & { projectId: string }>, tx?: Knex) => {
try {
const data = await findQuery(filter, tx || db);
return sqlNestRelationships({
data,
key: "id",
parentMapper: (el) => ({
...SecretRotationsSchema.parse(el),
projectId: el.projectId,
environment: { id: el.envId, name: el.envName, slug: el.envSlug }
}),
childrenMapper: [
{
key: "secId",
label: "outputs",
mapper: ({
secId,
outputKey,
secVersion,
secretKeyIV,
secretKeyTag,
secretKeyCiphertext,
secretValueTag,
secretValueIV,
secretValueCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext
}) => ({
key: outputKey,
secret: {
id: secId,
version: secVersion,
secretKeyIV,
secretKeyTag,
secretKeyCiphertext,
secretValueTag,
secretValueIV,
secretValueCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentCiphertext
}
})
}
] as const
});
} catch (error) {
throw new DatabaseError({ error, name: "SecretRotationFind" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const doc = await (tx || db)(TableName.SecretRotation)
.join(
TableName.Environment,
`${TableName.SecretRotation}.envId`,
`${TableName.Environment}.id`
)
.where({ [`${TableName.SecretRotation}.id` as "id"]: id })
.select(selectAllTableCols(TableName.SecretRotation))
.select(
db.ref("id").withSchema(TableName.Environment).as("envId"),
db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("name").withSchema(TableName.Environment).as("envName")
)
.first();
if (doc) {
const { envName, envSlug, envId, ...el } = doc;
return { ...el, envId, environment: { id: envId, slug: envSlug, name: envName } };
}
} catch (error) {
throw new DatabaseError({ error, name: "SecretRotationFindById" });
}
};
const findRotationOutputsByRotationId = async (rotationId: string) =>
secretRotationOutputOrm.find({ rotationId });
return {
...secretRotationOrm,
find,
findById,
secretOutputInsertMany: secretRotationOutputOrm.insertMany,
findRotationOutputsByRotationId
};
};
@@ -0,0 +1,5 @@
export {
DisableRotationErrors,
secretRotationQueueFactory,
TSecretRotationQueueFactory
} from "./secret-rotation-queue";
@@ -0,0 +1,157 @@
/* eslint-disable no-param-reassign */
import axios from "axios";
import jmespath from "jmespath";
import knex from "knex";
import { getConfig } from "@app/lib/config/env";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import {
TAssignOp,
TDbProviderClients,
TDirectAssignOp,
THttpProviderFunction
} from "../templates/types";
import { TSecretRotationData, TSecretRotationDbFn } from "./secret-rotation-queue-types";
const REGEX = /\${([^}]+)}/g;
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
export const interpolate = (data: any, getValue: (key: string) => unknown) => {
if (!data) return;
if (typeof data === "number") return data;
if (typeof data === "string") {
return data.replace(REGEX, (_a, b) => getValue(b) as string);
}
if (typeof data === "object" && Array.isArray(data)) {
data.forEach((el, index) => {
// eslint-disable-next-line
data[index] = interpolate(el, getValue);
});
}
if (typeof data === "object") {
if ((data as { ref: string })?.ref) return getValue((data as { ref: string }).ref);
const temp = data as Record<string, unknown>; // for converting ts object to record type
Object.keys(temp).forEach((key) => {
temp[key as keyof typeof temp] = interpolate(data[key as keyof typeof temp], getValue);
});
}
return data;
};
const getInterpolationValue = (variables: TSecretRotationData) => (key: string) => {
if (key.includes("|")) {
const [keyword, ...arg] = key.split("|").map((el) => el.trim());
switch (keyword) {
case "random": {
return alphaNumericNanoId(parseInt(arg[0], 10));
}
default: {
throw Error(`Interpolation key not found - ${key}`);
}
}
}
const [type, keyName] = key.split(".").map((el) => el.trim());
return variables[type as keyof TSecretRotationData][keyName];
};
export const secretRotationHttpFn = async (
func: THttpProviderFunction,
variables: TSecretRotationData
) => {
// string interpolation
const headers = interpolate(func.header, getInterpolationValue(variables));
const url = interpolate(func.url, getInterpolationValue(variables));
const body = interpolate(func.body, getInterpolationValue(variables));
// axios will automatically throw error if req status is not between 2xx range
return axios({
method: func.method,
url,
headers,
data: body,
timeout: EXTERNAL_REQUEST_TIMEOUT
});
};
export const secretRotationDbFn = async ({
ca,
host,
port,
query,
database,
password,
username,
client,
variables
}: TSecretRotationDbFn) => {
const appCfg = getConfig();
const ssl = ca ? { rejectUnauthorized: false, ca } : undefined;
if (host === "localhost" || host === "127.0.0.1" || appCfg.DB_CONNECTION_URI.includes(host))
throw new Error("Invalid db host");
const db = knex({
client,
connection: {
db: database,
port,
host,
user: username,
password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
ssl,
pool: { min: 0, max: 1 }
}
});
const data = await db.raw(query, variables);
return data;
};
export const secretRotationPreSetFn = (
op: Record<string, TDirectAssignOp>,
variables: TSecretRotationData
) => {
const getValFn = getInterpolationValue(variables);
Object.entries(op || {}).forEach(([key, assignFn]) => {
const [type, keyName] = key.split(".") as [keyof TSecretRotationData, string];
variables[type][keyName] = interpolate(assignFn.value, getValFn);
});
};
export const secretRotationHttpSetFn = async (
func: THttpProviderFunction,
variables: TSecretRotationData
) => {
const getValFn = getInterpolationValue(variables);
// http setter
const res = await secretRotationHttpFn(func, variables);
Object.entries(func.setter || {}).forEach(([key, assignFn]) => {
const [type, keyName] = key.split(".") as [keyof TSecretRotationData, string];
if (assignFn.assign === TAssignOp.JmesPath) {
variables[type][keyName] = jmespath.search(res.data, assignFn.path);
} else if (assignFn.value) {
variables[type][keyName] = interpolate(assignFn.value, getValFn);
}
});
};
export const getDbSetQuery = (
db: TDbProviderClients,
variable: { username: string; password: string }
) => {
if (db === TDbProviderClients.Pg) {
return {
query: "ALTER USER :username WITH PASSWORD :password",
variable
};
}
// add more based on client
return {
query: "ALTER USER :username IDENTIFIED BY :password",
variable
};
};
@@ -0,0 +1,27 @@
import { TDbProviderClients } from "../templates/types";
export type TSecretRotationEncData = {
inputs: Record<string, unknown>;
creds: Array<{
outputs: Record<string, unknown>;
internal: Record<string, unknown>;
}>;
};
export type TSecretRotationData = {
inputs: Record<string, unknown>;
outputs: Record<string, unknown>;
internal: Record<string, unknown>;
};
export type TSecretRotationDbFn = {
client: TDbProviderClients;
username: string;
password: string;
host: string;
database: string;
port: number;
query: string;
variables: Record<string, unknown>;
ca?: string;
};
@@ -0,0 +1,245 @@
import { SecretKeyEncoding } from "@app/db/schemas";
import {
encryptSymmetric128BitHexKeyUTF8,
infisicalSymmetricDecrypt,
infisicalSymmetricEncypt
} from "@app/lib/crypto/encryption";
import { daysToMillisecond } from "@app/lib/dates";
import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TSecretDalFactory } from "@app/services/secret/secret-dal";
import { TSecretVersionDalFactory } from "@app/services/secret/secret-version-dal";
import { TSecretRotationDalFactory } from "../secret-rotation-dal";
import { rotationTemplates } from "../templates";
import { TProviderFunctionTypes, TSecretRotationProviderTemplate } from "../templates/types";
import {
getDbSetQuery,
secretRotationDbFn,
secretRotationHttpFn,
secretRotationHttpSetFn,
secretRotationPreSetFn} from "./secret-rotation-queue-fn";
import {
TSecretRotationData,
TSecretRotationDbFn,
TSecretRotationEncData
} from "./secret-rotation-queue-types";
export type TSecretRotationQueueFactory = ReturnType<typeof secretRotationQueueFactory>;
type TSecretRotationQueueFactoryDep = {
queue: TQueueServiceFactory;
secretRotationDal: TSecretRotationDalFactory;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
secretDal: Pick<TSecretDalFactory, "bulkUpdate">;
secretVersionDal: Pick<TSecretVersionDalFactory, "insertMany">;
};
// These error should stop the repeatable job and ask user to reconfigure rotation
export class DisableRotationErrors extends Error {
name: string;
error: unknown;
constructor({ name, error, message }: { message: string; name?: string; error?: unknown }) {
super(message);
this.name = name || "DisableRotationErrors";
this.error = error;
}
}
export const secretRotationQueueFactory = ({
queue,
secretRotationDal,
projectBotService,
secretDal,
secretVersionDal
}: TSecretRotationQueueFactoryDep) => {
const addToQueue = async (rotationId: string, interval: number) =>
queue.queue(
QueueName.SecretRotation,
QueueJobs.SecretRotation,
{ rotationId },
{ jobId: rotationId, repeat: { every: daysToMillisecond(interval), immediately: true } }
);
const removeFromQueue = async (rotationId: string) =>
queue.stopRepeatableJob(QueueName.SecretRotation, rotationId);
queue.start(QueueName.SecretRotation, async (job) => {
const { rotationId } = job.data;
logger.info(`secretRotationQueue.process: [rotationDocument=${rotationId}]`);
const secretRotation = await secretRotationDal.findById(rotationId);
const rotationProvider = rotationTemplates.find(
({ name }) => name === secretRotation?.provider
);
try {
if (!rotationProvider || !secretRotation)
throw new DisableRotationErrors({ message: "Provider not found" });
const rotationOutputs = await secretRotationDal.findRotationOutputsByRotationId(rotationId);
if (!rotationOutputs.length)
throw new DisableRotationErrors({ message: "Secrets not found" });
// deep copy
const provider = JSON.parse(
JSON.stringify(rotationProvider)
) as TSecretRotationProviderTemplate;
// now get the encrypted variable values
// in includes the inputs, the previous outputs
// internal mapping variables etc
const { encryptedDataTag, encryptedDataIV, encryptedData, keyEncoding } = secretRotation;
if (!encryptedDataTag || !encryptedDataIV || !encryptedData || !keyEncoding) {
throw new DisableRotationErrors({ message: "No inputs found" });
}
const decryptedData = infisicalSymmetricDecrypt({
keyEncoding: keyEncoding as SecretKeyEncoding,
ciphertext: encryptedData,
iv: encryptedDataIV,
tag: encryptedDataTag
});
const variables = JSON.parse(decryptedData) as TSecretRotationEncData;
// rotation set cycle
const newCredential: TSecretRotationData = {
inputs: variables.inputs,
outputs: {},
internal: {}
};
// when its a database we keep cycling the variables accordingly
if (provider.template.type === TProviderFunctionTypes.DB) {
const lastCred = variables.creds.at(-1);
if (lastCred && variables.creds.length === 1) {
newCredential.internal.username =
lastCred.internal.username === variables.inputs.username1
? variables.inputs.username2
: variables.inputs.username1;
} else {
newCredential.internal.username = lastCred
? lastCred.internal.username
: variables.inputs.username1;
}
// set a random value for new password
newCredential.internal.rotated_password = alphaNumericNanoId(32);
const { username, password, host, database, port, ca } = newCredential.inputs;
const dbFunctionArg = {
username,
password,
host,
database,
port,
ca: ca as string,
client: provider.template.client
} as TSecretRotationDbFn;
// set function
await secretRotationDbFn({
...dbFunctionArg,
...getDbSetQuery(provider.template.client, {
password: newCredential.internal.rotated_password as string,
username: newCredential.internal.username as string
})
});
// test function
await secretRotationDbFn({
...dbFunctionArg,
query: "SELECT NOW()",
variables: {}
});
// clean up
if (variables.creds.length === 2) variables.creds.pop();
}
if (provider.template.type === TProviderFunctionTypes.HTTP) {
if (provider.template.functions.set?.pre) {
secretRotationPreSetFn(provider.template.functions.set.pre, newCredential);
}
await secretRotationHttpSetFn(provider.template.functions.set, newCredential);
// now test
await secretRotationHttpFn(provider.template.functions.test, newCredential);
if (variables.creds.length === 2) {
const deleteCycleCred = variables.creds.pop();
if (deleteCycleCred && provider.template.functions.remove) {
const deleteCycleVar = { inputs: variables.inputs, ...deleteCycleCred };
if (provider.template.type === TProviderFunctionTypes.HTTP) {
await secretRotationHttpFn(provider.template.functions.remove, deleteCycleVar);
}
}
}
}
variables.creds.unshift({
outputs: newCredential.outputs,
internal: newCredential.internal
});
const encVarData = infisicalSymmetricEncypt(JSON.stringify(variables));
const key = await projectBotService.getBotKey(secretRotation.projectId);
const encryptedSecrets = rotationOutputs.map(({ key: outputKey, secretId }) => ({
secretId,
value: encryptSymmetric128BitHexKeyUTF8(
typeof newCredential.outputs[outputKey] === "object"
? JSON.stringify(newCredential.outputs[outputKey])
: String(newCredential.outputs[outputKey]),
key
)
}));
await secretRotationDal.transaction(async (tx) => {
await secretRotationDal.updateById(
rotationId,
{
encryptedData: encVarData.ciphertext,
encryptedDataIV: encVarData.iv,
encryptedDataTag: encVarData.tag,
keyEncoding: encVarData.encoding,
algorithm: encVarData.algorithm,
lastRotatedAt: new Date(),
statusMessage: "Rotated successfull",
status: "success"
},
tx
);
const updatedSecrets = await secretDal.bulkUpdate(
encryptedSecrets.map(({ secretId, value }) => ({
id: secretId,
secretValueCiphertext: value.ciphertext,
secretValueIV: value.iv,
secretValueTag: value.tag
})),
tx
);
await secretDal.bulkUpdate(
updatedSecrets.map(({ id, version }) => ({ id, version: (version || 0) + 1 })),
tx
);
await secretVersionDal.insertMany(
updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
secretId: id
})),
tx
);
});
} catch (error) {
if (error instanceof DisableRotationErrors) {
if (job.id) {
queue.stopRepeatableJob(QueueName.SecretRotation, job.id);
}
}
await secretRotationDal.updateById(rotationId, {
status: "failed",
statusMessage: (error as Error).message.slice(0, 500),
lastRotatedAt: new Date()
});
}
});
return {
addToQueue,
removeFromQueue
};
};
@@ -0,0 +1,195 @@
import { ForbiddenError } from "@casl/ability";
import Ajv from "ajv";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types";
import { TProjectEnvDalFactory } from "@app/services/project-env/project-env-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TSecretRotationDalFactory } from "./secret-rotation-dal";
import { TSecretRotationQueueFactory } from "./secret-rotation-queue";
import { TSecretRotationEncData } from "./secret-rotation-queue/secret-rotation-queue-types";
import {
TCreateSecretRotationDTO,
TDeleteDTO,
TGetByIdDTO,
TListByProjectIdDTO,
TRestartDTO
} from "./secret-rotation-types";
import { rotationTemplates } from "./templates";
type TSecretRotationServiceFactoryDep = {
secretRotationDal: TSecretRotationDalFactory;
projectEnvDal: Pick<TProjectEnvDalFactory, "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
secretRotationQueue: TSecretRotationQueueFactory;
};
export type TSecretRotationServiceFactory = ReturnType<typeof secretRotationServiceFactory>;
const ajv = new Ajv({ strict: false });
export const secretRotationServiceFactory = ({
secretRotationDal,
permissionService,
projectEnvDal,
secretRotationQueue
}: TSecretRotationServiceFactoryDep) => {
const getProviderTemplates = async ({ actor, actorId, projectId }: TProjectPermission) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRotation
);
return {
custom: [],
providers: rotationTemplates
};
};
const createRotation = async ({
projectId,
actorId,
actor,
inputs,
outputs,
interval,
provider,
secretPath,
environment
}: TCreateSecretRotationDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.SecretRotation
);
const env = await projectEnvDal.findOne({ slug: environment });
if (!env) throw new BadRequestError({ message: "Environment not found" });
const selectedTemplate = rotationTemplates.find(({ name }) => name === provider);
if (!selectedTemplate) throw new BadRequestError({ message: "Provider not found" });
const formattedInputs: Record<string, unknown> = {};
Object.entries(inputs).forEach(([key, value]) => {
const { type } = selectedTemplate.template.inputs.properties[key];
if (type === "string") {
formattedInputs[key] = value;
return;
}
if (type === "integer") {
formattedInputs[key] = parseInt(value as string, 10);
return;
}
formattedInputs[key] = JSON.parse(value as string);
});
// ensure input one follows the correct schema
const valid = ajv.validate(selectedTemplate.template.inputs, formattedInputs);
if (!valid) {
throw new BadRequestError({ message: ajv.errors?.[0].message });
}
const unencryptedData: Partial<TSecretRotationEncData> = {
inputs: formattedInputs,
creds: []
};
const encData = infisicalSymmetricEncypt(JSON.stringify(unencryptedData));
const secretRotation = secretRotationDal.transaction(async (tx) => {
const doc = await secretRotationDal.create(
{
provider,
secretPath,
interval,
envId: env.id,
encryptedDataTag: encData.tag,
encryptedDataIV: encData.iv,
encryptedData: encData.ciphertext,
algorithm: encData.algorithm,
keyEncoding: encData.encoding
},
tx
);
await secretRotationQueue.addToQueue(doc.id, doc.interval);
const outputSecretMapping = await secretRotationDal.secretOutputInsertMany(
Object.entries(outputs).map(([key, secretId]) => ({ key, secretId, rotationId: doc.id })),
tx
);
return { ...doc, outputs: outputSecretMapping, environment: env };
});
return secretRotation;
};
const getById = async ({ rotationId, actor, actorId }: TGetByIdDTO) => {
const [doc] = await secretRotationDal.find({ id: rotationId });
if (!doc) throw new BadRequestError({ message: "Rotation not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
doc.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRotation
);
return doc;
};
const getByProjectId = async ({ actorId, projectId, actor }: TListByProjectIdDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRotation
);
const doc = await secretRotationDal.find({ projectId });
return doc;
};
const restartById = async ({ actor, actorId, rotationId }: TRestartDTO) => {
const doc = await secretRotationDal.findById(rotationId);
if (!doc) throw new BadRequestError({ message: "Rotation not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
doc.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.SecretRotation
);
await secretRotationQueue.removeFromQueue(doc.id);
await secretRotationQueue.addToQueue(doc.id, doc.interval);
return doc;
};
const deleteById = async ({ actor, actorId, rotationId }: TDeleteDTO) => {
const doc = await secretRotationDal.findById(rotationId);
if (!doc) throw new BadRequestError({ message: "Rotation not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
doc.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.SecretRotation
);
const deletedDoc = await secretRotationDal.transaction(async (tx) => {
const strat = await secretRotationDal.deleteById(rotationId, tx);
await secretRotationQueue.removeFromQueue(strat.id);
return strat;
});
return { ...doc, ...deletedDoc };
};
return {
getProviderTemplates,
getById,
getByProjectId,
createRotation,
restartById,
deleteById
};
};
@@ -0,0 +1,24 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateSecretRotationDTO = {
secretPath: string;
environment: string;
interval: number;
provider: string;
inputs: Record<string, unknown>;
outputs: Record<string, string>;
} & TProjectPermission;
export type TListByProjectIdDTO = TProjectPermission;
export type TDeleteDTO = {
rotationId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRestartDTO = {
rotationId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetByIdDTO = {
rotationId: string;
} & Omit<TProjectPermission, "projectId">;
@@ -0,0 +1,28 @@
import { MYSQL_TEMPLATE } from "./mysql";
import { POSTGRES_TEMPLATE } from "./postgres";
import { SENDGRID_TEMPLATE } from "./sendgrid";
import { TSecretRotationProviderTemplate } from "./types";
export const rotationTemplates: TSecretRotationProviderTemplate[] = [
{
name: "sendgrid",
title: "Twilio Sendgrid",
image: "sendgrid.png",
description: "Rotate Twilio Sendgrid API keys",
template: SENDGRID_TEMPLATE
},
{
name: "postgres",
title: "PostgreSQL",
image: "postgres.png",
description: "Rotate PostgreSQL/CockroachDB user credentials",
template: POSTGRES_TEMPLATE
},
{
name: "mysql",
title: "MySQL",
image: "mysql.png",
description: "Rotate MySQL@7/MariaDB user credentials",
template: MYSQL_TEMPLATE
}
];
@@ -0,0 +1,41 @@
import { TDbProviderClients, TProviderFunctionTypes } from "./types";
export const MYSQL_TEMPLATE = {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.MySql,
inputs: {
type: "object" as const,
properties: {
admin_username: { type: "string" as const },
admin_password: { type: "string" as const },
host: { type: "string" as const },
database: { type: "string" as const },
port: { type: "integer" as const, default: "3306" },
username1: {
type: "string",
default: "infisical-sql-user1",
desc: "This user must be created in your database"
},
username2: {
type: "string",
default: "infisical-sql-user2",
desc: "This user must be created in your database"
},
ca: { type: "string", desc: "SSL certificate for db auth(string)" }
},
required: [
"admin_username",
"admin_password",
"host",
"database",
"username1",
"username2",
"port"
],
additionalProperties: false
},
outputs: {
db_username: { type: "string" },
db_password: { type: "string" }
}
};
@@ -0,0 +1,41 @@
import { TDbProviderClients, TProviderFunctionTypes } from "./types";
export const POSTGRES_TEMPLATE = {
type: TProviderFunctionTypes.DB as const,
client: TDbProviderClients.Pg as const,
inputs: {
type: "object" as const,
properties: {
admin_username: { type: "string" as const },
admin_password: { type: "string" as const },
host: { type: "string" as const },
database: { type: "string" as const },
port: { type: "integer" as const, default: "5432" },
username1: {
type: "string",
default: "infisical-pg-user1",
desc: "This user must be created in your database"
},
username2: {
type: "string",
default: "infisical-pg-user2",
desc: "This user must be created in your database"
},
ca: { type: "string", desc: "SSL certificate for db auth(string)" }
},
required: [
"admin_username",
"admin_password",
"host",
"database",
"username1",
"username2",
"port"
],
additionalProperties: false
},
outputs: {
db_username: { type: "string" },
db_password: { type: "string" }
}
};
@@ -0,0 +1,62 @@
/* eslint-disable no-template-curly-in-string */
import { TAssignOp, TProviderFunctionTypes } from "./types";
export const SENDGRID_TEMPLATE = {
type: TProviderFunctionTypes.HTTP as const,
inputs: {
type: "object" as const,
properties: {
admin_api_key: { type: "string" as const, desc: "Sendgrid admin api key to create new keys" },
api_key_scopes: {
type: "array",
items: { type: "string" as const },
desc: "Scopes for created tokens by rotation(Array)"
}
},
required: ["admin_api_key", "api_key_scopes"],
additionalProperties: false
},
outputs: {
api_key: { type: "string" }
},
internal: {
api_key_id: { type: "string" }
},
functions: {
set: {
url: "https://api.sendgrid.com/v3/api_keys",
method: "POST",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
body: {
name: "infisical-${random | 16}",
scopes: { ref: "inputs.api_key_scopes" }
},
setter: {
"outputs.api_key": {
assign: TAssignOp.JmesPath as const,
path: "api_key"
},
"internal.api_key_id": {
assign: TAssignOp.JmesPath as const,
path: "api_key_id"
}
}
},
remove: {
url: "https://api.sendgrid.com/v3/api_keys/${internal.api_key_id}",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
method: "DELETE"
},
test: {
url: "https://api.sendgrid.com/v3/api_keys/${internal.api_key_id}",
header: {
Authorization: "Bearer ${inputs.admin_api_key}"
},
method: "GET"
}
}
};
@@ -0,0 +1,72 @@
export enum TProviderFunctionTypes {
HTTP = "http",
DB = "database"
}
export enum TDbProviderClients {
// postgres, cockroack db, amazon red shift
Pg = "pg",
// mysql and maria db
MySql = "mysql"
}
export enum TAssignOp {
Direct = "direct",
JmesPath = "jmesopath"
}
export type TJmesPathAssignOp = {
assign: TAssignOp.JmesPath;
path: string;
};
export type TDirectAssignOp = {
assign: TAssignOp.Direct;
value: string;
};
export type TAssignFunction = TJmesPathAssignOp | TDirectAssignOp;
export type THttpProviderFunction = {
url: string;
method: string;
header?: Record<string, string>;
query?: Record<string, string>;
body?: Record<string, unknown>;
setter?: Record<string, TAssignFunction>;
pre?: Record<string, TDirectAssignOp>;
};
export type TSecretRotationProviderTemplate = {
name: string;
title: string;
image?: string;
description?: string;
template: THttpProviderTemplate | TDbProviderTemplate;
};
export type THttpProviderTemplate = {
type: TProviderFunctionTypes.HTTP;
inputs: {
type: "object";
properties: Record<string, { type: string; [x: string]: unknown; desc?: string }>;
required?: string[];
};
outputs: Record<string, unknown>;
functions: {
set: THttpProviderFunction;
remove?: THttpProviderFunction;
test: THttpProviderFunction;
};
};
export type TDbProviderTemplate = {
type: TProviderFunctionTypes.DB;
client: TDbProviderClients;
inputs: {
type: "object";
properties: Record<string, { type: string; [x: string]: unknown; desc?: string }>;
required?: string[];
};
outputs: Record<string, unknown>;
};