Skip to content

refactor(events): typed payloads for PII-bearing emit sites (F-05 subset)#511

Merged
PaulDotterer merged 3 commits into
mainfrom
feat/507-typed-pii-payloads
Jul 4, 2026
Merged

refactor(events): typed payloads for PII-bearing emit sites (F-05 subset)#511
PaulDotterer merged 3 commits into
mainfrom
feat/507-typed-pii-payloads

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Closes #507. Prerequisite for spec 19 (#502) — the pii:"true" tag mechanism reflects over typed structs, so the PII-bearing emit sites must be typed first. Sequenced ahead of #504 (spec 20), which depends on these typed structs for row-bound AAD derivation.

What

  • Every SCIM, idp, totp, and identity-link emit site now passes a shared internal/eventtypes/payloads struct as Data instead of a map[string]any literal — emit and projector decode share one wire shape, so key drift fails at compile time.
  • New payload structs: SCIMGroupMapped/Unmapped/MappingUpdated, TOTPVerified/Disabled/BackupCodeUsed, UserDeleted/Disabled/Enabled, IdentityProviderDeleted/SCIMDisabled. Empty structs marshal to {}, byte-identical to the legacy emits (pinned by TestEmptyPayloads_MarshalToEmptyObject).
  • IdentityLinkLoginUpdated gains user_id — the DEK owner spec 19's crypto-shred layer resolves. Both emitters (SSO linker, SCIM sync) populate it; pre-existing events decode with UserID == "". Pinned by a linker unit test and a real-Postgres SCIM test, both red-checked.
  • IdentityUnlinked emitters now carry user_id/provider_id instead of {} (audit value; projector keys off stream_id regardless).
  • payloads.UserGroupUpdated.Description*string, aligned with the projector's update-vs-preserve contract. The SCIM rename path relies on nil-preserve (previously it emitted only name as a map — a naive struct conversion would have wiped descriptions on every SCIM group rename); the API handler sets the pointer. The UserGroupUpdated + SCIMGroup* decoders now decode via the shared structs.
  • Ratchet archtest TestEventPayloadsAreTyped: exact-count per-file allowlist over Data: map[string]any emit literals. A new untyped site fails the build anywhere; converting a site forces the allowance down in the same commit. The remaining 14 sites across 12 files are the non-PII broader-F-05 backlog.
  • CR-found fix (second commit): the SCIM profile-sync gate skipped the emit when all computed name values were empty, making an explicit clear impossible. Now gated on the name object being asserted (omitted = preserve, explicit empty = clear), with a regression test pinning both directions.

Wire compatibility

Emitted keys are unchanged on every path: empty structs → {}; always-set pointers → always-present keys (including explicit ""). Additive-only changes: user_id fields, and SCIM UserGroupCreated now emits is_dynamic:false/dynamic_query:"" like the API path already does (decoder-neutral).

Not converted (deliberate)

IdentityProviderUpdated in idp_handler.go builds a dynamic map with conditional keys (provider config, no per-user PII — spec 19 scopes IdP secrets out). It evades the syntactic ratchet (map variable) and belongs to the broader-F-05 follow-up, as do the 14 allowlisted non-PII sites.

Verification

  • go vet, staticcheck, gofmt clean; full go test ./... -count=1: 33 packages ok, 0 failures.
  • New guard red-checked (55 violations pre-conversion → green post-conversion); both user_id emit tests red-checked by scoped edits.
  • Local coderabbit review: 2 majors found → both fixed in the second commit; no other findings.

Summary by CodeRabbit

  • New Features

    • Improved handling of SCIM user and group updates, including clearer support for profile name changes, group display names, and member changes.
  • Bug Fixes

    • Fixed several event payloads to preserve empty vs. missing values more accurately during login, TOTP, identity-provider, and user lifecycle actions.
    • Updated user profile updates so omitted names no longer overwrite existing details, while explicit empty names clear them as expected.
    • Added broader backwards-compatibility coverage for event and payload behavior.

Convert every SCIM, idp, totp, and identity-link event emit site from
map[string]any literals to the shared internal/eventtypes/payloads
structs, so the emit site and the projector decoder share one wire
shape (compile-time drift detection) and spec 19's pii:"true" tag
mechanism has typed structs to reflect over.

