diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index aa32fcb5f..b3cff0322 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -122,6 +122,10 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ + project: z.object({ + name: z.string(), + slug: z.string() + }), event: z.object({ type: z.string(), metadata: z.any() @@ -138,7 +142,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const auditLogs = await server.services.auditLog.listProjectAuditLogs({ + const auditLogs = await server.services.auditLog.listAuditLogs({ actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index f0e69715d..716c23fe5 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -103,6 +103,13 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved if (!email || !profile.firstName) { + logger.info( + { + err: new Error("Invalid saml request. Missing email or first name"), + profile + }, + `email: ${email} firstName: ${profile.firstName as string}` + ); throw new BadRequestError({ message: "Invalid request. Missing email or first name" }); } diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index bcb76e6a5..3021beb0d 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -1,9 +1,9 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { AuditLogsSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, stripUndefinedInWhere } from "@app/lib/knex"; +import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; @@ -33,23 +33,44 @@ export const auditLogDALFactory = (db: TDbClient) => { .where( stripUndefinedInWhere({ projectId, - orgId, + [`${TableName.AuditLog}.orgId`]: orgId, eventType, - actor, userAgentType }) ) + + .leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`) + + .select(selectAllTableCols(TableName.AuditLog)) + + .select( + db.ref("name").withSchema(TableName.Project).as("projectName"), + db.ref("slug").withSchema(TableName.Project).as("projectSlug") + ) + .limit(limit) .offset(offset) - .orderBy("createdAt", "desc"); + .orderBy(`${TableName.AuditLog}.createdAt`, "desc"); + + if (actor) { + void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actor]); + } + if (startDate) { - void sqlQuery.where("createdAt", ">=", startDate); + void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate); } if (endDate) { - void sqlQuery.where("createdAt", "<=", endDate); + void sqlQuery.where(`${TableName.AuditLog}.createdAt`, "<=", endDate); } const docs = await sqlQuery; - return docs; + + return docs.map((doc) => ({ + ...AuditLogsSchema.parse(doc), + project: { + name: doc.projectName, + slug: doc.projectSlug + } + })); } catch (error) { throw new DatabaseError({ error }); } diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 2916eb412..11159c37b 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAuditLogDALFactory } from "./audit-log-dal"; @@ -11,7 +12,7 @@ import { EventType, TCreateAuditLogDTO, TListProjectAuditLogDTO } from "./audit- type TAuditLogServiceFactoryDep = { auditLogDAL: TAuditLogDALFactory; - permissionService: Pick; + permissionService: Pick; auditLogQueue: TAuditLogQueueServiceFactory; }; @@ -22,7 +23,7 @@ export const auditLogServiceFactory = ({ auditLogQueue, permissionService }: TAuditLogServiceFactoryDep) => { - const listProjectAuditLogs = async ({ + const listAuditLogs = async ({ userAgentType, eventType, offset, @@ -36,14 +37,33 @@ export const auditLogServiceFactory = ({ projectId, auditLogActor }: TListProjectAuditLogDTO) => { - const { permission } = await permissionService.getProjectPermission( - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId - ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + if (projectId) { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + } else { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + /** + * NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved + * to the organization level + */ + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + } + + // If project ID is not provided, then we need to return all the audit logs for the organization itself. + const auditLogs = await auditLogDAL.find({ startDate, endDate, @@ -52,8 +72,9 @@ export const auditLogServiceFactory = ({ eventType, userAgentType, actor: auditLogActor, - projectId + ...(projectId ? { projectId } : { orgId: actorOrgId }) }); + return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({ ...el, event: { type: logEventType, metadata: eventMetadata }, @@ -76,6 +97,6 @@ export const auditLogServiceFactory = ({ return { createAuditLog, - listProjectAuditLogs + listAuditLogs }; }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 10f97a163..3b3a5b107 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -6,14 +6,14 @@ import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; export type TListProjectAuditLogDTO = { auditLogActor?: string; - projectId: string; + projectId?: string; eventType?: string; startDate?: string; endDate?: string; userAgentType?: string; limit?: number; offset?: number; -} & TProjectPermission; +} & Omit; export type TCreateAuditLogDTO = { event: Event; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 003b29811..3c0a5fe52 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -452,7 +452,9 @@ export const PROJECT_USERS = { INVITE_MEMBER: { projectId: "The ID of the project to invite the member to.", emails: "A list of organization member emails to invite to the project.", - usernames: "A list of usernames to invite to the project." + usernames: "A list of usernames to invite to the project.", + roleSlugs: + "A list of role slugs to assign to the newly created project membership. If nothing is provided, it will default to the Member role." }, REMOVE_MEMBER: { projectId: "The ID of the project to remove the member from.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8f6224c51..f0675dff5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -473,6 +473,8 @@ export const registerRoutes = async ( userAliasDAL, orgMembershipDAL, tokenService, + permissionService, + groupProjectDAL, smtpService, projectMembershipDAL }); @@ -497,6 +499,7 @@ export const registerRoutes = async ( tokenService, projectUserAdditionalPrivilegeDAL, projectUserMembershipRoleDAL, + projectRoleDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -504,8 +507,7 @@ export const registerRoutes = async ( smtpService, userDAL, groupDAL, - orgBotDAL, - projectRoleDAL + orgBotDAL }); const signupService = authSignupServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 808f125bb..68a1dba45 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + AuditLogsSchema, GroupsSchema, IncidentContactsSchema, OrganizationsSchema, @@ -8,7 +9,9 @@ import { OrgRolesSchema, UsersSchema } from "@app/db/schemas"; -import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; +import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -62,6 +65,68 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/audit-logs", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get all audit logs for an organization", + querystring: z.object({ + eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }), + + response: { + 200: z.object({ + auditLogs: AuditLogsSchema.omit({ + eventMetadata: true, + eventType: true, + actor: true, + actorMetadata: true + }) + .merge( + z.object({ + project: z.object({ + name: z.string(), + slug: z.string() + }), + event: z.object({ + type: z.string(), + metadata: z.any() + }), + actor: z.object({ + type: z.string(), + metadata: z.any() + }) + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const auditLogs = await server.services.auditLog.listAuditLogs({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.query, + endDate: req.query.endDate, + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActor: req.query.actor, + actor: req.permission.type + }); + return { auditLogs }; + } + }); + server.route({ method: "GET", url: "/:organizationId/users", diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index d3c0db242..4e4583196 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -134,4 +134,39 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { ); } }); + + server.route({ + method: "GET", + url: "/me/:username/groups", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + username: z.string().trim() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + orgId: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const groupMemberships = await server.services.user.listUserGroups({ + username: req.params.username, + actorOrgId: req.permission.orgId, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return groupMemberships; + } + }); }; diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index 6ab574d64..4aa03f33c 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -26,7 +26,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }), body: z.object({ emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames) + usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames), + roleSlugs: z.string().array().optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs) }), response: { 200: z.object({ diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index fedfcfbc3..c74a6b1b2 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -95,6 +95,30 @@ export const groupProjectDALFactory = (db: TDbClient) => { } }; + const findByUserId = async (userId: string, orgId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .join(TableName.Groups, function () { + this.on(`${TableName.UserGroupMembership}.groupId`, "=", `${TableName.Groups}.id`).andOn( + `${TableName.Groups}.orgId`, + "=", + db.raw("?", [orgId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.Groups), + db.ref("name").withSchema(TableName.Groups), + db.ref("slug").withSchema(TableName.Groups), + db.ref("orgId").withSchema(TableName.Groups) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByUserId" }); + } + }; + // The GroupProjectMembership table has a reference to the project (projectId) AND the group (groupId). // We need to join the GroupProjectMembership table with the Groups table to get the group name and slug. // We also need to join the GroupProjectMembershipRole table to get the role of the group in the project. @@ -197,5 +221,5 @@ export const groupProjectDALFactory = (db: TDbClient) => { return members; }; - return { ...groupProjectOrm, findByProjectId, findAllProjectGroupMembers }; + return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers }; }; diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 4759db309..4ee192c88 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -153,7 +153,6 @@ export const orgAdminServiceFactory = ({ members: [ { orgMembershipId: membership.id, - projectMembershipRole: ProjectMembershipRole.Admin, userPublicKey: userEncryptionKey.publicKey } ] diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 9dad7086e..5b4efb992 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -114,11 +114,11 @@ export const orgServiceFactory = ({ tokenService, orgBotDAL, licenseService, + projectRoleDAL, samlConfigDAL, userGroupMembershipDAL, projectBotDAL, - projectUserMembershipRoleDAL, - projectRoleDAL + projectUserMembershipRoleDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id diff --git a/backend/src/services/project-membership/project-membership-fns.ts b/backend/src/services/project-membership/project-membership-fns.ts deleted file mode 100644 index 98acd6eec..000000000 --- a/backend/src/services/project-membership/project-membership-fns.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Knex } from "knex"; - -import { ProjectMembershipRole, SecretKeyEncoding, TProjectMemberships } from "@app/db/schemas"; -import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; -import { getConfig } from "@app/lib/config/env"; -import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; -import { BadRequestError } from "@app/lib/errors"; -import { groupBy } from "@app/lib/fn"; - -import { TOrgDALFactory } from "../org/org-dal"; -import { TProjectDALFactory } from "../project/project-dal"; -import { assignWorkspaceKeysToMembers } from "../project/project-fns"; -import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; -import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; -import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; -import { TProjectMembershipDALFactory } from "./project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; - -type TAddMembersToProjectArg = { - orgDAL: Pick; - projectMembershipDAL: Pick; - projectDAL: Pick; - projectKeyDAL: Pick; - projectBotDAL: Pick; - userGroupMembershipDAL: Pick; - projectUserMembershipRoleDAL: Pick; - smtpService: Pick; -}; - -type AddMembersToNonE2EEProjectDTO = { - emails: string[]; - usernames: string[]; - projectId: string; - projectMembershipRole: ProjectMembershipRole; - sendEmails?: boolean; -}; - -type AddMembersToNonE2EEProjectOptions = { - tx?: Knex; - throwOnProjectNotFound?: boolean; -}; - -export const addMembersToProject = ({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - projectBotDAL, - userGroupMembershipDAL, - projectUserMembershipRoleDAL, - smtpService -}: TAddMembersToProjectArg) => { - // Can create multiple memberships for a singular project, based on user email / username - const addMembersToNonE2EEProject = async ( - { emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO, - options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true } - ) => { - const processTransaction = async (tx: Knex) => { - const usernamesAndEmails = [...emails, ...usernames]; - - const project = await projectDAL.findProjectById(projectId); - 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( - project.orgId, - [...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))], - tx - ); - - if (orgMembers.length !== usernamesAndEmails.length) - throw new BadRequestError({ message: "Some users are not part of org" }); - - if (!orgMembers.length) return []; - - const existingMembers = await projectMembershipDAL.find({ - projectId, - $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } - }); - if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); - - const ghostUser = await projectDAL.findProjectGhostUser(projectId); - - if (!ghostUser) { - throw new BadRequestError({ - message: "Failed to find sudo user" - }); - } - - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); - - if (!ghostUserLatestKey) { - throw new BadRequestError({ - message: "Failed to find sudo user latest key" - }); - } - - const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) { - throw new BadRequestError({ - message: "Failed to find bot" - }); - } - - const botPrivateKey = infisicalSymmetricDecrypt({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey - }); - - const newWsMembers = assignWorkspaceKeysToMembers({ - decryptKey: ghostUserLatestKey, - userPrivateKey: botPrivateKey, - members: orgMembers.map((membership) => ({ - orgMembershipId: membership.id, - projectMembershipRole, - userPublicKey: membership.user.publicKey - })) - }); - - const members: TProjectMemberships[] = []; - - const userIdsToExcludeForProjectKeyAddition = new Set( - await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) - ); - const projectMemberships = await projectMembershipDAL.insertMany( - orgMembers.map(({ user }) => ({ - projectId, - userId: user.id - })), - tx - ); - await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })), - tx - ); - - members.push(...projectMemberships); - - const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); - await projectKeyDAL.insertMany( - orgMembers - .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) - .map(({ user, id }) => ({ - encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, - nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, - senderId: ghostUser.id, - receiverId: user.id, - projectId - })), - tx - ); - - if (sendEmails) { - const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); - - const appCfg = getConfig(); - - if (recipients.length) { - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical project invitation", - recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), - substitutions: { - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - } - } - - return members; - }; - - if (options.tx) { - return processTransaction(options.tx); - } - return projectMembershipDAL.transaction(processTransaction); - }; - - return { - addMembersToNonE2EEProject - }; -}; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 49a86f31b..e2ed4647d 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -42,7 +42,7 @@ type TProjectMembershipServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; userDAL: Pick; userGroupMembershipDAL: TUserGroupMembershipDALFactory; - projectRoleDAL: Pick; + projectRoleDAL: Pick; orgDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 21efaf691..c50ee4c1b 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -53,4 +53,5 @@ export type TAddUsersToWorkspaceNonE2EEDTO = { sendEmails?: boolean; emails: string[]; usernames: string[]; + roleSlugs?: string[]; } & TProjectPermission; diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index cb836d932..d6b01c33b 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -16,7 +16,7 @@ export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateK privateKey: userPrivateKey }); - const newWsMembers = members.map(({ orgMembershipId, userPublicKey, projectMembershipRole }) => { + const newWsMembers = members.map(({ orgMembershipId, userPublicKey }) => { const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric( plaintextProjectKey, userPublicKey, @@ -25,7 +25,6 @@ export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateK return { orgMembershipId, - projectRole: projectMembershipRole, workspaceEncryptedKey: inviteeCipherText, workspaceEncryptedNonce: inviteeNonce }; diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index 8f1e3fc3f..d59bde6c1 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -300,8 +300,7 @@ export const projectQueueFactory = ({ members: [ { userPublicKey: user.publicKey, - orgMembershipId: orgMembership.id, - projectMembershipRole: ProjectMembershipRole.Admin + orgMembershipId: orgMembership.id } ] }); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index f51c561d7..31ab297b8 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -277,8 +277,7 @@ export const projectServiceFactory = ({ members: [ { userPublicKey: user.publicKey, - orgMembershipId: orgMembership.id, - projectMembershipRole: ProjectMembershipRole.Admin + orgMembershipId: orgMembership.id } ] }); @@ -292,7 +291,7 @@ export const projectServiceFactory = ({ tx ); await projectUserMembershipRoleDAL.create( - { projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole }, + { projectMembershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, tx ); diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index fbbd32d2b..3bfdae709 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,4 +1,4 @@ -import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; +import { TProjectKeys } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -88,7 +88,6 @@ export type AddUserToWsDTO = { userPrivateKey: string; members: { orgMembershipId: string; - projectMembershipRole: ProjectMembershipRole; userPublicKey: string; }[]; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index f0b043279..8d7d1ffbe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,4 +1,8 @@ +import { ForbiddenError } from "@casl/ability"; + import { SecretKeyEncoding } from "@app/db/schemas"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -8,8 +12,10 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; +import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserDALFactory } from "./user-dal"; +import { TListUserGroupsDTO } from "./user-types"; type TUserServiceFactoryDep = { userDAL: Pick< @@ -27,10 +33,12 @@ type TUserServiceFactoryDep = { | "delete" >; userAliasDAL: Pick; + groupProjectDAL: Pick; orgMembershipDAL: Pick; tokenService: Pick; projectMembershipDAL: Pick; smtpService: Pick; + permissionService: TPermissionServiceFactory; }; export type TUserServiceFactory = ReturnType; @@ -40,8 +48,10 @@ export const userServiceFactory = ({ userAliasDAL, orgMembershipDAL, projectMembershipDAL, + groupProjectDAL, tokenService, - smtpService + smtpService, + permissionService }: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (username: string) => { const user = await userDAL.findOne({ username }); @@ -295,6 +305,27 @@ export const userServiceFactory = ({ return updatedOrgMembership.projectFavorites; }; + const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { + const user = await userDAL.findOne({ + username + }); + + // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. + if (user.id !== actorId) { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + } + + const memberships = await groupProjectDAL.findByUserId(user.id, actorOrgId); + return memberships; + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -304,6 +335,7 @@ export const userServiceFactory = ({ deleteUser, getMe, createUserAction, + listUserGroups, getUserAction, unlockUser, getUserPrivateKey, diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts index f7b46978a..9b482de98 100644 --- a/backend/src/services/user/user-types.ts +++ b/backend/src/services/user/user-types.ts @@ -1,3 +1,9 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TListUserGroupsDTO = { + username: string; +} & Omit; + export enum UserEncryption { V1 = 1, V2 = 2 diff --git a/docs/documentation/platform/workflow-integrations/slack-integration.mdx b/docs/documentation/platform/workflow-integrations/slack-integration.mdx index e08ce66c2..18172a2a5 100644 --- a/docs/documentation/platform/workflow-integrations/slack-integration.mdx +++ b/docs/documentation/platform/workflow-integrations/slack-integration.mdx @@ -6,6 +6,55 @@ description: "Learn how to setup Slack integration" This guide will provide step by step instructions on how to configure Slack integration for your Infisical projects. + + ## Create Slack workflow integration + + + In order to use Slack integration in your projects, you will first have to + configure a Slack workflow integration in your organization. + ![org-slack-overview](/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png) + + + Press "Add" and select "Slack" as the platform. + ![org-slack-initial-add](/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png) + + Give your Slack integration a descriptive alias. You will use this to select the Slack integration for your project. + ![org-slack-add-form](/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png) + + Press **Connect Slack**. This opens up the Slack app installation flow. Select the Slack workspace you want to install the custom Slack app to and press **Allow**. + ![org-slack-authenticate](/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png) + + This completes the workflow integration creation flow. The projects in your organization can now use this Slack integration to send real-time updates to your Slack workspace. + ![org-slack-workspace](/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-workspace.png) + ![org-slack-created](/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png) + + + + + + ## Configure project to use Slack workflow integration + + + + ![project-slack-overview](/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png) + + + Your project will send notifications to the connected Slack workspace of the + selected Slack integration when the configured events are triggered. + ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png) + + + ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png) + + To enable notifications in private Slack channels, you need to invite the Infisical Slack bot to join those channels. + + You now have a working native integration with Slack! + + + + + + ## Configure admin settings Note that this step only has to be done once for the entire instance. @@ -41,7 +90,7 @@ This guide will provide step by step instructions on how to configure Slack inte -## Create Slack workflow integration + ## Create Slack workflow integration @@ -67,26 +116,27 @@ This guide will provide step by step instructions on how to configure Slack inte -## Configure project to use Slack workflow integration + ## Configure project to use Slack workflow integration - - - ![project-slack-overview](/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png) - - - Your project will send notifications to the connected Slack workspace of the - selected Slack integration when the configured events are triggered. - ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png) - - - ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png) - - To enable notifications in private Slack channels, you need to invite your Slack bot to join those channels. - - You now have a working native integration with Slack! + + + ![project-slack-overview](/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png) + + + Your project will send notifications to the connected Slack workspace of the + selected Slack integration when the configured events are triggered. + ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png) + + + ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png) + + To enable notifications in private Slack channels, you need to invite your Slack bot to join those channels. + + You now have a working native integration with Slack! - - + + + diff --git a/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png b/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png new file mode 100644 index 000000000..048e91f85 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-workspace.png b/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-workspace.png new file mode 100644 index 000000000..416247775 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-workspace.png differ diff --git a/frontend/src/components/signup/EnterEmailStep.tsx b/frontend/src/components/signup/EnterEmailStep.tsx index 2b5b0d138..1b1a5c8a3 100644 --- a/frontend/src/components/signup/EnterEmailStep.tsx +++ b/frontend/src/components/signup/EnterEmailStep.tsx @@ -50,7 +50,8 @@ export default function EnterEmailStep({ // If everything is correct, go to the next step if (!emailCheckBool) { try { - await mutateAsync({ email }); + await mutateAsync({ email: email.toLowerCase() }); + setEmail(email.toLowerCase()) incrementStep(); } catch (e) { if (axios.isAxiosError(e)) { diff --git a/frontend/src/components/signup/TeamInviteStep.tsx b/frontend/src/components/signup/TeamInviteStep.tsx index 934935a42..60276d217 100644 --- a/frontend/src/components/signup/TeamInviteStep.tsx +++ b/frontend/src/components/signup/TeamInviteStep.tsx @@ -60,7 +60,7 @@ export default function TeamInviteStep(): JSX.Element { placeholder="email@example.com, email2@example.com..." /> -
+
)} @@ -272,6 +280,7 @@ export const UserPage = withPermission( } buttonText="Deactivate" /> + handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx new file mode 100644 index 000000000..dcfd0553c --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import Link from "next/link"; +import { faFilter } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { EmptyState, IconButton, Tooltip } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { OrgUser } from "@app/hooks/api/types"; +import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; + +type Props = { + orgMembership: OrgUser; +}; + +export const UserAuditLogsSection = withPermission( + ({ orgMembership }: Props) => { + const [showFilter, setShowFilter] = useState(false); + const { subscription, isLoading } = useSubscription(); + + // eslint-disable-next-line no-nested-ternary + return subscription?.auditLogs ? ( +
+
+

Audit Logs

+ + + setShowFilter(!showFilter)} + > +
+

Filter

+ +
+
+
+
+ +
+ ) : !isLoading ? ( +
+
+

Audit Logs

+
+ +

+ Please{" "} + + + upgrade your subscription + + {" "} + to view audit logs for this user +

+
+ } + /> + + ) : null; + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } +); diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx new file mode 100644 index 000000000..620fe5b7e --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx @@ -0,0 +1,45 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + group: TGroupWithProjectMemberships; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; +}; + +export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { + return ( + <> + + {group.name} + +
+ + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromGroup", { + groupSlug: group.slug + }); + }} + > + + + +
+ + + + ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx new file mode 100644 index 000000000..2967348a2 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { OrgUser } from "@app/hooks/api/users/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { UserGroupsTable } from "./UserGroupsTable"; + +type Props = { + orgMembership: OrgUser; +}; + +export const UserGroupsSection = ({ orgMembership }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeUserFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroup } = useRemoveUserFromGroup(); + + const handleRemoveUserFromGroup = useCallback(async (groupSlug: string) => { + try { + await removeUserFromGroup({ + slug: groupSlug, + username: orgMembership.user.username + }); + + createNotification({ + type: "success", + text: "User removed from group successfully" + }); + + handlePopUpClose("removeUserFromGroup"); + } catch (error) { + createNotification({ + type: "error", + text: "Failed to remove user from group" + }); + } + }, []); + + return ( + <> +
+
+

Groups

+
+ + +
+ + handlePopUpToggle("removeUserFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const popupData = popUp?.removeUserFromGroup?.data as { + groupSlug: string; + }; + + return handleRemoveUserFromGroup(popupData.groupSlug); + }} + /> + + ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx new file mode 100644 index 000000000..15299da26 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -0,0 +1,42 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; + +import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { OrgUser } from "@app/hooks/api/types"; +import { useListUserGroupMemberships } from "@app/hooks/api/users/queries"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { UserGroupsRow } from "./UserGroupsRow"; + +type Props = { + orgMembership: OrgUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; +}; + +export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { + const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username); + + return ( + + + + + + + + + {groups?.map((group) => ( + + ))} + +
Name +
+ {!isLoading && !groups?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx index d9f95f2d5..e9b8504a2 100644 --- a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx @@ -12,7 +12,7 @@ export const AuditLogsPage = withProjectPermission(

Audit Logs

- + ); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index 011e1a915..a9f10875c 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Control, Controller, UseFormReset } from "react-hook-form"; import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { Button, DatePicker, FormControl, Select, SelectItem } from "@app/components/v2"; import { useWorkspace } from "@app/context"; @@ -19,11 +20,13 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label })); type Props = { + presetActor?: string; + className?: string; control: Control; reset: UseFormReset; }; -export const LogsFilter = ({ control, reset }: Props) => { +export const LogsFilter = ({ presetActor, className, control, reset }: Props) => { const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); @@ -69,8 +72,13 @@ export const LogsFilter = ({ control, reset }: Props) => { }; return ( -
-
+
+
{ label="Event" errorText={error?.message} isError={Boolean(error)} - className="mr-4 w-40" + className="w-40" > { label="Source" errorText={error?.message} isError={Boolean(error)} - className="mr-4 w-40" + className="w-40" > - {filteredOrgUsers.map(({ id: orgUserId, user: u }) => ( - - {u?.username} - - ))} - - - )} - /> +
+
+ ( + + + + {filteredOrgUsers && filteredOrgUsers.length > 0 ? ( +
+ {/* eslint-disable-next-line no-nested-ternary */} + {selectedOrgMembershipIds.length === 1 + ? filteredOrgUsers.find( + (orgUser) => orgUser.id === selectedOrgMembershipIds[0] + )?.user.username + : selectedOrgMembershipIds.length === 0 + ? "No users selected" + : `${selectedOrgMembershipIds.length} users selected`} + +
+ ) : ( +
+ No users found +
+ )} +
+ + {filteredOrgUsers && filteredOrgUsers.length > 0 ? ( + filteredOrgUsers.map((member) => { + const isSelected = selectedOrgMembershipIds.includes(member.id); + + return ( + + filteredOrgUsers.length > 1 && event.preventDefault() + } + onClick={() => { + if (selectedOrgMembershipIds.includes(String(member.id))) { + field.onChange( + selectedOrgMembershipIds.filter( + (membershipId: string) => + membershipId !== String(member.id) + ) + ); + } else { + field.onChange([ + ...selectedOrgMembershipIds, + String(member.id) + ]); + } + }} + key={`membership-id-${member.id}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {member.user.username} + + ); + }) + ) : ( +
+ )} + + + + )} + /> +
+ +
+ ( + + + + {roles && roles.length > 0 ? ( +
+ {/* eslint-disable-next-line no-nested-ternary */} + {selectedRoleSlugs.length === 1 + ? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name + : selectedRoleSlugs.length === 0 + ? "Select at least one role" + : `${selectedRoleSlugs.length} roles selected`} + +
+ ) : ( +
+ No roles found +
+ )} +
+ + {roles && roles.length > 0 ? ( + roles.map((role) => { + const isSelected = selectedRoleSlugs.includes(role.slug); + + return ( + roles.length > 1 && event.preventDefault()} + onClick={() => { + if (selectedRoleSlugs.includes(String(role.slug))) { + field.onChange( + selectedRoleSlugs.filter( + (roleSlug: string) => roleSlug !== String(role.slug) + ) + ); + } else { + field.onChange([...selectedRoleSlugs, role.slug]); + } + }} + key={`role-slug-${role.slug}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {role.name} + + ); + }) + ) : ( +
+ )} + + + + )} + /> +
+
+