Skip to content

Commit 2d30110

Browse files
Merge branch 'main' into brendan/jwt-versioning
2 parents 5b9b3ed + 84f6454 commit 2d30110

6 files changed

Lines changed: 112 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- 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)
1112
- Added per-user JWT session versioning so admin-driven member removals (and voluntary leaves) invalidate the removed user's active JWT cookies, personal API keys, and OAuth tokens atomically on their next request. [#1168](https://github.com/sourcebot-dev/sourcebot/pull/1168)
1213

1314
## [4.17.0] - 2026-04-30

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({
@@ -62,6 +65,16 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success:
6265
return guardError;
6366
}
6467

68+
await auditService.createAudit({
69+
action: "org.member_removed",
70+
actor: { id: user.id, type: "user" },
71+
target: { id: memberId, type: "user" },
72+
orgId: org.id,
73+
metadata: {
74+
message: `${user.id} removed ${memberId} from the organization`,
75+
},
76+
});
77+
6578
return { success: true };
6679
}))
6780
);
@@ -106,6 +119,16 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
106119
return guardError;
107120
}
108121

122+
await auditService.createAudit({
123+
action: "org.member_left",
124+
actor: { id: user.id, type: "user" },
125+
target: { id: user.id, type: "user" },
126+
orgId: org.id,
127+
metadata: {
128+
message: `${user.id} left the organization`,
129+
},
130+
});
131+
109132
return {
110133
success: true,
111134
}

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)