Found by the adversarial read on #1730 (sprint C3). Third instance of #1707's shape; deliberately left out of that PR because the sprint scopes C3 to the GoogleEmail reads in the GoogleIntegration service files and the rest of the section belongs to another lane.
Problem
GoogleAdminService.LinkAccountAsync (src/Sections/Humans.GoogleIntegration/Services/GoogleAdminService.cs:508, reached from GoogleController.cs:579, POST /Google/Accounts/Link) still carries the pre-#1707 guard:
var user = await userService.GetUserInfoAsync(userId, ct);
if (user is null)
return new WorkspaceAccountActionResult(false, ErrorMessage: "Human not found.");
Two ways an archived id gets through:
- Merge tombstone —
CachingUserService.GetUserInfoAsync resolves merge chains forward, so a merged-away id returns the survivor's record. Non-null, so the guard passes, and the writes below then run against the tombstone id: AddVerifiedEmailAsync(userId, email), TrySetGoogleEmailStatusFromSyncAsync(userId, …), EnqueueGoogleResyncForUserTeamsAsync(userId).
- GDPR-anonymized / legacy tombstone — does not redirect, returns itself, non-null, accepted outright.
#1730 narrowed UserInfo.IsActive to exclude tombstones, so the second case is now catchable by IsActive alone — but this call site does not check it.
Fix
Same guard the two already-fixed siblings use (GateController.cs:129-133, EmailProvisioningService.ProvisionNobodiesEmailAsync):
if (user is not { IsActive: true } || user.Id != userId)
return new WorkspaceAccountActionResult(false, ErrorMessage: "Human not found.");
user.Id != userId covers the merge-forward case; IsActive covers the tombstone-returns-itself case. Error message stays non-disclosing.
While in the file, check the other public methods on GoogleAdminService that take a userId from outside for the same shape — this issue is scoped to whatever that audit turns up in that one service.
Acceptance
POST /Google/Accounts/Link with a merged-away id links nothing and writes nothing.
- Same for a GDPR-anonymized id.
- Tests under
tests/Humans.GoogleIntegration.Tests pin both, asserting the downstream writes were never called.
- Any other unguarded
userId entry point on GoogleAdminService is either fixed or explicitly noted as safe.
Found by the adversarial read on #1730 (sprint C3). Third instance of #1707's shape; deliberately left out of that PR because the sprint scopes C3 to the
GoogleEmailreads in the GoogleIntegration service files and the rest of the section belongs to another lane.Problem
GoogleAdminService.LinkAccountAsync(src/Sections/Humans.GoogleIntegration/Services/GoogleAdminService.cs:508, reached fromGoogleController.cs:579,POST /Google/Accounts/Link) still carries the pre-#1707 guard:Two ways an archived id gets through:
CachingUserService.GetUserInfoAsyncresolves merge chains forward, so a merged-away id returns the survivor's record. Non-null, so the guard passes, and the writes below then run against the tombstone id:AddVerifiedEmailAsync(userId, email),TrySetGoogleEmailStatusFromSyncAsync(userId, …),EnqueueGoogleResyncForUserTeamsAsync(userId).#1730 narrowed
UserInfo.IsActiveto exclude tombstones, so the second case is now catchable byIsActivealone — but this call site does not check it.Fix
Same guard the two already-fixed siblings use (
GateController.cs:129-133,EmailProvisioningService.ProvisionNobodiesEmailAsync):user.Id != userIdcovers the merge-forward case;IsActivecovers the tombstone-returns-itself case. Error message stays non-disclosing.While in the file, check the other public methods on
GoogleAdminServicethat take auserIdfrom outside for the same shape — this issue is scoped to whatever that audit turns up in that one service.Acceptance
POST /Google/Accounts/Linkwith a merged-away id links nothing and writes nothing.tests/Humans.GoogleIntegration.Testspin both, asserting the downstream writes were never called.userIdentry point onGoogleAdminServiceis either fixed or explicitly noted as safe.