Skip to content

fix(contact-logs): make Made_By and Contact_ID server-authoritative (F4) - #85

Merged
chriskehayias merged 1 commit into
mainfrom
fix/contact-log-attribution-f4
Sep 12, 2026
Merged

chriskehayias merged 1 commit into
mainfrom
fix/contact-log-attribution-f4

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

Closes F4 (Medium) from the auth review.

The problem

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.

The fix

Enforcement lives in ContactLogService — the boundary every path goes through, including one that bypasses the actions:

Field Create Update
Made_By the authorization gate's returned User_ID the authorization gate's returned User_ID
Contact_ID caller's subject contact, validated by sanitizeNumericId never sent — MP preserves the existing value

Both 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-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. 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_ID remains 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_By on create and update, a smuggled Contact_ID on update, and a non-positive Contact_ID on create.

These were mutation-tested — reverting the strip makes exactly the three service guards fail, so they protect rather than merely pass.

  • 921 tests passing / 54 files
  • npm run lint, npm run build (exit 0), coverage gates (99.54%) all clean
  • No Ministry Platform writes performed at any point; everything ran against the mocked MPHelper

Docs

.claude/references/auth.md gains § Attribution is server-authoritative (F4) under Authorization, plus a closed-findings row.

🤖 Generated with Claude Code

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>
@chriskehayias
chriskehayias merged commit 8ee9ed2 into main Sep 12, 2026
2 checks passed
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

1 participant