feat(infisical-pg): added secret version api, fixed pass reset flow, smtp connection issue fixed

This commit is contained in:
Akhil Mohan
2024-01-16 23:42:03 +05:30
parent 31c0bf6831
commit 5373cb6afb
18 changed files with 244 additions and 39 deletions

View File

@@ -8,6 +8,7 @@ import { registerSecretApprovalRequestRouter } from "./secret-approval-request-r
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
import { registerSecretRotationRouter } from "./secret-rotation-router";
import { registerSecretScanningRouter } from "./secret-scanning-router";
import { registerSecretVersionRouter } from "./secret-version-router";
import { registerSnapshotRouter } from "./snapshot-router";
import { registerTrustedIpRouter } from "./trusted-ip-router";
@@ -34,4 +35,5 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerSamlRouter, { prefix: "/sso" });
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
};

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { SecretVersionsSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSecretVersionRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/:secretId/secret-versions",
method: "GET",
schema: {
params: z.object({
secretId: z.string()
}),
querystring: z.object({
offset: z.coerce.number(),
limit: z.coerce.number()
}),
response: {
200: z.object({
secretVersions: SecretVersionsSchema.omit({ secretBlindIndex: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretVersions = await server.services.secret.getSecretVersions({
actor: req.permission.type,
actorId: req.permission.id,
limit: req.query.limit,
offset: req.query.offset,
secretId: req.params.secretId
});
return { secretVersions };
}
});
};

View File

@@ -6,12 +6,12 @@ import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TOrgDalFactory } from "@app/services/org/org-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDalFactory } from "@app/services/user/user-dal";
import { TSecretScanningDalFactory } from "../secret-scanning-dal";
import {
scanContentAndGetFindings,
scanFullRepoContentAndGetFindings} from "./secret-scanning-fns";
scanFullRepoContentAndGetFindings
} from "./secret-scanning-fns";
import {
SecretMatch,
TScanFullRepoEventPayload,
@@ -23,7 +23,6 @@ type TSecretScanningQueueFactoryDep = {
secretScanningDal: TSecretScanningDalFactory;
smtpService: Pick<TSmtpService, "sendMail">;
orgMembershipDal: Pick<TOrgDalFactory, "findMembership">;
userDal: Pick<TUserDalFactory, "find">;
};
export type TSecretScanningQueueFactory = ReturnType<typeof secretScanningQueueFactory>;
@@ -33,7 +32,6 @@ export const secretScanningQueueFactory = ({
secretScanningDal,
smtpService,
orgMembershipDal: orgMemberDal,
userDal
}: TSecretScanningQueueFactoryDep) => {
const startFullRepoScan = async (payload: TScanFullRepoEventPayload) => {
await queueService.queue(QueueName.SecretFullRepoScan, QueueJobs.SecretScan, payload, {
@@ -69,12 +67,7 @@ export const secretScanningQueueFactory = ({
orgId: organizationId,
role: OrgMembershipRole.Admin
});
const userEmails = await userDal.find({
$in: {
id: adminsOfWork.map(({ userId }) => userId).filter(Boolean) as string[]
}
});
return userEmails.map((userObject) => userObject.email);
return adminsOfWork.map((userObject) => userObject.email);
};
queueService.start(QueueName.SecretPushEventScan, async (job) => {

View File

@@ -251,7 +251,6 @@ export const registerRoutes = async (
const apiKeyService = apiKeyServiceFactory({ apiKeyDal, userDal });
const secretScanningQueue = secretScanningQueueFactory({
userDal,
smtpService,
secretScanningDal,
queueService,

View File

@@ -31,6 +31,8 @@ export const sapPubSchema = SecretApprovalPoliciesSchema.merge(
export const secretRawSchema = z.object({
id: z.string(),
_id: z.string(),
workspace: z.string(),
environment: z.string(),
version: z.number(),
type: z.string(),
secretKey: z.string(),

View File

@@ -1,8 +1,9 @@
import { z } from "zod";
import { BackupPrivateKeySchema } from "@app/db/schemas";
import { BackupPrivateKeySchema, UsersSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { validateSignUpAuthorization } from "@app/services/auth/auth-fns";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerPasswordRouter = async (server: FastifyZodProvider) => {
@@ -66,6 +67,58 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/email/password-reset",
schema: {
body: z.object({
email: z.string().email().trim()
}),
response: {
200: z.object({
message: z.string()
})
}
},
handler: async (req) => {
await server.services.password.sendPasswordResetEmail(req.body.email);
return {
message: "If an account exists with this email, a password reset link has been sent"
};
}
});
server.route({
method: "POST",
url: "/email/password-reset-verify",
schema: {
body: z.object({
email: z.string().email().trim(),
code: z.string().trim()
}),
response: {
200: z.object({
message: z.string(),
user: UsersSchema,
token: z.string()
})
}
},
handler: async (req) => {
const { token, user } = await server.services.password.verifyPasswordResetEmail(
req.body.email,
req.body.code
);
return {
message: "Successfully verified email",
user,
token
};
}
});
server.route({
method: "POST",
url: "/backup-private-key",
@@ -87,9 +140,10 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const token = validateSignUpAuthorization(req.headers.authorization as string, "",false)!
const backupPrivateKey = await server.services.password.createBackupPrivateKey({
...req.body,
userId: req.permission.id
userId: token.userId,
});
if (!backupPrivateKey) throw new Error("Failed to create backup key");
@@ -100,7 +154,6 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/backup-private-key",
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
response: {
200: z.object({
@@ -110,8 +163,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const token = validateSignUpAuthorization(req.headers.authorization as string, "",false)!
const backupPrivateKey = await server.services.password.getBackupPrivateKeyOfUser(
req.permission.id
token.userId
);
if (!backupPrivateKey) throw new Error("Failed to find backup key");
@@ -121,8 +175,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/email/password-reset",
onRequest: verifyAuth([AuthMode.JWT]),
url: "/password-reset",
schema: {
body: z.object({
protectedKey: z.string().trim(),
@@ -141,9 +194,10 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const token = validateSignUpAuthorization(req.headers.authorization as string, "",false)!
await server.services.password.resetPasswordByBackupKey({
...req.body,
userId: req.permission.id
userId: token.userId,
});
return { message: "Successfully updated backup private key" };

View File

@@ -65,7 +65,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
encryptedPrivateKeyTag: z.string().trim(),
salt: z.string().trim(),
verifier: z.string().trim(),
organizationName: z.string().trim(),
organizationName: z.string().trim().min(1),
providerAuthToken: z.string().trim().optional().nullish(),
attributionSource: z.string().trim().optional()
}),

View File

@@ -21,7 +21,7 @@ export const validateProviderAuthToken = (providerToken: string, email: string)
if (decodedToken.email !== email) throw new Error("Invalid auth credentials");
};
export const validateSignUpAuthorization = async (token: string, userId: string) => {
export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => {
const appCfg = getConfig();
const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>token?.split(" ", 2) ?? [
null,
@@ -45,6 +45,7 @@ export const validateSignUpAuthorization = async (token: string, userId: string)
AUTH_TOKEN_VALUE,
appCfg.JWT_AUTH_SECRET
) as AuthModeProviderSignUpTokenPayload;
if (!validate) return decodedToken;
if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError();
if (decodedToken.userId !== userId) throw new UnauthorizedError();

View File

@@ -10,7 +10,13 @@ import {
TOrgMembershipsUpdate
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, withTransaction } from "@app/lib/knex";
import {
buildFindFilter,
selectAllTableCols,
TFindFilter,
TFindOpt,
withTransaction
} from "@app/lib/knex";
export type TOrgDalFactory = ReturnType<typeof orgDalFactory>;
@@ -109,7 +115,7 @@ export const orgDalFactory = (db: TDbClient) => {
// MEMBERSHIP OPERATIONS
// --------------------
const orgMembershipOrm = ormify(db, TableName.OrgMembership);
// const orgMembershipOrm = ormify(db, TableName.OrgMembership);
const createMembership = async (data: TOrgMembershipsInsert, tx?: Knex) => {
try {
@@ -160,6 +166,32 @@ export const orgDalFactory = (db: TDbClient) => {
}
};
const findMembership = async (
filter: TFindFilter<TOrgMemberships>,
{ offset, limit, sort, tx }: TFindOpt<TOrgMemberships> = {}
) => {
try {
const query = (tx || db)(TableName.OrgMembership)
.where(buildFindFilter(filter))
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select(
selectAllTableCols(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users)
);
if (limit) query.limit(limit);
if (offset) query.offset(offset);
if (sort) {
query.orderBy(
sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))
);
}
const res = await query;
return res;
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });
}
};
return withTransaction(db, {
findAllOrgMembers,
findOrgById,
@@ -167,7 +199,7 @@ export const orgDalFactory = (db: TDbClient) => {
create,
updateById,
deleteById,
findMembership: orgMembershipOrm.find,
findMembership,
createMembership,
updateMembershipById,
deleteMembershipById,

View File

@@ -1,6 +1,6 @@
import { ForbiddenError } from "@casl/ability";
import { OrgMembershipStatus, ProjectMembershipRole } from "@app/db/schemas";
import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
@@ -142,7 +142,11 @@ export const projectMembershipServiceFactory = ({
);
const orgMembers = await orgDal.findMembership({
orgId: project.orgId,
$in: { id: members.map(({ orgMembershipId }) => orgMembershipId) }
$in: {
[`${TableName.OrgMembership}.id` as "id"]: members.map(
({ orgMembershipId }) => orgMembershipId
)
}
});
if (orgMembers.length !== members.length)
throw new BadRequestError({ message: "Some users are not part of org" });
@@ -180,7 +184,7 @@ export const projectMembershipServiceFactory = ({
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: orgMembers.map(({ userId }) => userId).filter(Boolean) as string[],
recipients: orgMembers.map(({ email }) => email).filter(Boolean) as string[],
substitutions: {
inviterFirstName: sender.firstName,
inviterEmail: sender.email,

View File

@@ -197,7 +197,10 @@ export const interpolateSecrets = ({
return expandSecrets;
};
export const decryptSecretRaw = (secret: TSecrets, key: string) => {
export const decryptSecretRaw = (
secret: TSecrets & { workspace: string; environment: string },
key: string
) => {
const secretKey = decryptSymmetric128BitHexKeyUTF8({
ciphertext: secret.secretKeyCiphertext,
iv: secret.secretKeyIV,
@@ -225,6 +228,8 @@ export const decryptSecretRaw = (secret: TSecrets, key: string) => {
return {
secretKey,
workspace: secret.workspace,
environment: secret.environment,
secretValue,
secretComment,
version: secret.version,

View File

@@ -38,6 +38,7 @@ import {
TGetASecretRawDTO,
TGetSecretsDTO,
TGetSecretsRawDTO,
TGetSecretVersionsDTO,
TListSecretVersionDTO,
TUpdateBulkSecretDTO,
TUpdateSecretDTO,
@@ -326,7 +327,7 @@ export const secretServiceFactory = ({
await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment });
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return { ...secret[0], tags };
return { ...secret[0], environment, workspace: projectId, tags };
};
const updateSecret = async ({
@@ -419,7 +420,7 @@ export const secretServiceFactory = ({
await snapshotService.performSnapshot(folderId);
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment });
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return updatedSecret[0];
return { ...updatedSecret[0], workspace: projectId, environment };
};
const deleteSecret = async ({
@@ -474,7 +475,7 @@ export const secretServiceFactory = ({
await secretQueueService.syncSecrets({ secretPath: path, projectId, environment });
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return deletedSecret[0];
return { ...deletedSecret[0], workspace: projectId, environment };
};
const getSecrets = async ({
@@ -515,9 +516,12 @@ export const secretServiceFactory = ({
secretDal,
folderDal
});
return { secrets, imports: importedSecrets };
return {
secrets: secrets.map((el) => ({ ...el, workspace: projectId, environment })),
imports: importedSecrets
};
}
return { secrets };
return { secrets: secrets.map((el) => ({ ...el, workspace: projectId, environment })) };
};
const getASecret = async ({
@@ -572,14 +576,18 @@ export const secretServiceFactory = ({
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) {
if (secretBlindIndex === importedSecrets[i].secrets[j].secretBlindIndex) {
return importedSecrets[i].secrets[j];
return {
...importedSecrets[i].secrets[j],
workspace: projectId,
environment: importedSecrets[i].environment.slug
};
}
}
}
}
if (!secret) throw new BadRequestError({ message: "Secret not found" });
return secret;
return { ...secret, workspace: projectId, environment };
};
const createManySecret = async ({
@@ -813,7 +821,12 @@ export const secretServiceFactory = ({
secrets: secrets.map((el) => decryptSecretRaw(el, botKey)),
imports: (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({
...el,
secrets: importedSecrets.map((sec) => decryptSecretRaw(sec, botKey))
secrets: importedSecrets.map((sec) =>
decryptSecretRaw(
{ ...sec, environment: el.environment.slug, workspace: projectId },
botKey
)
)
}))
};
};
@@ -953,6 +966,36 @@ export const secretServiceFactory = ({
return decryptSecretRaw(secret, botKey);
};
const getSecretVersions = async ({
actorId,
actor,
limit = 20,
offset = 0,
secretId
}: TGetSecretVersionsDTO) => {
const secret = await secretDal.findById(secretId);
if (!secret) throw new BadRequestError({ message: "Failed to find secret" });
const folder = await folderDal.findById(secret.folderId);
if (!folder) throw new BadRequestError({ message: "Failed to find secret" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
folder.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
);
const secretVersions = await secretVersionDal.find(
{ secretId },
{ offset, limit, sort: [["createdAt", "desc"]] }
);
return secretVersions;
};
return {
createSecret,
deleteSecret,
@@ -968,6 +1011,7 @@ export const secretServiceFactory = ({
updateSecretRaw,
deleteSecretRaw,
listSecretVersionsBySecretId,
getSecretVersions,
// external services function
fnSecretBulkDelete,
fnSecretBulkUpdate,

View File

@@ -167,6 +167,12 @@ export type TDeleteSecretRawDTO = TProjectPermission & {
type: SecretType;
};
export type TGetSecretVersionsDTO = Omit<TProjectPermission, "projectId"> & {
limit?: number;
offset?: number;
secretId: string;
};
export type TFnSecretBulkInsert = {
folderId: string;
tx?: Knex;

View File

@@ -74,6 +74,16 @@ services:
- NEXT_PUBLIC_ENV=development
- INFISICAL_TELEMETRY_ENABLED=false
smtp-server:
container_name: infisical-dev-smtp-server
image: lytrax/mailhog:latest # https://github.com/mailhog/MailHog/issues/353#issuecomment-821137362
restart: always
logging:
driver: "none" # disable saving logs
ports:
- 1025:1025 # SMTP server
- 8025:8025 # Web UI
volumes:
postgres-data:
driver: local

View File

@@ -1,3 +1,11 @@
export enum ProjectMembershipRole {
Admin = "admin",
Member = "member",
Custom = "custom",
Viewer = "viewer",
NoAccess = "no-access"
}
export type TGetProjectRolesDTO = {
workspaceId?: string;
};

View File

@@ -15,7 +15,10 @@ const generalPermissionSchema = z
export const formSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
slug: z.string().trim(),
slug: z
.string()
.trim()
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
permissions: z
.object({
workspace: z

View File

@@ -53,6 +53,7 @@ import {
useUpdateUserWorkspaceRole,
useUploadWsKey
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@@ -158,7 +159,6 @@ export const MemberListTab = () => {
() => members?.find(({ user: u }) => userId === u?.id)?.role === "owner",
[userId, members]
);
console.log(members);
const findRoleFromId = useCallback(
(roleId: string) => {
@@ -171,7 +171,9 @@ export const MemberListTab = () => {
if (!currentOrg?.id) return;
try {
const isCustomRole = !["admin", "member", "viewer"].includes(role);
const isCustomRole = !Object.values(ProjectMembershipRole).includes(
role as ProjectMembershipRole
);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {

View File

@@ -28,7 +28,10 @@ const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const;
export const formSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
slug: z.string().trim(),
slug: z
.string()
.trim()
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
permissions: z
.object({
secrets: z.record(multiEnvPermissionSchema).optional(),