- New payload structs: SCIMGroupMapped/Unmapped/MappingUpdated,
  TOTPVerified/Disabled/BackupCodeUsed, UserDeleted/Disabled/Enabled,
  IdentityProviderDeleted/SCIMDisabled. Empty structs marshal to {},
  byte-identical to the legacy map emits (pinned by test).
- IdentityLinkLoginUpdated gains user_id — the DEK-owner spec 19's
  crypto-shred layer resolves; both emitters (SSO linker, SCIM sync)
  populate it. Pre-#507 events decode with UserID == "".
- IdentityUnlinked emitters now populate user_id/provider_id instead
  of {} (audit value; the projector keys off stream_id regardless).
- payloads.UserGroupUpdated.Description becomes *string omitempty,
  aligned with the projector's update-vs-preserve contract; the SCIM
  rename path relies on nil-preserve, the API handler sets the pointer.
  Decoders for UserGroupUpdated + SCIMGroup* now decode via the shared
  structs (projector-local duplicates removed).
- New archtest TestEventPayloadsAreTyped: exact-count per-file ratchet
  over Data: map[string]any emit literals — new untyped sites fail the
  build, and converting a site forces the allowance down in the same
  commit. Remaining 14 allowlisted sites are the non-PII broader-F-05
  backlog.

Wire compatibility: emitted keys are unchanged on every path (empty
structs -> {}, always-set pointers -> always-present keys); the only
additive changes are user_id fields and UserGroupCreated's is_dynamic/
dynamic_query defaults, all ignored-or-tolerated by the decoders.
Local CR finding on the typed-payload conversion: the profile-sync
gate skipped the UserProfileUpdated emit whenever every computed name
value was empty, so a SCIM PUT/sync that explicitly cleared the name
could never propagate — contradicting the pointer semantics
(present-but-empty = overwrite). Gate on the name object being
asserted instead: omitted name preserves the profile, explicit empty
name clears it. Regression test pins both directions.
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PaulDotterer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c358057b-25fe-4717-8f6b-e66cad012616

📥 Commits

Reviewing files that changed from the base of the PR and between d57ea2f and 5d350b4.

📒 Files selected for processing (2)
  • internal/scim/groups_helpers.go
  • internal/scim/users_mutate.go
📝 Walkthrough

Walkthrough

This PR converts event Data payloads across auth, idp, totp, user, user_group, identity-link, and SCIM handlers from generic map[string]any to strongly-typed structs defined in internal/eventtypes/payloads. New payload types are added, UserGroupUpdated.Description and IdentityLinkLoginUpdated gain new field semantics, projectors are updated to decode typed structs, and an archtest ratchet plus roundtrip/behavioral tests validate the new shapes.

Changes

Typed Payload Conversion

Layer / File(s) Summary
Payload type contracts and roundtrip tests
internal/eventtypes/payloads/idp.go, user.go, user_group.go, identity_link.go, scim_group.go, totp.go, payloads_test.go
Adds new empty/typed payload structs (IdentityProviderDeleted, IdentityProviderSCIMDisabled, UserDeleted/Disabled/Enabled, TOTPVerified/Disabled/BackupCodeUsed, SCIMGroupMapped/Unmapped/MappingUpdated), changes UserGroupUpdated.Description and UserLoggedIn.Provider to pointer/omitempty semantics, adds UserID to IdentityLinkLoginUpdated, and adds roundtrip/marshal tests for all new shapes.
Auth/IdP/TOTP/User API handlers
internal/api/auth_handler.go, idp_handler.go, totp_handler.go, user_handler.go, user_group_handler.go
Switches emitted event Data from empty/generic maps to typed payload structs for login, provider deletion, SCIM disable, TOTP verify/disable/backup-code, user enable/disable/delete, and user group updates.
Identity linker
internal/idp/linker.go, linker_test.go
Emits typed IdentityUnlinked/IdentityLinkLoginUpdated payloads with populated UserID; test asserts payload type and UserID value.
Projector decoding
internal/projectors/scim_group_mapping.go, user_group.go
Replaces anonymous/raw decode structs with shared typed payloads for SCIM group mapping and user group update events.
SCIM group handlers
internal/scim/groups.go, groups_create.go, groups_helpers.go, groups_mutate.go
Emits typed payloads for mapping, unmapping, group creation, member add/remove, and rename events across create/patch/replace/delete flows.
SCIM user handlers
internal/scim/users.go, users_create.go, users_helpers.go, users_mutate.go, users_link_test.go
Emits typed payloads for identity linking, user creation, email/status changes, and profile updates; changes profile-update gating from field-emptiness to name != nil; adds a ptr helper and new tests for user_id propagation and name-clearing semantics.
Archtest ratchet
internal/archtest/typed_payloads_test.go
Adds a static scan detecting map-literal Data in event-append composite literals and enforces a shrinking per-file allowlist.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • manchtools/power-manage-server#192: Introduces the broader typed-payload framework (payload roundtrip tests, store.Event.Data as any) that this PR's conversions build on.
  • manchtools/power-manage-server#127: Introduces typed projector decoders/listeners for identity-provider events aligned with this PR's typed IdentityProviderDeleted/IdentityLinkLoginUpdated payloads.
  • manchtools/power-manage-server#438: Introduces/enforces the decodePayload[T] helper used by this PR's updated scim_group_mapping.go projector.

