diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dfd5050..8d618948c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [EE] Added three new audit actions covering the full org membership lifecycle: `org.member_added`, `org.member_removed`, and `org.member_left`. [#1165](https://github.com/sourcebot-dev/sourcebot/pull/1165) + ## [4.17.0] - 2026-04-30 ### Added diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index bd3308ee7..aed74bcac 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -145,6 +145,9 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ | `user.signed_out` | `user` | `user` | | `org.member_promoted_to_owner` | `user` | `user` | | `org.owner_demoted_to_member` | `user` | `user` | +| `org.member_added` | `user` | `user` | +| `org.member_removed` | `user` | `user` | +| `org.member_left` | `user` | `user` | ## Response schema diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 939ba442c..0d069ea69 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1005,36 +1005,6 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = return addUserToOrgRes; } - // Send approval email to the user - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - const html = await render(JoinRequestApprovedEmail({ - baseUrl: env.AUTH_URL, - user: { - name: request.requestedBy.name ?? undefined, - email: request.requestedBy.email!, - avatarUrl: request.requestedBy.image ?? undefined, - }, - orgName: org.name, - })); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: request.requestedBy.email!, - from: env.EMAIL_FROM_ADDRESS, - subject: `Your request to join ${org.name} has been approved`, - html, - text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); - } - await auditService.createAudit({ action: "user.join_request_approved", actor: { @@ -1047,6 +1017,50 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = type: "account_join_request" } }); + + await auditService.createAudit({ + action: "org.member_added", + actor: { id: user.id, type: "user" }, + target: { id: request.requestedById, type: "user" }, + orgId: org.id, + metadata: { + message: `${user.id} approved join request ${requestId} for ${request.requestedById}`, + }, + }); + + // Send approval email to the user + const smtpConnectionUrl = getSMTPConnectionURL(); + if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { + try { + const html = await render(JoinRequestApprovedEmail({ + baseUrl: env.AUTH_URL, + user: { + name: request.requestedBy.name ?? undefined, + email: request.requestedBy.email!, + avatarUrl: request.requestedBy.image ?? undefined, + }, + orgName: org.name, + })); + + const transport = createTransport(smtpConnectionUrl); + const result = await transport.sendMail({ + to: request.requestedBy.email!, + from: env.EMAIL_FROM_ADDRESS, + subject: `Your request to join ${org.name} has been approved`, + html, + text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); + } + } catch (e) { + logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${e}`); + } + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); + } return { success: true, } diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index 466705044..b319b5170 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -56,6 +56,16 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () => return addUserToOrgRes; } + await auditService.createAudit({ + action: "org.member_added", + actor: { id: user.id, type: "user" }, + target: { id: user.id, type: "user" }, + orgId: org.id, + metadata: { + message: `${user.id} joined the organization via invite link`, + }, + }); + return { success: true, } @@ -135,6 +145,16 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } }); + await auditService.createAudit({ + action: "org.member_added", + actor: { id: user.id, type: "user" }, + target: { id: user.id, type: "user" }, + orgId: invite.org.id, + metadata: { + message: `${user.id} joined the organization by accepting invite ${inviteId}`, + }, + }); + return { success: true, }; diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts index 1b82b7cd0..b333430a2 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -5,11 +5,14 @@ import { ErrorCode } from "@/lib/errorCodes"; import { notFound, ServiceError } from "@/lib/serviceError"; import { withAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { getAuditService } from "@/ee/features/audit/factory"; import { OrgRole, Prisma } from "@sourcebot/db"; import { StatusCodes } from "http-status-codes"; +const auditService = getAuditService(); + export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, role, prisma }) => + withAuth(async ({ user, org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { const guardError = await prisma.$transaction(async (tx) => { const targetMember = await tx.userToOrg.findUnique({ @@ -58,6 +61,16 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success: return guardError; } + await auditService.createAudit({ + action: "org.member_removed", + actor: { id: user.id, type: "user" }, + target: { id: memberId, type: "user" }, + orgId: org.id, + metadata: { + message: `${user.id} removed ${memberId} from the organization`, + }, + }); + return { success: true }; })) ); @@ -98,6 +111,16 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> = return guardError; } + await auditService.createAudit({ + action: "org.member_left", + actor: { id: user.id, type: "user" }, + target: { id: user.id, type: "user" }, + orgId: org.id, + metadata: { + message: `${user.id} left the organization`, + }, + }); + return { success: true, } diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index d4bc3eefb..d3c0c9341 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -104,6 +104,16 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { type: "org" } }); + + await auditService.createAudit({ + action: "org.member_added", + actor: { id: user.id, type: "user" }, + target: { id: user.id, type: "user" }, + orgId: SINGLE_TENANT_ORG_ID, + metadata: { + message: `${user.id} joined the organization as the initial owner`, + }, + }); } else if (!defaultOrg.memberApprovalRequired) { const hasAvailability = await orgHasAvailability(); if (!hasAvailability) { @@ -118,6 +128,16 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { role: OrgRole.MEMBER, } }); + + await auditService.createAudit({ + action: "org.member_added", + actor: { id: user.id, type: "user" }, + target: { id: user.id, type: "user" }, + orgId: SINGLE_TENANT_ORG_ID, + metadata: { + message: `${user.id} joined the organization (member approval not required)`, + }, + }); } // Dynamic import to avoid circular dependency: