Skip to content

Commit 6cca4b9

Browse files
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.
1 parent ff41d83 commit 6cca4b9

5 files changed

Lines changed: 77 additions & 1 deletion

File tree

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,16 @@ export const approveAccountRequest = async (requestId: string) => sew(async () =
10471047
type: "account_join_request"
10481048
}
10491049
});
1050+
1051+
await auditService.createAudit({
1052+
action: "org.member_added",
1053+
actor: { id: user.id, type: "user" },
1054+
target: { id: request.requestedById, type: "user" },
1055+
orgId: org.id,
1056+
metadata: {
1057+
message: `${user.id} approved join request ${requestId} for ${request.requestedById}`,
1058+
},
1059+
});
10501060
return {
10511061
success: true,
10521062
}

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)