Poem

  • A rabbit hopped through maps untyped and free,
    Now structs stand guard where chaos used to be. 🐇
  • SCIM, TOTP, and login events too,
    Each payload typed, precise, and true.
  • With tests to ratchet the old ways away,
    The warren cheers a tidier day.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main typed-payload refactor scope.
Linked Issues check ✅ Passed The PR converts the targeted SCIM, idp, totp, and identity-link emit sites, adds user_id, and introduces the archtest ratchet.
Out of Scope Changes check ✅ Passed No clearly unrelated changes stand out; the extra payload and SCIM behavior updates support the migration and regression fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/507-typed-pii-payloads

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/scim/groups_helpers.go (1)

178-188: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Check the orphan unmap append before creating the replacement mapping.

Line 178 uses h.appendEvent in a function that can return errors. If this unmap append fails, Line 192 can still create the replacement mapping, leaving the stale mapping active alongside the new one.

Suggested fix
-	h.appendEvent(ctx, store.Event{
+	if err := h.store.AppendEvent(ctx, store.Event{
 		StreamType: "scim_group_mapping",
 		StreamID:   m.ID,
 		EventType:  string(eventtypes.SCIMGroupUnmapped),
 		Data: payloads.SCIMGroupUnmapped{
 			ProviderID:  providerID,
 			SCIMGroupID: m.SCIMGroupID,
 		},
 		ActorType: "scim",
 		ActorID:   providerID,
-	})
+	}); err != nil {
+		return m, fmt.Errorf("unmap old SCIM group mapping: %w", err)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/scim/groups_helpers.go` around lines 178 - 188, The orphan-unmap
path in the SCIM group mapping flow can fail silently and still proceed to
create a replacement mapping, leaving the old mapping active. In the function
that calls h.appendEvent for scim_group_mapping, check and handle the error from
the SCIMGroupUnmapped append before continuing. Make the replacement-mapping
creation path run only after a successful unmap event so the stale mapping
cannot coexist with the new one.
internal/scim/users_mutate.go (1)

121-137: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Return an error when the profile event append fails.

This branch now handles explicit SCIM name clears, but a failed AppendEvent is only logged and the handler continues with a 200. That can silently drop a source-of-truth profile update while email/status failures correctly abort.

Proposed fix
 		if err := h.store.AppendEvent(ctx, store.Event{
 			StreamType: "user",
 			StreamID:   userID,
 			EventType:  string(eventtypes.UserProfileUpdated),
@@
 			ActorType: "scim",
 			ActorID:   provider.ID,
 		}); err != nil {
-			h.logger.Warn("failed to update user profile via SCIM", "error", err)
+			h.logger.Error("failed to update user profile via SCIM", "error", err)
+			writeError(w, http.StatusInternalServerError, "failed to update user profile")
+			return
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/scim/users_mutate.go` around lines 121 - 137, The SCIM profile
update path in users_mutate.go logs AppendEvent failures but still continues,
which can return success after a missed source-of-truth update. Update the user
profile update handler around h.store.AppendEvent so that on error it returns an
error to the caller instead of only calling h.logger.Warn, matching the failure
handling used for the other SCIM mutations and ensuring the request does not
complete with a 200 when the event write fails.
🧹 Nitpick comments (1)
internal/eventtypes/payloads/payloads_test.go (1)

774-781: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an explicit-empty-string case for UserGroupUpdated.Description.

SCIMGroupMappingUpdated has a dedicated test for the "explicit empty string stays on the wire" case, but UserGroupUpdated only tests the non-nil-value and nil-omit cases. Given the second commit in this PR fixed exactly this "explicit empty vs. omitted" class of bug for SCIM name fields, adding the analogous Description: ptr("") roundtrip test here would guard the same regression class for group descriptions.

✅ Suggested additional test
 func TestUserGroupUpdated_NilDescriptionOmitsKey(t *testing.T) {
 	raw, err := json.Marshal(payloads.UserGroupUpdated{Name: "ops"})
 	require.NoError(t, err)
 	assert.JSONEq(t, `{"name":"ops"}`, string(raw))
 }
+
+func TestUserGroupUpdated_ExplicitEmptyDescriptionStaysOnWire(t *testing.T) {
+	raw, err := json.Marshal(payloads.UserGroupUpdated{Name: "ops", Description: ptr("")})
+	require.NoError(t, err)
+	assert.JSONEq(t, `{"name":"ops","description":""}`, string(raw))
+}

Also applies to: 965-974

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/eventtypes/payloads/payloads_test.go` around lines 774 - 781, Add an
explicit empty-string roundtrip test for UserGroupUpdated.Description in the
payloads tests: alongside TestRoundtrip_UserGroupUpdated, verify that
Description: ptr("") survives json.Marshal/json.Unmarshal unchanged, similar to
the existing SCIMGroupMappingUpdated empty-string case. Keep the current non-nil
and nil-omit coverage, and use the UserGroupUpdated type plus its roundtrip test
to locate and extend the assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/scim/groups_helpers.go`:
- Around line 178-188: The orphan-unmap path in the SCIM group mapping flow can
fail silently and still proceed to create a replacement mapping, leaving the old
mapping active. In the function that calls h.appendEvent for scim_group_mapping,
check and handle the error from the SCIMGroupUnmapped append before continuing.
Make the replacement-mapping creation path run only after a successful unmap
event so the stale mapping cannot coexist with the new one.

In `@internal/scim/users_mutate.go`:
- Around line 121-137: The SCIM profile update path in users_mutate.go logs
AppendEvent failures but still continues, which can return success after a
missed source-of-truth update. Update the user profile update handler around
h.store.AppendEvent so that on error it returns an error to the caller instead
of only calling h.logger.Warn, matching the failure handling used for the other
SCIM mutations and ensuring the request does not complete with a 200 when the
event write fails.

---

Nitpick comments:
In `@internal/eventtypes/payloads/payloads_test.go`:
- Around line 774-781: Add an explicit empty-string roundtrip test for
UserGroupUpdated.Description in the payloads tests: alongside
TestRoundtrip_UserGroupUpdated, verify that Description: ptr("") survives
json.Marshal/json.Unmarshal unchanged, similar to the existing
SCIMGroupMappingUpdated empty-string case. Keep the current non-nil and nil-omit
coverage, and use the UserGroupUpdated type plus its roundtrip test to locate
and extend the assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cafaa358-3940-4f3d-8394-f91a55713e03

📥 Commits

Reviewing files that changed from the base of the PR and between 1ff2af4 and d57ea2f.

📒 Files selected for processing (26)
  • internal/api/auth_handler.go
  • internal/api/idp_handler.go
  • internal/api/totp_handler.go
  • internal/api/user_group_handler.go
  • internal/api/user_handler.go
  • internal/archtest/typed_payloads_test.go
  • internal/eventtypes/payloads/identity_link.go
  • internal/eventtypes/payloads/idp.go
  • internal/eventtypes/payloads/payloads_test.go
  • internal/eventtypes/payloads/scim_group.go
  • internal/eventtypes/payloads/totp.go
  • internal/eventtypes/payloads/user.go
  • internal/eventtypes/payloads/user_group.go
  • internal/idp/linker.go
  • internal/idp/linker_test.go
  • internal/projectors/scim_group_mapping.go
  • internal/projectors/user_group.go
  • internal/scim/groups.go
  • internal/scim/groups_create.go
  • internal/scim/groups_helpers.go
  • internal/scim/groups_mutate.go
  • internal/scim/users.go
  • internal/scim/users_create.go
  • internal/scim/users_helpers.go
  • internal/scim/users_link_test.go
  • internal/scim/users_mutate.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

F-05: typed payloads for PII-bearing emit sites (SCIM/idp/totp/identity-link)

1 participant