Feat: Specify organization role and projects when inviting users to org

This commit is contained in:
Daniel Hougaard
2024-08-23 02:25:15 +04:00
parent 2ecfd1bb7e
commit 06d8800ee0
22 changed files with 387 additions and 178 deletions
+1
View File
@@ -688,6 +688,7 @@ export const registerRoutes = async (
orgDAL, orgDAL,
orgService, orgService,
projectMembershipDAL, projectMembershipDAL,
projectRoleDAL,
folderDAL, folderDAL,
licenseService, licenseService,
certificateAuthorityDAL, certificateAuthorityDAL,
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { UsersSchema } from "@app/db/schemas"; import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas";
import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; import { inviteUserRateLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -19,7 +19,8 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
inviteeEmails: z.array(z.string().trim().email()), inviteeEmails: z.array(z.string().trim().email()),
organizationId: z.string().trim(), organizationId: z.string().trim(),
projectIds: z.array(z.string().trim()).optional(), projectIds: z.array(z.string().trim()).optional(),
organizationRoleSlug: z.string().trim().optional() projectRoleSlug: z.nativeEnum(ProjectMembershipRole).optional(),
organizationRoleSlug: z.nativeEnum(OrgMembershipRole)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -44,6 +45,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
userId: req.permission.id, userId: req.permission.id,
inviteeEmails: req.body.inviteeEmails, inviteeEmails: req.body.inviteeEmails,
projectIds: req.body.projectIds, projectIds: req.body.projectIds,
projectRoleSlug: req.body.projectRoleSlug,
organizationRoleSlug: req.body.organizationRoleSlug, organizationRoleSlug: req.body.organizationRoleSlug,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId actorOrgId: req.permission.orgId
+25 -3
View File
@@ -1,6 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { IntegrationsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import {
IntegrationsSchema,
ProjectMembershipsSchema,
ProjectRolesSchema,
UserEncryptionKeysSchema,
UsersSchema
} from "@app/db/schemas";
import { PROJECTS } from "@app/lib/api-docs"; import { PROJECTS } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -122,15 +128,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit rateLimit: readLimit
}, },
schema: { schema: {
querystring: z.object({
includeRoles: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true")
}),
response: { response: {
200: z.object({ 200: z.object({
workspaces: projectWithEnv.array() workspaces: projectWithEnv
.extend({
roles: ProjectRolesSchema.array().optional()
})
.array()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => { handler: async (req) => {
const workspaces = await server.services.project.getProjects(req.permission.id); const workspaces = await server.services.project.getProjects({
includeRoles: req.query.includeRoles,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actor: req.permission.type,
actorOrgId: req.permission.orgId
});
return { workspaces }; return { workspaces };
} }
}); });
@@ -1,3 +1,5 @@
import { ProjectMembershipRole } from "@app/db/schemas";
export enum TokenType { export enum TokenType {
TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", TOKEN_EMAIL_CONFIRMATION = "emailConfirmation",
TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified
@@ -54,9 +56,14 @@ export enum TokenMetadataType {
InviteToProjects = "projects-invite" InviteToProjects = "projects-invite"
} }
// TODO: Split the payloads into separate types for better type inference (not needed until we add more types of metadata) export type TTokenInviteToProjectsMetadataPayload = {
projectIds: string[];
projectRoleSlug: ProjectMembershipRole;
userId: string;
orgId: string;
};
export type TTokenMetadata = { export type TTokenMetadata = {
type: TokenMetadataType.InviteToProjects; type: TokenMetadataType.InviteToProjects;
projectIds: string[]; payload: TTokenInviteToProjectsMetadataPayload;
userId: string;
}; };
@@ -1,7 +1,7 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; import { OrgMembershipStatus, TableName } from "@app/db/schemas";
import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -17,7 +17,7 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types";
import { TOrgDALFactory } from "../org/org-dal"; import { TOrgDALFactory } from "../org/org-dal";
import { TOrgServiceFactory } from "../org/org-service"; import { TOrgServiceFactory } from "../org/org-service";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
@@ -373,13 +373,17 @@ export const authSignupServiceFactory = ({
if (metadata) { if (metadata) {
const metadataObj = jwt.verify(metadata, appCfg.AUTH_SECRET) as TTokenMetadata; const metadataObj = jwt.verify(metadata, appCfg.AUTH_SECRET) as TTokenMetadata;
if (user.id !== metadataObj.userId) { if (
metadataObj.payload.userId !== user.id ||
metadataObj.payload.orgId !== orgMembership.orgId ||
metadataObj.type !== TokenMetadataType.InviteToProjects
) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: "Malformed or invalid metadata token" message: "Malformed or invalid metadata token"
}); });
} }
for await (const projectId of metadataObj.projectIds) { for await (const projectId of metadataObj.payload.projectIds) {
await addMembersToProject({ await addMembersToProject({
orgDAL, orgDAL,
projectDAL, projectDAL,
@@ -394,10 +398,13 @@ export const authSignupServiceFactory = ({
emails: [user.email!], emails: [user.email!],
usernames: [], usernames: [],
projectId, projectId,
projectMembershipRole: ProjectMembershipRole.Member, projectMembershipRole: metadataObj.payload.projectRoleSlug,
sendEmails: false sendEmails: false
}, },
tx {
tx,
throwOnProjectNotFound: false
}
); );
} }
} }
+57 -10
View File
@@ -4,7 +4,14 @@ import crypto from "crypto";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { Knex } from "knex"; import { Knex } from "knex";
import { OrgMembershipRole, OrgMembershipStatus, ProjectMembershipRole, TableName, TUsers } from "@app/db/schemas"; import {
OrgMembershipRole,
OrgMembershipStatus,
ProjectMembershipRole,
ProjectVersion,
TableName,
TUsers
} from "@app/db/schemas";
import { TProjects } from "@app/db/schemas/projects"; import { TProjects } from "@app/db/schemas/projects";
import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
@@ -25,8 +32,9 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenMetadataType, TokenType } from "../auth-token/auth-token-types"; import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { verifyProjectVersion } from "../project/project-fns";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
@@ -435,6 +443,7 @@ export const orgServiceFactory = ({
userId, userId,
inviteeEmails, inviteeEmails,
organizationRoleSlug, organizationRoleSlug,
projectRoleSlug,
projectIds, projectIds,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
@@ -467,6 +476,22 @@ export const orgServiceFactory = ({
}); });
} }
if (projectIds?.length) {
const projects = await projectDAL.find({
orgId,
$in: {
id: projectIds
}
});
// if its not v3, throw an error
if (!verifyProjectVersion(projects, ProjectVersion.V3)) {
throw new BadRequestError({
message: "One or more selected projects are not compatible with this operation. Please upgrade your projects."
});
}
}
const inviteeUsers = await orgDAL.transaction(async (tx) => { const inviteeUsers = await orgDAL.transaction(async (tx) => {
const users: Pick< const users: Pick<
TUsers & { orgId: string }, TUsers & { orgId: string },
@@ -474,6 +499,7 @@ export const orgServiceFactory = ({
>[] = []; >[] = [];
for await (const inviteeEmail of inviteeEmails) { for await (const inviteeEmail of inviteeEmails) {
const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx);
if (inviteeUser) { if (inviteeUser) {
// if user already exist means its already part of infisical // if user already exist means its already part of infisical
// Thus the signup flow is not needed anymore // Thus the signup flow is not needed anymore
@@ -504,7 +530,22 @@ export const orgServiceFactory = ({
tx tx
); );
if (projectIds) { if (projectIds?.length) {
if (
organizationRoleSlug === OrgMembershipRole.Custom ||
projectRoleSlug === ProjectMembershipRole.Custom
) {
throw new BadRequestError({
message: "Custom roles are not supported for inviting users to projects and organizations"
});
}
if (!projectRoleSlug) {
throw new BadRequestError({
message: "Selecting a project role is required to invite users to projects"
});
}
for await (const projectId of projectIds) { for await (const projectId of projectIds) {
await projectMembershipDAL.create( await projectMembershipDAL.create(
{ {
@@ -528,10 +569,12 @@ export const orgServiceFactory = ({
emails: [inviteeEmail], emails: [inviteeEmail],
usernames: [], usernames: [],
projectId, projectId,
projectMembershipRole: ProjectMembershipRole.Member, projectMembershipRole: projectRoleSlug,
sendEmails: false sendEmails: false
}, },
tx {
tx
}
); );
} }
} }
@@ -561,7 +604,7 @@ export const orgServiceFactory = ({
inviteEmail: inviteeEmail, inviteEmail: inviteeEmail,
orgId, orgId,
userId: user.id, userId: user.id,
role: organizationRoleSlug || OrgMembershipRole.Member, role: organizationRoleSlug,
status: OrgMembershipStatus.Invited, status: OrgMembershipStatus.Invited,
isActive: true isActive: true
}, },
@@ -588,13 +631,17 @@ export const orgServiceFactory = ({
}); });
let inviteMetadata: string = ""; let inviteMetadata: string = "";
if (projectIds && projectIds.length > 0) { if (projectIds && projectIds?.length > 0) {
inviteMetadata = jwt.sign( inviteMetadata = jwt.sign(
{ {
type: TokenMetadataType.InviteToProjects, type: TokenMetadataType.InviteToProjects,
projectIds, payload: {
userId: invitee.id projectIds,
}, projectRoleSlug: projectRoleSlug!, // Implicitly checked inside transaction if projectRoleSlug is undefined
userId: invitee.id,
orgId
}
} satisfies TTokenMetadata,
appCfg.AUTH_SECRET, appCfg.AUTH_SECRET,
{ {
expiresIn: appCfg.JWT_INVITE_LIFETIME expiresIn: appCfg.JWT_INVITE_LIFETIME
+3 -1
View File
@@ -1,3 +1,4 @@
import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas";
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
@@ -30,8 +31,9 @@ export type TInviteUserToOrgDTO = {
actorOrgId: string | undefined; actorOrgId: string | undefined;
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
inviteeEmails: string[]; inviteeEmails: string[];
organizationRoleSlug?: string; organizationRoleSlug: OrgMembershipRole;
projectIds?: string[]; projectIds?: string[];
projectRoleSlug?: ProjectMembershipRole;
}; };
export type TVerifyUserToOrgDTO = { export type TVerifyUserToOrgDTO = {
@@ -35,6 +35,11 @@ type AddMembersToNonE2EEProjectDTO = {
sendEmails?: boolean; sendEmails?: boolean;
}; };
type AddMembersToNonE2EEProjectOptions = {
tx?: Knex;
throwOnProjectNotFound?: boolean;
};
export const addMembersToProject = ({ export const addMembersToProject = ({
orgDAL, orgDAL,
projectDAL, projectDAL,
@@ -45,15 +50,22 @@ export const addMembersToProject = ({
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
smtpService smtpService
}: TAddMembersToProjectArg) => { }: TAddMembersToProjectArg) => {
// Can create multiple memberships for a singular project, based on user email / username
const addMembersToNonE2EEProject = async ( const addMembersToNonE2EEProject = async (
{ emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO, { emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO,
transaction?: Knex options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true }
) => { ) => {
const processTransaction = async (tx: Knex) => { const processTransaction = async (tx: Knex) => {
const usernamesAndEmails = [...emails, ...usernames]; const usernamesAndEmails = [...emails, ...usernames];
const project = await projectDAL.findProjectById(projectId); const project = await projectDAL.findProjectById(projectId);
if (!project) throw new BadRequestError({ message: "Project not found" }); if (!project) {
if (options.throwOnProjectNotFound) {
throw new BadRequestError({ message: "Project not found when attempting to add user to project" });
}
return [];
}
const orgMembers = await orgDAL.findOrgMembersByUsername( const orgMembers = await orgDAL.findOrgMembersByUsername(
project.orgId, project.orgId,
@@ -61,12 +73,6 @@ export const addMembersToProject = ({
tx tx
); );
// const allorgMembers = await orgDAL.findAllOrgMembers(project.orgId,);
console.log("usernameandena", usernamesAndEmails);
console.log("orgMembers", orgMembers);
// console.log("allorgMembers", allorgMembers);
if (orgMembers.length !== usernamesAndEmails.length) if (orgMembers.length !== usernamesAndEmails.length)
throw new BadRequestError({ message: "Some users are not part of org" }); throw new BadRequestError({ message: "Some users are not part of org" });
@@ -131,7 +137,7 @@ export const addMembersToProject = ({
tx tx
); );
await projectUserMembershipRoleDAL.insertMany( await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })),
tx tx
); );
@@ -172,8 +178,8 @@ export const addMembersToProject = ({
return members; return members;
}; };
if (transaction) { if (options.tx) {
return processTransaction(transaction); return processTransaction(options.tx);
} }
return projectMembershipDAL.transaction(processTransaction); return projectMembershipDAL.transaction(processTransaction);
}; };
@@ -0,0 +1,52 @@
import { ProjectMembershipRole } from "@app/db/schemas";
import {
projectAdminPermissions,
projectMemberPermissions,
projectNoAccessPermissions,
projectViewerPermission
} from "@app/ee/services/permission/project-permission";
export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => {
return [
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
projectId,
name: "Admin",
slug: ProjectMembershipRole.Admin,
permissions: projectAdminPermissions,
description: "Full administrative access over a project",
createdAt: new Date(),
updatedAt: new Date()
},
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
projectId,
name: "Developer",
slug: ProjectMembershipRole.Member,
permissions: projectMemberPermissions,
description: "Limited read/write role in a project",
createdAt: new Date(),
updatedAt: new Date()
},
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response
projectId,
name: "Viewer",
slug: ProjectMembershipRole.Viewer,
permissions: projectViewerPermission,
description: "Only read role in a project",
createdAt: new Date(),
updatedAt: new Date()
},
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
projectId,
name: "No Access",
slug: ProjectMembershipRole.NoAccess,
permissions: projectNoAccessPermissions,
description: "No access to any resources in the project",
createdAt: new Date(),
updatedAt: new Date()
}
].filter(({ slug }) => !roleFilter || roleFilter.includes(slug));
};
@@ -5,13 +5,9 @@ import { ProjectMembershipRole } from "@app/db/schemas";
import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { import {
projectAdminPermissions,
projectMemberPermissions,
projectNoAccessPermissions,
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSet, ProjectPermissionSet,
ProjectPermissionSub, ProjectPermissionSub
projectViewerPermission
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
@@ -20,6 +16,7 @@ import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/id
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TProjectRoleDALFactory } from "./project-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal";
import { getPredefinedRoles } from "./project-role-fns";
import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types";
type TProjectRoleServiceFactoryDep = { type TProjectRoleServiceFactoryDep = {
@@ -37,51 +34,6 @@ const unpackPermissions = (permissions: unknown) =>
unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]) unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
); );
const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => {
return [
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
projectId,
name: "Admin",
slug: ProjectMembershipRole.Admin,
permissions: projectAdminPermissions,
description: "Full administrative access over a project",
createdAt: new Date(),
updatedAt: new Date()
},
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
projectId,
name: "Developer",
slug: ProjectMembershipRole.Member,
permissions: projectMemberPermissions,
description: "Limited read/write role in a project",
createdAt: new Date(),
updatedAt: new Date()
},
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response
projectId,
name: "Viewer",
slug: ProjectMembershipRole.Viewer,
permissions: projectViewerPermission,
description: "Only read role in a project",
createdAt: new Date(),
updatedAt: new Date()
},
{
id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
projectId,
name: "No Access",
slug: ProjectMembershipRole.NoAccess,
permissions: projectNoAccessPermissions,
description: "No access to any resources in the project",
createdAt: new Date(),
updatedAt: new Date()
}
].filter(({ slug }) => !roleFilter || roleFilter.includes(slug));
};
export const projectRoleServiceFactory = ({ export const projectRoleServiceFactory = ({
projectRoleDAL, projectRoleDAL,
permissionService, permissionService,
@@ -1,5 +1,6 @@
import crypto from "crypto"; import crypto from "crypto";
import { ProjectVersion, TProjects } from "@app/db/schemas";
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
@@ -53,6 +54,16 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr
return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; return { key: encryptedProjectKey, iv: encryptedProjectKeyIv };
}; };
export const verifyProjectVersion = (projects: Pick<TProjects, "version">[], version: ProjectVersion) => {
for (const project of projects) {
if (project.version !== version) {
return false;
}
}
return true;
};
export const getProjectKmsCertificateKeyId = async ({ export const getProjectKmsCertificateKeyId = async ({
projectId, projectId,
projectDAL, projectDAL,
@@ -10,6 +10,7 @@ import { TKeyStoreFactory } from "@app/keystore/keystore";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
@@ -30,6 +31,8 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { getPredefinedRoles } from "../project-role/project-role-fns";
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TProjectDALFactory } from "./project-dal"; import { TProjectDALFactory } from "./project-dal";
@@ -44,6 +47,7 @@ import {
TListProjectCasDTO, TListProjectCasDTO,
TListProjectCertificateTemplatesDTO, TListProjectCertificateTemplatesDTO,
TListProjectCertsDTO, TListProjectCertsDTO,
TListProjectsDTO,
TLoadProjectKmsBackupDTO, TLoadProjectKmsBackupDTO,
TToggleProjectAutoCapitalizationDTO, TToggleProjectAutoCapitalizationDTO,
TUpdateAuditLogsRetentionDTO, TUpdateAuditLogsRetentionDTO,
@@ -84,6 +88,7 @@ type TProjectServiceFactoryDep = {
orgDAL: Pick<TOrgDALFactory, "findOne">; orgDAL: Pick<TOrgDALFactory, "findOne">;
keyStore: Pick<TKeyStoreFactory, "deleteItem">; keyStore: Pick<TKeyStoreFactory, "deleteItem">;
projectBotDAL: Pick<TProjectBotDALFactory, "create">; projectBotDAL: Pick<TProjectBotDALFactory, "create">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
kmsService: Pick< kmsService: Pick<
TKmsServiceFactory, TKmsServiceFactory,
| "updateProjectSecretManagerKmsKey" | "updateProjectSecretManagerKmsKey"
@@ -112,6 +117,7 @@ export const projectServiceFactory = ({
projectEnvDAL, projectEnvDAL,
licenseService, licenseService,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
projectRoleDAL,
identityProjectMembershipRoleDAL, identityProjectMembershipRoleDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateDAL, certificateDAL,
@@ -389,8 +395,37 @@ export const projectServiceFactory = ({
return deletedProject; return deletedProject;
}; };
const getProjects = async (actorId: string) => { const getProjects = async ({ actorId, includeRoles, actor, actorAuthMethod, actorOrgId }: TListProjectsDTO) => {
const workspaces = await projectDAL.findAllProjects(actorId); const workspaces = await projectDAL.findAllProjects(actorId);
if (includeRoles) {
const customRoles = await projectRoleDAL.find({
$in: {
projectId: workspaces.map((workspace) => workspace.id)
}
});
const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId);
const workspacesWithRoles = await Promise.all(
workspaces.map(async (workspace) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
workspace.id,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role);
return {
...workspace,
roles: [...(workspaceMappedToRoles[workspace.id] || []), ...getPredefinedRoles(workspace.id)]
};
})
);
return workspacesWithRoles;
}
return workspaces; return workspaces;
}; };
@@ -75,6 +75,10 @@ export type TDeleteProjectDTO = {
actorOrgId: string | undefined; actorOrgId: string | undefined;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TListProjectsDTO = {
includeRoles: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpgradeProjectDTO = { export type TUpgradeProjectDTO = {
userPrivateKey: string; userPrivateKey: string;
} & TProjectPermission; } & TProjectPermission;
@@ -32,7 +32,8 @@ export default function TeamInviteStep(): JSX.Element {
.map(async (email) => { .map(async (email) => {
mutateAsync({ mutateAsync({
inviteeEmails: [email], inviteeEmails: [email],
organizationId: String(localStorage.getItem("orgData.id")) organizationId: String(localStorage.getItem("orgData.id")),
organizationRoleSlug: "member"
}); });
}); });
+1 -1
View File
@@ -47,7 +47,7 @@ export const roleQueryKeys = {
["user-project-permissions", { workspaceId }] as const ["user-project-permissions", { workspaceId }] as const
}; };
const getProjectRoles = async (projectId: string) => { export const getProjectRoles = async (projectId: string) => {
const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>( const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>(
`/api/v1/workspace/${projectId}/roles` `/api/v1/workspace/${projectId}/roles`
); );
+1 -1
View File
@@ -162,7 +162,7 @@ export const useAddUsersToOrg = () => {
type Response = { type Response = {
data: { data: {
message: string; message: string;
completeInviteLinks: { completeInviteLinks?: {
email: string; email: string;
link: string; link: string;
}[]; }[];
+2 -1
View File
@@ -151,7 +151,8 @@ export type DeletOrgMembershipDTO = {
export type AddUserToOrgDTO = { export type AddUserToOrgDTO = {
inviteeEmails: string[]; inviteeEmails: string[];
projectIds?: string[]; projectIds?: string[];
organizationRoleSlug?: string; projectRoleSlug?: string;
organizationRoleSlug: string;
organizationId: string; organizationId: string;
}; };
+8 -4
View File
@@ -138,8 +138,12 @@ export const useGetUpgradeProjectStatus = ({
}); });
}; };
const fetchUserWorkspaces = async () => { const fetchUserWorkspaces = async (includeRoles?: boolean) => {
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace"); const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", {
params: {
includeRoles
}
});
return data.workspaces; return data.workspaces;
}; };
@@ -171,8 +175,8 @@ export const useGetWorkspaceById = (
}); });
}; };
export const useGetUserWorkspaces = () => export const useGetUserWorkspaces = (includeRoles?: boolean) =>
useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles));
const fetchUserWorkspaceMemberships = async (orgId: string) => { const fetchUserWorkspaceMemberships = async (orgId: string) => {
const { data } = await apiRequest.get<Record<string, Workspace[]>>( const { data } = await apiRequest.get<Record<string, Workspace[]>>(
@@ -1,3 +1,5 @@
import { TProjectRole } from "../roles/types";
export enum ProjectVersion { export enum ProjectVersion {
V1 = 1, V1 = 1,
V2 = 2, V2 = 2,
@@ -22,6 +24,8 @@ export type Workspace = {
auditLogsRetentionDays: number; auditLogsRetentionDays: number;
slug: string; slug: string;
createdAt: string; createdAt: string;
roles?: TProjectRole[];
}; };
export type WorkspaceEnv = { export type WorkspaceEnv = {
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -25,18 +26,21 @@ import {
useGetOrgRoles, useGetOrgRoles,
useGetUserWorkspaces useGetUserWorkspaces
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { OrgInviteLink } from "./OrgInviteLink"; import { OrgInviteLink } from "./OrgInviteLink";
const DEFAULT_ORG_MEMBER_ROLE_SLUG = "member"; const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member";
const EmailSchema = z.string().email().min(1).trim().toLowerCase(); const EmailSchema = z.string().email().min(1).trim().toLowerCase();
const addMemberFormSchema = z.object({ const addMemberFormSchema = z.object({
emails: z.string().min(1).trim().toLowerCase(), emails: z.string().min(1).trim().toLowerCase(),
projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]), projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]),
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_MEMBER_ROLE_SLUG) projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
}); });
type TAddMemberForm = z.infer<typeof addMemberFormSchema>; type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
@@ -71,7 +75,12 @@ export const AddOrgMemberModal = ({
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) }); } = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
const onAddMembers = async ({ emails, organizationRoleSlug, projectIds }: TAddMemberForm) => { const onAddMembers = async ({
emails,
organizationRoleSlug,
projectIds,
projectRoleSlug
}: TAddMemberForm) => {
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
try { try {
@@ -98,7 +107,8 @@ export const AddOrgMemberModal = ({
organizationId: currentOrg?.id, organizationId: currentOrg?.id,
inviteeEmails: emails.split(",").map((email) => email.trim()), inviteeEmails: emails.split(",").map((email) => email.trim()),
organizationRoleSlug, organizationRoleSlug,
projectIds projectIds,
projectRoleSlug
}); });
setCompleteInviteLinks(data?.completeInviteLinks ?? null); setCompleteInviteLinks(data?.completeInviteLinks ?? null);
@@ -128,7 +138,12 @@ export const AddOrgMemberModal = ({
}; };
const projectIds = watch("projectIds", []); const projectIds = watch("projectIds", []);
const { data: projects } = useGetUserWorkspaces(); const { data: projects } = useGetUserWorkspaces(true);
const filteredProjects = useMemo(
() => projects?.filter((project) => project.version === ProjectVersion.V3),
[projects]
);
return ( return (
<Modal <Modal
@@ -179,7 +194,7 @@ export const AddOrgMemberModal = ({
> >
<div> <div>
<Select <Select
defaultValue={DEFAULT_ORG_MEMBER_ROLE_SLUG} defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
{...field} {...field}
onValueChange={(val) => field.onChange(val)} onValueChange={(val) => field.onChange(val)}
> >
@@ -195,83 +210,117 @@ export const AddOrgMemberModal = ({
/> />
</div> </div>
<div className="-space-y-3"> <div className="flex items-center justify-between gap-2">
<Controller <div className="w-full">
control={control} <Controller
name="projectIds" control={control}
render={({ field, fieldState: { error } }) => ( name="projectIds"
<FormControl render={({ field, fieldState: { error } }) => (
label="Add user to projects" <FormControl
isError={Boolean(error?.message)} label="Add user to projects"
errorText={error?.message} isError={Boolean(error?.message)}
> errorText={error?.message}
<DropdownMenu> >
<DropdownMenuTrigger asChild> <DropdownMenu>
{projects && projects.length > 0 ? ( <DropdownMenuTrigger asChild>
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200"> {filteredProjects && filteredProjects.length > 0 ? (
{projectIds.length === 1 <div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
? projects.find((project) => project.id === projectIds[0])?.name {projectIds.length === 1
: `${projectIds.length} projects selected`} ? filteredProjects.find((project) => project.id === projectIds[0])
<FontAwesomeIcon icon={faChevronDown} className="text-xs" /> ?.name
</div> : `${projectIds.length} projects selected`}
) : ( <FontAwesomeIcon icon={faChevronDown} className="text-xs" />
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200"> </div>
No projects found ) : (
</div> <div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
)} No projects found
</DropdownMenuTrigger> </div>
<DropdownMenuContent )}
align="start" </DropdownMenuTrigger>
className="thin-scrollbar z-[100] max-h-80" <DropdownMenuContent
> align="start"
{projects && projects.length > 0 ? ( className="thin-scrollbar z-[100] max-h-80"
projects.map((project) => { >
const isSelected = projectIds.includes(String(project.id)); {filteredProjects && filteredProjects.length > 0 ? (
filteredProjects.map((project) => {
const isSelected = projectIds.includes(String(project.id));
return ( return (
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => projects.length > 0 && event.preventDefault()} onSelect={(event) =>
onClick={() => { filteredProjects.length > 0 && event.preventDefault()
if (projectIds.includes(String(project.id))) {
field.onChange(
projectIds.filter(
(projectId: string) => projectId !== String(project.id)
)
);
} else {
field.onChange([...projectIds, String(project.id)]);
} }
}} onClick={() => {
key={`project-id-${project.id}`} if (projectIds.includes(String(project.id))) {
icon={ field.onChange(
isSelected ? ( projectIds.filter(
<FontAwesomeIcon (projectId: string) => projectId !== String(project.id)
icon={faCheckCircle} )
className="pr-0.5 text-primary" );
/> } else {
) : ( field.onChange([...projectIds, String(project.id)]);
<div className="pl-[1.01rem]" /> }
) }}
} key={`project-id-${project.id}`}
iconPos="left" icon={
className="w-[28.4rem] text-sm" isSelected ? (
> <FontAwesomeIcon
{project.name} icon={faCheckCircle}
</DropdownMenuItem> className="pr-0.5 text-primary"
); />
}) ) : (
) : ( <div className="pl-[1.01rem]" />
<div /> )
)} }
</DropdownMenuContent> iconPos="left"
</DropdownMenu> className="w-[28.4rem] text-sm"
</FormControl> >
)} {project.name}
/> </DropdownMenuItem>
<p className="ml-0.5 text-xs text-mineshaft-400"> );
Members will automatically be assigned the <a className="font-bold">Developer</a>{" "} })
role for the projects you select. ) : (
</p> <div />
)}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
<div className="flex min-w-fit justify-end">
<Controller
control={control}
name="projectRoleSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Select which role to assign to the users in the selected projects."
label="Role"
isError={Boolean(error)}
errorText={error?.message}
>
<div>
<Select
isDisabled={projectIds.length === 0}
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
{...field}
onValueChange={(val) => field.onChange(val)}
>
{Object.entries(ProjectMembershipRole).map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, slug]) =>
slug !== "custom" && (
<SelectItem key={slug} value={slug}>
<span className="capitalize">{slug.replace("-", " ")}</span>
</SelectItem>
)
)}
</Select>
</div>
</FormControl>
)}
/>
</div>
</div> </div>
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
@@ -108,7 +108,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
try { try {
const { data } = await addUsersMutateAsync({ const { data } = await addUsersMutateAsync({
organizationId: orgId, organizationId: orgId,
inviteeEmails: [email] inviteeEmails: [email],
organizationRoleSlug: "member"
}); });
setCompleteInviteLinks(data?.completeInviteLinks || null); setCompleteInviteLinks(data?.completeInviteLinks || null);
@@ -50,7 +50,8 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
try { try {
const { data } = await inviteUsers({ const { data } = await inviteUsers({
organizationId: orgId, organizationId: orgId,
inviteeEmails: [email] inviteeEmails: [email],
organizationRoleSlug: "member"
}); });
// setCompleteInviteLink(data?.completeInviteLink || ""); // setCompleteInviteLink(data?.completeInviteLink || "");