From 6cca4b9b6eca81577bfef664b90158504f09dfc0 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 30 Apr 2026 18:56:09 -0700 Subject: [PATCH 1/4] feat(web): add audit log entries for org membership changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new audit actions covering the full membership lifecycle: - org.member_added — fires from all five UserToOrg-creation paths (initial owner, auto-add on signup, magic invite-link join, email invite redemption, join-request approval). Two of those paths were previously silent. - org.member_removed — fires when an admin removes a member via the Settings UI. - org.member_left — fires when a user leaves the org themselves. Each event uses a consistent (actor=user, target=user) shape so the membership history can be reconstructed with a single query per state transition. Existing audits (user.invite_accepted, user.join_request_approved, user.owner_created) are preserved as semantic detail. --- docs/docs/configuration/audit-logs.mdx | 3 +++ packages/web/src/actions.ts | 10 ++++++++ packages/web/src/app/invite/actions.ts | 20 +++++++++++++++ .../src/features/userManagement/actions.ts | 25 ++++++++++++++++++- packages/web/src/lib/authUtils.ts | 20 +++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) 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..aecf18841 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1047,6 +1047,16 @@ 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}`, + }, + }); 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: From c232cce91545a0afd3a477576e77cebd43320e9f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 30 Apr 2026 18:56:56 -0700 Subject: [PATCH 2/4] chore: add changelog entry for #1165 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dfd5050..0c7ad55de 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` (fires from all five `UserToOrg`-creation paths, including two that were previously silent), `org.member_removed`, and `org.member_left`. [#1165](https://github.com/sourcebot-dev/sourcebot/pull/1165) + ## [4.17.0] - 2026-04-30 ### Added From 073421a94701c160f23e548e57ff715e64b9d5d8 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 30 Apr 2026 18:57:17 -0700 Subject: [PATCH 3/4] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7ad55de..8d618948c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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` (fires from all five `UserToOrg`-creation paths, including two that were previously silent), `org.member_removed`, and `org.member_left`. [#1165](https://github.com/sourcebot-dev/sourcebot/pull/1165) +- [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 From abdfd9f485355f9cc2689711f7477ca132aa27d1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 17:05:22 +0000 Subject: [PATCH 4/4] fix(web): write approval audits before email side effect Move user.join_request_approved and org.member_added audit writes to occur immediately after addUserToOrganization() and before the email send. This ensures the audit trail is complete even if render() or sendMail() throws. Wrapped the email block in try/catch so email failures are logged without propagating as errors. Co-authored-by: Brendan Kellam --- packages/web/src/actions.ts | 64 ++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index aecf18841..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: { @@ -1057,6 +1027,40 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = 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, }