Skip to content

Commit 84f6454

Browse files
feat(web): add audit log entries for org membership changes (#1165)
* feat(web): add audit log entries for org membership changes 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. * chore: add changelog entry for #1165 * changelog * 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 <brendan-kellam@users.noreply.github.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Brendan Kellam <brendan-kellam@users.noreply.github.com>
1 parent ff41d83 commit 84f6454

6 files changed

Lines changed: 114 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- [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)
12+
1013
## [4.17.0] - 2026-04-30
1114

1215
### Added

docs/docs/configuration/audit-logs.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
145145
| `user.signed_out` | `user` | `user` |
146146
| `org.member_promoted_to_owner` | `user` | `user` |
147147
| `org.owner_demoted_to_member` | `user` | `user` |
148+
| `org.member_added` | `user` | `user` |
149+
| `org.member_removed` | `user` | `user` |
150+
| `org.member_left` | `user` | `user` |
148151

149152

150153
## Response schema

packages/web/src/actions.ts

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,36 +1005,6 @@ export const approveAccountRequest = async (requestId: string) => sew(async () =
10051005
return addUserToOrgRes;
10061006
}
10071007

1008-
// Send approval email to the user
1009-
const smtpConnectionUrl = getSMTPConnectionURL();
1010-
if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
1011-
const html = await render(JoinRequestApprovedEmail({
1012-
baseUrl: env.AUTH_URL,
1013-
user: {
1014-
name: request.requestedBy.name ?? undefined,
1015-
email: request.requestedBy.email!,
1016-
avatarUrl: request.requestedBy.image ?? undefined,
1017-
},
1018-
orgName: org.name,
1019-
}));
1020-
1021-
const transport = createTransport(smtpConnectionUrl);
1022-
const result = await transport.sendMail({
1023-
to: request.requestedBy.email!,
1024-
from: env.EMAIL_FROM_ADDRESS,
1025-
subject: `Your request to join ${org.name} has been approved`,
1026-
html,
1027-
text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`,
1028-
});
1029-
1030-
const failed = result.rejected.concat(result.pending).filter(Boolean);
1031-
if (failed.length > 0) {
1032-
logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`);
1033-
}
1034-
} else {
1035-
logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`);
1036-
}
1037-
10381008
await auditService.createAudit({
10391009
action: "user.join_request_approved",
10401010
actor: {
@@ -1047,6 +1017,50 @@ export const approveAccountRequest = async (requestId: string) => sew(async () =
10471017
type: "account_join_request"
10481018
}
10491019
});
1020+
1021+
await auditService.createAudit({
1022+
action: "org.member_added",
1023+
actor: { id: user.id, type: "user" },
1024+
target: { id: request.requestedById, type: "user" },
1025+
orgId: org.id,
1026+
metadata: {
1027+
message: `${user.id} approved join request ${requestId} for ${request.requestedById}`,
1028+
},
1029+
});
1030+
1031+
// Send approval email to the user
1032+
const smtpConnectionUrl = getSMTPConnectionURL();
1033+
if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
1034+
try {
1035+
const html = await render(JoinRequestApprovedEmail({
1036+
baseUrl: env.AUTH_URL,
1037+
user: {
1038+
name: request.requestedBy.name ?? undefined,
1039+
email: request.requestedBy.email!,
1040+
avatarUrl: request.requestedBy.image ?? undefined,
1041+
},
1042+
orgName: org.name,
1043+
}));
1044+
1045+
const transport = createTransport(smtpConnectionUrl);
1046+
const result = await transport.sendMail({
1047+
to: request.requestedBy.email!,
1048+
from: env.EMAIL_FROM_ADDRESS,
1049+
subject: `Your request to join ${org.name} has been approved`,
1050+
html,
1051+
text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`,
1052+
});
1053+
1054+
const failed = result.rejected.concat(result.pending).filter(Boolean);
1055+
if (failed.length > 0) {
1056+
logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`);
1057+
}
1058+
} catch (e) {
1059+
logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${e}`);
1060+
}
1061+
} else {
1062+
logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`);
1063+
}
10501064
return {
10511065
success: true,
10521066
}

packages/web/src/app/invite/actions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () =>
5656
return addUserToOrgRes;
5757
}
5858

59+
await auditService.createAudit({
60+
action: "org.member_added",
61+
actor: { id: user.id, type: "user" },
62+
target: { id: user.id, type: "user" },
63+
orgId: org.id,
64+
metadata: {
65+
message: `${user.id} joined the organization via invite link`,
66+
},
67+
});
68+
5969
return {
6070
success: true,
6171
}
@@ -135,6 +145,16 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
135145
}
136146
});
137147

148+
await auditService.createAudit({
149+
action: "org.member_added",
150+
actor: { id: user.id, type: "user" },
151+
target: { id: user.id, type: "user" },
152+
orgId: invite.org.id,
153+
metadata: {
154+
message: `${user.id} joined the organization by accepting invite ${inviteId}`,
155+
},
156+
});
157+
138158
return {
139159
success: true,
140160
};

packages/web/src/features/userManagement/actions.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import { ErrorCode } from "@/lib/errorCodes";
55
import { notFound, ServiceError } from "@/lib/serviceError";
66
import { withAuth } from "@/middleware/withAuth";
77
import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
8+
import { getAuditService } from "@/ee/features/audit/factory";
89
import { OrgRole, Prisma } from "@sourcebot/db";
910
import { StatusCodes } from "http-status-codes";
1011

12+
const auditService = getAuditService();
13+
1114
export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
12-
withAuth(async ({ org, role, prisma }) =>
15+
withAuth(async ({ user, org, role, prisma }) =>
1316
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
1417
const guardError = await prisma.$transaction(async (tx) => {
1518
const targetMember = await tx.userToOrg.findUnique({
@@ -58,6 +61,16 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success:
5861
return guardError;
5962
}
6063

64+
await auditService.createAudit({
65+
action: "org.member_removed",
66+
actor: { id: user.id, type: "user" },
67+
target: { id: memberId, type: "user" },
68+
orgId: org.id,
69+
metadata: {
70+
message: `${user.id} removed ${memberId} from the organization`,
71+
},
72+
});
73+
6174
return { success: true };
6275
}))
6376
);
@@ -98,6 +111,16 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
98111
return guardError;
99112
}
100113

114+
await auditService.createAudit({
115+
action: "org.member_left",
116+
actor: { id: user.id, type: "user" },
117+
target: { id: user.id, type: "user" },
118+
orgId: org.id,
119+
metadata: {
120+
message: `${user.id} left the organization`,
121+
},
122+
});
123+
101124
return {
102125
success: true,
103126
}

packages/web/src/lib/authUtils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
104104
type: "org"
105105
}
106106
});
107+
108+
await auditService.createAudit({
109+
action: "org.member_added",
110+
actor: { id: user.id, type: "user" },
111+
target: { id: user.id, type: "user" },
112+
orgId: SINGLE_TENANT_ORG_ID,
113+
metadata: {
114+
message: `${user.id} joined the organization as the initial owner`,
115+
},
116+
});
107117
} else if (!defaultOrg.memberApprovalRequired) {
108118
const hasAvailability = await orgHasAvailability();
109119
if (!hasAvailability) {
@@ -118,6 +128,16 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
118128
role: OrgRole.MEMBER,
119129
}
120130
});
131+
132+
await auditService.createAudit({
133+
action: "org.member_added",
134+
actor: { id: user.id, type: "user" },
135+
target: { id: user.id, type: "user" },
136+
orgId: SINGLE_TENANT_ORG_ID,
137+
metadata: {
138+
message: `${user.id} joined the organization (member approval not required)`,
139+
},
140+
});
121141
}
122142

123143
// Dynamic import to avoid circular dependency:

0 commit comments

Comments
 (0)