fix(contact-logs): make Made_By and Contact_ID server-authoritative (F4) - #85
Merged
Merged
Conversation
Contact-log writes accepted `Made_By` and `Contact_ID` from the caller. The
action's parameter type omitted `Made_By`, but TypeScript is erased at runtime
and a server action is a POST endpoint whose payload shape the caller controls.
The update path validated with `ContactLogSchema.omit({ Contact_Log_ID,
Contact_Date }).partial()`, which keeps both as known keys and passed them
straight through to the PUT.
Any role-holder could therefore re-attribute a pastoral log to a different
staff member, or move it onto a different contact's record, with one crafted
request. MP's audit log recorded the editor, but the record itself was
falsified.
Enforcement lives in `ContactLogService` — the boundary every path goes
through, including one that bypasses the actions:
Made_By create + update <- the authorization gate's returned User_ID
Contact_ID create <- caller's subject contact, sanitizeNumericId'd
update <- never sent; MP preserves the existing value
Both fields are added to the schema `.omit({...})`, and a Zod object parse
strips keys it does not declare, so a smuggled key is dropped rather than
merely untyped. The server-stamped `Made_By` is spread last so nothing above
can override it. `requireSecurityRole` now runs first in both methods, since
its return value is the only source of attribution.
The actions assemble neither field. Attribution has exactly one source; two
layers stamping it could drift, and a caller value could slip past whichever
was checked second.
Behavior change worth knowing: `Made_By` on an edited log now reads as the
staff member who last wrote the row, not necessarily whoever originally made
the contact. This was a deliberate call; MP's audit trail additionally records
every edit via `$userId`.
Tests: 9 new cases drive the service and actions with the shapes a crafted
request can actually send — a smuggled `Made_By` on create and update, a
smuggled `Contact_ID` on update, and a non-positive `Contact_ID` on create.
Reverting the strip makes the three service guards fail, so they protect rather
than merely pass. Suite 921 passing in 54 files; lint, build and coverage
gates clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes F4 (Medium) from the auth review.
The problem
Contact-log writes accepted
Made_ByandContact_IDfrom the caller. The action's parameter type omittedMade_By, but TypeScript is erased at runtime and a server action is a POST endpoint whose payload shape the caller controls. The update path validated withContactLogSchema.omit({ Contact_Log_ID, Contact_Date }).partial(), which keeps both as known keys and passed them straight through to the PUT.Any role-holder could therefore re-attribute a pastoral log to a different staff member, or move it onto a different contact's record, with one crafted request. MP's audit log recorded the editor, but the record itself was falsified.
The fix
Enforcement lives in
ContactLogService— the boundary every path goes through, including one that bypasses the actions:Made_ByUser_IDUser_IDContact_IDsanitizeNumericIdBoth fields go into the schema
.omit({...}), and a Zod object parse strips keys it does not declare, so a smuggled key is dropped rather than merely untyped. The server-stampedMade_Byis spread last so nothing above can override it.requireSecurityRolenow runs first in both methods, since its return value is the only source of attribution.The actions assemble neither field. Attribution has exactly one source; two layers stamping it could drift, and a caller value could slip past whichever was checked second.
Behavior change worth knowing
Made_Byon an edited log now reads as the staff member who last wrote the row, not necessarily whoever originally made the contact. The previous code deliberately avoided this; stamping the editor was an explicit decision here. MP's audit trail additionally records every edit via$userId.Contact_IDremains the subject contact (the person the note is about) — it is simply no longer re-parentable.Testing
9 new cases drive the service and actions with the shapes a crafted request can actually send: a smuggled
Made_Byon create and update, a smuggledContact_IDon update, and a non-positiveContact_IDon create.These were mutation-tested — reverting the strip makes exactly the three service guards fail, so they protect rather than merely pass.
npm run lint,npm run build(exit 0), coverage gates (99.54%) all cleanMPHelperDocs
.claude/references/auth.mdgains § Attribution is server-authoritative (F4) under Authorization, plus a closed-findings row.🤖 Generated with Claude Code