Ghost user WIP

This commit is contained in:
Daniel Hougaard
2024-02-22 04:59:11 +01:00
parent db773864d5
commit 84cab17f5c
8 changed files with 373 additions and 160 deletions
+156 -42
View File
@@ -1,10 +1,15 @@
import { ForbiddenError } from "@casl/ability";
import crypto from "crypto"; import crypto from "crypto";
import { z } from "zod"; import { z } from "zod";
import { ProjectMembershipRole, ProjectsSchema } from "@app/db/schemas"; import { ProjectMembershipRole, ProjectsSchema } from "@app/db/schemas";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { encryptAsymmetric } from "@app/lib/crypto"; import { encryptAsymmetric } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { createWsMembers } from "@app/lib/project"; import { createWsMembers } from "@app/lib/project";
import { authRateLimit } from "@app/server/config/rateLimiter"; import { authRateLimit } from "@app/server/config/rateLimiter";
import { ActorType } from "@app/services/auth/auth-type";
const projectWithEnv = ProjectsSchema.merge( const projectWithEnv = ProjectsSchema.merge(
z.object({ z.object({
@@ -24,103 +29,212 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
body: z.object({ body: z.object({
projectName: z.string().trim(), projectName: z.string().trim(),
inviteAllOrgMembers: z.boolean(), inviteMemberEmails: z.array(z.string().email()).optional(),
organizationId: z.string().trim() organizationId: z.string().trim()
}), }),
response: { response: {
200: z.object({ 200: z.object({
workspace: projectWithEnv project: projectWithEnv
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
// 1. create the ghost user and add it to the org as admin const { permission } = await server.services.permission.getOrgPermission(
const ghost = await server.services.org.addGhostUser(req.body.organizationId); req.permission.type,
req.permission.id,
req.body.organizationId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
// 2. create the workspace // 2. Create a new project (will set the e2ee db field to false).
const workspace = await server.services.project.createProject({ const { project, ghostUser } = await server.services.project.createProject({
actorId: ghost.user.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
orgId: req.body.organizationId, orgId: req.body.organizationId,
workspaceName: req.body.projectName workspaceName: req.body.projectName
}); });
// 3. create a random key that we'll use as the project key // 3. Create a random key that we'll use as the project key.
const randomBytes = crypto.randomBytes(16).toString("hex"); const randomBytes = crypto.randomBytes(16).toString("hex");
const ghostPrivateKey = ghost.keys.plainPrivateKey; // 4. Encrypt the project key with the users key pair.
const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric( const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric(
randomBytes, randomBytes,
ghost.keys.publicKey, ghostUser.keys.publicKey,
ghostPrivateKey ghostUser.keys.plainPrivateKey
); );
// 3. create workspace keys for the ghost user // 4. Save the project key for the ghost user.
await server.services.projectKey.uploadProjectKeys({ await server.services.projectKey.uploadProjectKeys({
projectId: workspace.id, projectId: project.id,
actor: req.permission.type, actor: req.permission.type,
actorId: ghost.user.id, actorId: ghostUser.user.id,
nonce: encryptedProjectKeyIv, nonce: encryptedProjectKeyIv,
receiverId: ghost.user.id, receiverId: ghostUser.user.id,
encryptedKey: encryptedProjectKey encryptedKey: encryptedProjectKey
}); });
// 4. create a project bot // 5. Create a bot for the project.
const bot = await server.services.projectBot.findBotByProjectId({ const bot = await server.services.projectBot.findBotByProjectId({
actorId: ghost.user.id, actorId: ghostUser.user.id,
actor: req.permission.type, actor: req.permission.type,
projectId: workspace.id projectId: project.id,
// We set the publicKey and privateKey of the bot to the same as the ghost user.
// We do this because we'll need to access the private key again later, when adding new members to the project.
publicKey: ghostUser.keys.publicKey,
privateKey: ghostUser.keys.plainPrivateKey
}); });
// 5. activate the bot // 6. Activate the bot.
await server.services.projectBot.setBotActiveState({ await server.services.projectBot.setBotActiveState({
botKey: { botKey: {
encryptedKey: encryptedProjectKey, encryptedKey: encryptedProjectKey,
nonce: encryptedProjectKeyIv nonce: encryptedProjectKeyIv
}, },
actorId: ghost.user.id, actorId: ghostUser.user.id,
isActive: true, isActive: true,
actor: req.permission.type, actor: req.permission.type,
botId: bot.id botId: bot.id
}); });
// 6. get the current user & org membership // 7. get the current user & org membership
const user = await server.services.user.getMe(req.permission.id); const user = await server.services.user.getMe(req.permission.id);
const userOrgMembership = await server.services.permission.getUserOrgPermission(user.id, req.body.organizationId); const userOrgMembership = await server.services.permission.getUserOrgPermission(user.id, req.body.organizationId);
// 7. Get the latest key from the ghost! // 7. Get the latest key from the ghost!
const latestKey = await server.services.projectKey.getLatestProjectKey({ const latestKey = await server.services.projectKey.getLatestProjectKey({
actorId: ghost.user.id, actorId: ghostUser.user.id,
actor: req.permission.type, actor: req.permission.type,
projectId: workspace.id projectId: project.id
}); });
if (!latestKey) throw new Error("Failed to get latest key"); if (!latestKey) throw new Error("Failed to get latest key");
// 8. Create workspace members for the current user // If the project is being created by a user, add the user to the project as an admin
if (req.permission.type === ActorType.USER) {
const projectAdmin = createWsMembers({
decryptKey: latestKey,
members: [
{
userPublicKey: user.publicKey,
orgMembershipId: userOrgMembership.membership.id,
projectMembershipRole: ProjectMembershipRole.Admin // <-- Make the first user an admin
}
],
userPrivateKey: ghostUser.keys.plainPrivateKey
});
const projectAdmin = await createWsMembers({ await server.services.projectMembership.addUsersToProject({
decryptKey: latestKey, projectId: project.id,
members: [ actorId: ghostUser.user.id,
{ actor: req.permission.type,
userPublicKey: user.publicKey, members: projectAdmin
orgMembershipId: userOrgMembership.membership.id, });
projectMembershipRole: ProjectMembershipRole.Admin // <-- Make the first user an admin }
} // If the project is being created by an identity, add the identity to the project as an admin
], else if (req.permission.type === ActorType.IDENTITY) {
userPrivateKey: ghostPrivateKey await server.services.identityProject.createProjectIdentity({
}); actor: ActorType.IDENTITY,
actorId: ghostUser.user.id,
identityId: req.permission.id,
projectId: project.id,
role: ProjectMembershipRole.Admin
});
}
// 9. Add the current user to the workspace return { project };
await server.services.projectMembership.addUsersToProject({ }
projectId: workspace.id, });
actorId: ghost.user.id,
server.route({
method: "POST",
url: "/:projectId/memberships",
config: {
rateLimit: authRateLimit
},
schema: {
params: z.object({
projectId: z.string()
}),
body: z.object({
emails: z.string().email().array()
})
},
handler: async (req) => {
const { permission } = await server.services.permission.getProjectPermission(
req.permission.type,
req.permission.id,
req.params.projectId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const project = await server.services.project.getAProject({
actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
members: projectAdmin projectId: req.params.projectId
}); });
return { workspace }; const ghostUser = await server.services.project.findProjectGhostUser(req.params.projectId);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed" // TODO: Add a message
});
}
const latestKey = await server.services.projectKey.getLatestProjectKey({
actorId: ghostUser.id,
actor: ActorType.USER,
projectId: req.params.projectId
});
if (!latestKey) {
throw new BadRequestError({
message: "Failed to find project key"
});
}
const bot = await server.services.projectBot.findBotByProjectId({
actor: req.permission.type,
actorId: req.permission.id,
projectId: req.params.projectId
});
// We get the bot private key, because the bot private key is the same as the ghost user's private key.
const botPrivateKey = server.services.projectBot.getBotPrivateKey({ bot });
const members = await server.services.org.findOrgMembersByEmail({
actor: req.permission.type,
actorId: req.permission.id,
orgId: project.orgId,
emails: req.body.emails
});
if (members.length !== req.body.emails.length) {
throw new BadRequestError({
message: "Some users are not part of the organization"
});
}
const wsMembers = createWsMembers({
members: members.map((membership) => ({
orgMembershipId: membership.id,
projectMembershipRole: ProjectMembershipRole.Member,
userPublicKey: membership.user.publicKey
})),
decryptKey: latestKey,
userPrivateKey: botPrivateKey
});
await server.services.projectMembership.addUsersToProject({
projectId: req.params.projectId,
actorId: ghostUser.id, // We set the actor ID to the ghost user, because this is used as senderId in the project key sharing
actor: ActorType.USER,
members: wsMembers
});
return {};
} }
}); });
}; };
+41 -23
View File
@@ -2,6 +2,8 @@ import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import crypto from "crypto"; import crypto from "crypto";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { Knex } from "knex";
import { nanoid } from "nanoid";
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
import { TProjects } from "@app/db/schemas/projects"; import { TProjects } from "@app/db/schemas/projects";
@@ -30,6 +32,7 @@ import { TOrgRoleDALFactory } from "./org-role-dal";
import { import {
TDeleteOrgMembershipDTO, TDeleteOrgMembershipDTO,
TFindAllWorkspacesDTO, TFindAllWorkspacesDTO,
TFindOrgMembersByEmailDTO,
TInviteUserToOrgDTO, TInviteUserToOrgDTO,
TUpdateOrgDTO, TUpdateOrgDTO,
TUpdateOrgMembershipDTO, TUpdateOrgMembershipDTO,
@@ -95,6 +98,15 @@ export const orgServiceFactory = ({
return members; return members;
}; };
const findOrgMembersByEmail = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
const members = await orgDAL.findOrgMembersByEmail(orgId, emails);
return members;
};
const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => { const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
@@ -120,31 +132,38 @@ export const orgServiceFactory = ({
return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id)); return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id));
}; };
const addGhostUser = async (orgId: string) => { const addGhostUser = async (orgId: string, tx?: Knex) => {
const email = `ghost@${orgId}.com`; const email = `ghost@${nanoid(8)}-${orgId}.com`; // We add a nanoid because the email is unique. And we have to create a new ghost user each time, so we can have access to the private key.
const password = crypto.randomBytes(128).toString("hex"); const password = crypto.randomBytes(128).toString("hex");
const user = await userDAL.create({ const user = await userDAL.create(
ghost: true, {
authMethods: [AuthMethod.EMAIL], ghost: true,
email: `ghost@${orgId}.com`, authMethods: [AuthMethod.EMAIL],
isAccepted: true email,
}); isAccepted: true
},
tx
);
const encKeys = await generateUserSrpKeys(email, password); const encKeys = await generateUserSrpKeys(email, password);
await userDAL.upsertUserEncryptionKey(user.id, { await userDAL.upsertUserEncryptionKey(
encryptionVersion: 2, user.id,
protectedKey: encKeys.protectedKey, {
protectedKeyIV: encKeys.protectedKeyIV, encryptionVersion: 2,
protectedKeyTag: encKeys.protectedKeyTag, protectedKey: encKeys.protectedKey,
publicKey: encKeys.publicKey, protectedKeyIV: encKeys.protectedKeyIV,
encryptedPrivateKey: encKeys.encryptedPrivateKey, protectedKeyTag: encKeys.protectedKeyTag,
iv: encKeys.encryptedPrivateKeyIV, publicKey: encKeys.publicKey,
tag: encKeys.encryptedPrivateKeyTag, encryptedPrivateKey: encKeys.encryptedPrivateKey,
salt: encKeys.salt, iv: encKeys.encryptedPrivateKeyIV,
verifier: encKeys.verifier tag: encKeys.encryptedPrivateKeyTag,
}); salt: encKeys.salt,
verifier: encKeys.verifier
},
tx
);
const createMembershipData = { const createMembershipData = {
orgId, orgId,
@@ -153,9 +172,7 @@ export const orgServiceFactory = ({
status: OrgMembershipStatus.Accepted status: OrgMembershipStatus.Accepted
}; };
console.log("createMembershipData", createMembershipData); await orgDAL.createMembership(createMembershipData, tx);
await orgDAL.createMembership(createMembershipData);
return { return {
user, user,
@@ -533,6 +550,7 @@ export const orgServiceFactory = ({
inviteUserToOrganization, inviteUserToOrganization,
verifyUserToOrg, verifyUserToOrg,
updateOrg, updateOrg,
findOrgMembersByEmail,
createOrganization, createOrganization,
deleteOrganizationById, deleteOrganizationById,
deleteOrgMembership, deleteOrgMembership,
+7
View File
@@ -30,6 +30,13 @@ export type TVerifyUserToOrgDTO = {
code: string; code: string;
}; };
export type TFindOrgMembersByEmailDTO = {
actor: ActorType;
actorId: string;
orgId: string;
emails: string[];
};
export type TFindAllWorkspacesDTO = { export type TFindAllWorkspacesDTO = {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
@@ -13,10 +13,9 @@ import {
} from "@app/lib/crypto"; } from "@app/lib/crypto";
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types";
import { TProjectBotDALFactory } from "./project-bot-dal"; import { TProjectBotDALFactory } from "./project-bot-dal";
import { TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types"; import { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types";
type TProjectBotServiceFactoryDep = { type TProjectBotServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@@ -26,12 +25,12 @@ type TProjectBotServiceFactoryDep = {
export type TProjectBotServiceFactory = ReturnType<typeof projectBotServiceFactory>; export type TProjectBotServiceFactory = ReturnType<typeof projectBotServiceFactory>;
export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => { export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => {
const getBotPrivateKey = async ({ encoding, nonce, tag, encryptedPrivateKey }: TGetPrivateKeyDTO) => const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) =>
infisicalSymmetricDecrypt({ infisicalSymmetricDecrypt({
keyEncoding: encoding, keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: nonce, iv: bot.iv,
tag, tag: bot.tag,
ciphertext: encryptedPrivateKey ciphertext: bot.encryptedPrivateKey
}); });
const getBotKey = async (projectId: string) => { const getBotKey = async (projectId: string) => {
@@ -41,24 +40,24 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey)
throw new BadRequestError({ message: "Encryption key missing" }); throw new BadRequestError({ message: "Encryption key missing" });
const privateKeyBot = await getBotPrivateKey({ const botPrivateKey = getBotPrivateKey({ bot });
nonce: bot.iv,
tag: bot.tag,
encryptedPrivateKey: bot.encryptedPrivateKey,
encoding: bot.keyEncoding as SecretKeyEncoding
});
console.log("privateKeyBot", privateKeyBot);
return decryptAsymmetric({ return decryptAsymmetric({
ciphertext: bot.encryptedProjectKey, ciphertext: bot.encryptedProjectKey,
privateKey: privateKeyBot, privateKey: botPrivateKey,
nonce: bot.encryptedProjectKeyNonce, nonce: bot.encryptedProjectKeyNonce,
publicKey: bot.sender.publicKey publicKey: bot.sender.publicKey
}); });
}; };
const findBotByProjectId = async ({ actorId, actor, actorOrgId, projectId }: TProjectPermission) => { const findBotByProjectId = async ({
actorId,
actor,
projectId,
actorOrgId,
privateKey,
publicKey
}: TFindBotByProjectIdDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const appCfg = getConfig(); const appCfg = getConfig();
@@ -67,9 +66,10 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
const doc = await projectBotDAL.findOne({ projectId }, tx); const doc = await projectBotDAL.findOne({ projectId }, tx);
if (doc) return doc; if (doc) return doc;
const { publicKey, privateKey } = generateAsymmetricKeyPair(); const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair();
if (appCfg.ROOT_ENCRYPTION_KEY) { if (appCfg.ROOT_ENCRYPTION_KEY) {
const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY); const { iv, tag, ciphertext } = encryptSymmetric(keys.privateKey, appCfg.ROOT_ENCRYPTION_KEY);
return projectBotDAL.create( return projectBotDAL.create(
{ {
name: "Infisical Bot", name: "Infisical Bot",
@@ -78,7 +78,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
iv, iv,
encryptedPrivateKey: ciphertext, encryptedPrivateKey: ciphertext,
isActive: false, isActive: false,
publicKey, publicKey: keys.publicKey,
algorithm: SecretEncryptionAlgo.AES_256_GCM, algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.BASE64 keyEncoding: SecretKeyEncoding.BASE64
}, },
@@ -86,7 +86,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
); );
} }
if (appCfg.ENCRYPTION_KEY) { if (appCfg.ENCRYPTION_KEY) {
const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(privateKey, appCfg.ENCRYPTION_KEY); const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(keys.privateKey, appCfg.ENCRYPTION_KEY);
return projectBotDAL.create( return projectBotDAL.create(
{ {
name: "Infisical Bot", name: "Infisical Bot",
@@ -95,7 +95,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
iv, iv,
encryptedPrivateKey: ciphertext, encryptedPrivateKey: ciphertext,
isActive: false, isActive: false,
publicKey, publicKey: keys.publicKey,
algorithm: SecretEncryptionAlgo.AES_256_GCM, algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8 keyEncoding: SecretKeyEncoding.UTF8
}, },
@@ -107,6 +107,15 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
return bot; return bot;
}; };
const findProjectByBotId = async (botId: string) => {
try {
const bot = await projectBotDAL.findProjectByBotId(botId);
return bot;
} catch (e) {
throw new BadRequestError({ message: "Failed to find bot by ID" });
}
};
const setBotActiveState = async ({ actor, botId, botKey, actorId, actorOrgId, isActive }: TSetActiveStateDTO) => { const setBotActiveState = async ({ actor, botId, botKey, actorId, actorOrgId, isActive }: TSetActiveStateDTO) => {
const bot = await projectBotDAL.findById(botId); const bot = await projectBotDAL.findById(botId);
if (!bot) throw new BadRequestError({ message: "Bot not found" }); if (!bot) throw new BadRequestError({ message: "Bot not found" });
@@ -145,6 +154,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
findBotByProjectId, findBotByProjectId,
setBotActiveState, setBotActiveState,
getBotPrivateKey, getBotPrivateKey,
findProjectByBotId,
getBotKey getBotKey
}; };
}; };
@@ -1,4 +1,5 @@
import { SecretKeyEncoding } from "@app/db/schemas"; // import { SecretKeyEncoding } from "@app/db/schemas";
import { TProjectBots } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export type TSetActiveStateDTO = { export type TSetActiveStateDTO = {
@@ -10,9 +11,16 @@ export type TSetActiveStateDTO = {
botId: string; botId: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TFindBotByProjectIdDTO = {
privateKey?: string;
publicKey?: string;
} & TProjectPermission;
export type TGetPrivateKeyDTO = { export type TGetPrivateKeyDTO = {
encoding: SecretKeyEncoding; // encoding: SecretKeyEncoding;
nonce: string; // nonce: string;
tag: string; // tag: string;
encryptedPrivateKey: string; // encryptedPrivateKey: string;
bot: TProjectBots;
}; };
@@ -1,6 +1,7 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; import { OrgMembershipStatus, ProjectMembershipRole, TableName, TUsers } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
@@ -27,7 +28,7 @@ type TProjectMembershipServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
smtpService: TSmtpService; smtpService: TSmtpService;
projectMembershipDAL: TProjectMembershipDALFactory; projectMembershipDAL: TProjectMembershipDALFactory;
userDAL: Pick<TUserDALFactory, "findById" | "findOne">; userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">; projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">;
orgDAL: Pick<TOrgDALFactory, "findMembership">; orgDAL: Pick<TOrgDALFactory, "findMembership">;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
@@ -55,64 +56,78 @@ export const projectMembershipServiceFactory = ({
return projectMembershipDAL.findAllProjectMembers(projectId); return projectMembershipDAL.findAllProjectMembers(projectId);
}; };
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, email }: TInviteUserToProjectDTO) => { const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const invitee = await userDAL.findOne({ email }); const invitees: TUsers[] = [];
if (!invitee || !invitee.isAccepted)
throw new BadRequestError({ for (const email of emails) {
message: "Faield to validate invitee", const invitee = await userDAL.findOne({ email });
name: "Invite user to project" if (!invitee || !invitee.isAccepted)
throw new BadRequestError({
message: "Faield to validate invitee",
name: "Invite user to project"
});
const inviteeMembership = await projectMembershipDAL.findOne({
userId: invitee.id,
projectId
});
if (inviteeMembership)
throw new BadRequestError({
message: "Existing member of project",
name: "Invite user to project"
});
const project = await projectDAL.findById(projectId);
const inviteeMembershipOrg = await orgDAL.findMembership({
userId: invitee.id,
orgId: project.orgId,
status: OrgMembershipStatus.Accepted
});
if (!inviteeMembershipOrg)
throw new BadRequestError({
message: "Failed to validate invitee org membership",
name: "Invite user to project"
});
await projectMembershipDAL.create({
userId: invitee.id,
projectId,
role: ProjectMembershipRole.Member
}); });
const inviteeMembership = await projectMembershipDAL.findOne({ const sender = await userDAL.findById(actorId);
userId: invitee.id, const appCfg = getConfig();
projectId await smtpService.sendMail({
}); template: SmtpTemplates.WorkspaceInvite,
if (inviteeMembership) subjectLine: "Infisical workspace invitation",
throw new BadRequestError({ recipients: [invitee.email],
message: "Existing member of project", substitutions: {
name: "Invite user to project" inviterFirstName: sender.firstName,
inviterEmail: sender.email,
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
}); });
const project = await projectDAL.findById(projectId); invitees.push(invitee);
const inviteeMembershipOrg = await orgDAL.findMembership({ }
userId: invitee.id,
orgId: project.orgId,
status: OrgMembershipStatus.Accepted
});
if (!inviteeMembershipOrg)
throw new BadRequestError({
message: "Failed to validate invitee org membership",
name: "Invite user to project"
});
const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId);
await projectMembershipDAL.create({
userId: invitee.id,
projectId,
role: ProjectMembershipRole.Member
});
const sender = await userDAL.findById(actorId); return { invitees, latestKey };
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: [invitee.email],
substitutions: {
inviterFirstName: sender.firstName,
inviterEmail: sender.email,
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
return { invitee, latestKey };
}; };
const addUsersToProject = async ({ projectId, actorId, actor, actorOrgId, members }: TAddUsersToWorkspaceDTO) => { const addUsersToProject = async ({
projectId,
actorId,
actor,
actorOrgId,
members,
sendEmails = true
}: TAddUsersToWorkspaceDTO) => {
const project = await projectDAL.findById(projectId); const project = await projectDAL.findById(projectId);
if (!project) throw new BadRequestError({ message: "Project not found" }); if (!project) throw new BadRequestError({ message: "Project not found" });
@@ -158,19 +173,22 @@ export const projectMembershipServiceFactory = ({
tx tx
); );
}); });
const sender = await userDAL.findById(actorId);
const appCfg = getConfig(); if (sendEmails) {
await smtpService.sendMail({ const sender = await userDAL.findById(actorId);
template: SmtpTemplates.WorkspaceInvite, const appCfg = getConfig();
subjectLine: "Infisical workspace invitation", await smtpService.sendMail({
recipients: orgMembers.map(({ email }) => email).filter(Boolean), template: SmtpTemplates.WorkspaceInvite,
substitutions: { subjectLine: "Infisical workspace invitation",
inviterFirstName: sender.firstName, recipients: orgMembers.map(({ email }) => email).filter(Boolean),
inviterEmail: sender.email, substitutions: {
workspaceName: project.name, inviterFirstName: sender.firstName,
callback_url: `${appCfg.SITE_URL}/login` inviterEmail: sender.email,
} workspaceName: project.name,
}); callback_url: `${appCfg.SITE_URL}/login`
}
});
}
return orgMembers; return orgMembers;
}; };
@@ -185,6 +203,15 @@ export const projectMembershipServiceFactory = ({
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId);
if (membershipUser?.ghost) {
throw new BadRequestError({
message: "Unauthorized member update",
name: "Update project membership"
});
}
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole);
if (isCustomRole) { if (isCustomRole) {
const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); const customRole = await projectRoleDAL.findOne({ slug: role, projectId });
@@ -220,6 +247,15 @@ export const projectMembershipServiceFactory = ({
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member);
const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId);
if (membershipUser?.ghost) {
throw new BadRequestError({
message: "Cannot delete ghost",
name: "Delete project membership"
});
}
const membership = await projectMembershipDAL.transaction(async (tx) => { const membership = await projectMembershipDAL.transaction(async (tx) => {
const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx);
@@ -4,7 +4,7 @@ import { TProjectPermission } from "@app/lib/types";
export type TGetProjectMembershipDTO = TProjectPermission; export type TGetProjectMembershipDTO = TProjectPermission;
export type TInviteUserToProjectDTO = { export type TInviteUserToProjectDTO = {
email: string; emails: string[];
} & TProjectPermission; } & TProjectPermission;
export type TUpdateProjectMembershipDTO = { export type TUpdateProjectMembershipDTO = {
@@ -17,6 +17,7 @@ export type TDeleteProjectMembershipDTO = {
} & TProjectPermission; } & TProjectPermission;
export type TAddUsersToWorkspaceDTO = { export type TAddUsersToWorkspaceDTO = {
sendEmails?: boolean;
members: { members: {
orgMembershipId: string; orgMembershipId: string;
workspaceEncryptedKey: string; workspaceEncryptedKey: string;
@@ -11,6 +11,7 @@ import { createSecretBlindIndex } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TOrgServiceFactory } from "../org/org-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
@@ -28,7 +29,8 @@ type TProjectServiceFactoryDep = {
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
folderDAL: Pick<TSecretFolderDALFactory, "insertMany">; folderDAL: Pick<TSecretFolderDALFactory, "insertMany">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany">; projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser">;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">; secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -40,6 +42,7 @@ export const projectServiceFactory = ({
projectDAL, projectDAL,
permissionService, permissionService,
folderDAL, folderDAL,
orgService,
secretBlindIndexDAL, secretBlindIndexDAL,
projectMembershipDAL, projectMembershipDAL,
projectEnvDAL, projectEnvDAL,
@@ -64,7 +67,9 @@ export const projectServiceFactory = ({
}); });
} }
const newProject = projectDAL.transaction(async (tx) => { const results = await projectDAL.transaction(async (tx) => {
const ghostUser = await orgService.addGhostUser(orgId, tx);
const project = await projectDAL.create( const project = await projectDAL.create(
{ {
name: workspaceName, name: workspaceName,
@@ -74,10 +79,10 @@ export const projectServiceFactory = ({
}, },
tx tx
); );
// set user as admin member for project // set ghost user as admin of project
await projectMembershipDAL.create( await projectMembershipDAL.create(
{ {
userId: actorId, userId: ghostUser.user.id,
role: ProjectMembershipRole.Admin, role: ProjectMembershipRole.Admin,
projectId: project.id projectId: project.id
}, },
@@ -106,10 +111,23 @@ export const projectServiceFactory = ({
tx tx
); );
// _id for backward compat // _id for backward compat
return { ...project, environments: envs, _id: project.id }; return {
project: {
...project,
environments: envs,
_id: project.id
},
ghostUser
};
}); });
return newProject; return results;
};
const findProjectGhostUser = async (projectId: string) => {
const user = await projectMembershipDAL.findProjectGhostUser(projectId);
return user;
}; };
const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => { const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => {
@@ -156,6 +174,7 @@ export const projectServiceFactory = ({
createProject, createProject,
deleteProject, deleteProject,
getProjects, getProjects,
findProjectGhostUser,
getAProject, getAProject,
toggleAutoCapitalization, toggleAutoCapitalization,
updateName updateName