fix(contact-logs): authorize writes by MP security role, sanitize numeric IDs - #75
Merged
chriskehayias merged 1 commit intoAug 21, 2026
Conversation
…eric IDs Closes four TODOs across two related defect sets in the contact-log write path. Both sets are in one commit because they are inseparable at the import level: the contact-log actions now call sanitizeNumericId, and that helper is also used by contactLogService, userService, contact-lookup-details, and authorizationService. Splitting them would leave either half non-compiling. ## Authorization: authenticate AND authorize Closes .claude/TODO/contact-log-actions-authenticate-but-not-authorize.md The contact-log actions confirmed a session existed, then acted on whatever ID they were handed. deleteContactLog was the sharpest edge: any authenticated session could delete any contact log in the domain by ID. Policy decision (the TODO required one, and it was made rather than assumed): Any authenticated user who holds a Ministry Platform security role may create, edit, and delete any contact log, including one another user created. Ownership (Made_By) is deliberately not a factor — contact logs are shared pastoral records and staff need to correct and remove each other's entries. MP security roles are the domain's own authorization mechanism, so the app defers to them rather than inventing a parallel permission model that could drift. New src/services/authorizationService.ts owns the gate. It fails closed: a session with no resolvable MP User_ID, or an MP user holding no security role, gets UnauthorizedError plus a greppable mp.write.unauthorized log line carrying table, operation, userId and a reason of no_mp_user / no_security_role / role_not_permitted. Roles are re-read per write with no caching, so a revoked role takes effect immediately — one extra MP read on a rare operation is worth more than a stale authorization decision against a shared production database. MP_WRITE_SECURITY_ROLES narrows the gate to named roles without a code change; unset means any security role. Reads stay authentication-only, documented as a choice: every authenticated user is MP staff who can already see this data in MP. ## Refactor: stop re-implementing User_ID resolution Closes .claude/TODO/contact-log-actions-bypass-session-context-service.md createContactLog and updateContactLog each re-implemented the dp_Users User_ID lookup inline, byte-for-byte — work resolveMpUserId already does and caches, and which SessionContextService.getActingUserIdForWrite() exists to serve. Both inline blocks are gone, along with the getUserGuid helper and the MPHelper / sanitizeGuid imports. The acting User_ID now comes from the session-baked value customSession resolved, so a write costs no dp_Users round-trip. mp.write.non_user is still emitted for an unresolved acting user, so the attempt stays visible in logs. Whether the write then proceeds is now the authorization gate's decision rather than an accident of a failed lookup. Behavior change: updateContactLog no longer stamps Made_By with the editor. That column records who made the *contact*, not who last touched the row. Since any role-holder may edit anyone's log, stamping the editor rewrote the pastoral record's authorship — a supervisor fixing a typo became the person who made the phone call. MP's audit trail still captures the editor via $userId. ## Tests: the component driving MP writes Closes .claude/TODO/contact-logs-component-untested.md contact-logs.tsx was 602 lines at 0% coverage on the app's only interactive MP write path. Now 87.6% statements via 13 targeted tests covering the delete confirmation gate, client-side validation, and error surfacing. Verified by mutation, not by coverage percentage: making handleDeleteClick call deleteContactLog(logId) directly — the exact "delete fires before confirmation resolves" regression the TODO named — fails all four gate tests. The suite that preceded them failed none of it. ## Filter injection: numeric IDs Closes .claude/TODO/mp-filter-injection-numeric-ids.md This half is the work of concurrent session mpnext-d2, carried here rather than in a PR of its own because of the import-level coupling described above. New sanitizeNumericId in filter-sanitize.ts validates numeric primary-key IDs before interpolation. A TypeScript `number` annotation is erased at runtime and server actions compile to POST endpoints whose payload shape the caller controls, so a string reaches a `number` parameter. The old `!id || id <= 0` guard was a no-op against that: for '1 OR 1=1', `!id` is false and `id <= 0` is false. Applied at every numeric filter boundary — contact-logs actions, contact-lookup-details actions (a second entry point into getContactLogsByContactId that the TODO had missed), contactLogService, userService, and authorizationService. Behavior change: searchContactLogs(0) now throws instead of silently performing an unfiltered top-50 read — the old `if (contactId)` treated 0 as "no filter". Nothing calls searchContactLogs outside tests. ## Verification 575 tests passing, 32 files (was 474 / 32) npx tsc --noEmit clean npx eslint . clean npm run build clean coverage thresholds pass; contact-logs/actions.ts at 100% statements Docs: authorization policy and MP_WRITE_SECURITY_ROLES in references/auth.md; mandatory-sanitization section in references/ministryplatform.query-syntax.md; Radix-under-jsdom test patterns and inventory in references/testing.md; docs/TestCoverage.md sections 5.1, 5.4, 5.5 and 6 marked fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chriskehayias
deleted the
fix/contact-log-authorization-and-write-path-tests
branch
August 21, 2026 12:12
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 four TODOs across two related defect sets in the contact-log write path.
Why one PR: the two sets are inseparable at the import level. The contact-log actions now call
sanitizeNumericId, and that helper is also used bycontactLogService,userService,contact-lookup-details, andauthorizationService. Splitting them would leave either half non-compiling. The filter-injection half is the work of a concurrent session (mpnext-d2), carried here rather than in a PR of its own for that reason.1. Authorization — authenticate and authorize
Closes
.claude/TODO/contact-log-actions-authenticate-but-not-authorize.mdThe actions confirmed a session existed, then acted on whatever ID they were handed.
deleteContactLogwas the sharpest edge: any authenticated session could delete any contact log in the domain by ID.The TODO required a policy decision. It was made:
Ownership (
Made_By) is deliberately not a factor: contact logs are shared pastoral records and staff need to correct and remove each other's entries. MP security roles are the domain's own authorization mechanism, so the app defers to them rather than inventing a parallel permission model that could drift out of sync.New
src/services/authorizationService.tsowns the gate and fails closed — a session with no resolvable MPUser_ID, or an MP user holding no security role, getsUnauthorizedErrorplus a greppablemp.write.unauthorizedlog line (table,operation,userId,reasonofno_mp_user/no_security_role/role_not_permitted).No caching, on purpose: one extra MP read per write, so a revoked role takes effect immediately. A stale authorization decision is not a trade worth making against a shared production database.
MP_WRITE_SECURITY_ROLES="Administrators,Pastoral Staff"narrows the gate to named roles without a code change; unset means any security role. Reads stay authentication-only, documented as a choice — every authenticated user is MP staff who can already see this data in MP itself.The policy is encoded in tests that would fail under a different one (
should NOT delete when the caller holds no security role,should permit editing a log made by a different user), so a future reader knows the absence of an ownership check was chosen rather than overlooked.2. Stop re-implementing
User_IDresolutionCloses
.claude/TODO/contact-log-actions-bypass-session-context-service.mdcreateContactLogandupdateContactLogeach re-implemented thedp_Userslookup inline, byte-for-byte — workresolveMpUserIdalready does and caches, and whichSessionContextService.getActingUserIdForWrite()exists to serve. Both blocks are gone, along with thegetUserGuidhelper and theMPHelper/sanitizeGuidimports. A write now costs zerodp_Usersround-trips.mp.write.non_useris still emitted for an unresolved acting user, so the attempt stays visible in logs. Whether the write then proceeds is now the authorization gate's decision rather than an accident of a failed lookup.updateContactLogno longer stampsMade_Bywith the editor. That column records who made the contact, not who last touched the row. Since any role-holder may edit anyone's log, stamping the editor rewrote the pastoral record's authorship — a supervisor fixing a typo became the person who made the phone call. MP's audit trail still captures the editor via$userId.3. Tests for the component driving MP writes
Closes
.claude/TODO/contact-logs-component-untested.mdcontact-logs.tsxwas 602 lines at 0% coverage on the app's only interactive MP write path. Now 87.6% statements via 13 targeted tests: the delete-confirmation gate, client-side validation, and error surfacing.Verified by mutation, not by coverage percentage — making
handleDeleteClickcalldeleteContactLog(logId)directly, the exact "delete fires before the confirmation resolves" regression the TODO named, fails all four gate tests. The suite that preceded them failed none of it.4. Numeric-ID filter injection
Closes
.claude/TODO/mp-filter-injection-numeric-ids.md— credit tompnext-d2.New
sanitizeNumericIdvalidates numeric primary-key IDs before interpolation. A TypeScriptnumberannotation is erased at runtime and server actions compile to POST endpoints whose payload shape the caller controls, so a string reaches anumberparameter. The old!id || id <= 0guard was a no-op against that: for'1 OR 1=1',!idis false andid <= 0is false.Applied at every numeric filter boundary: contact-logs actions, contact-lookup-details actions (a second entry point into
getContactLogsByContactIdthe TODO had missed),contactLogService,userService,authorizationService.searchContactLogs(0)now throws instead of silently performing an unfiltered top-50 read — the oldif (contactId)treated0as "no filter". Nothing callssearchContactLogsoutside tests.Verification
npx vitest runnpx tsc --noEmitnpx eslint .npm run buildcontact-logs/actions.ts100% statementsDocs
references/auth.md— authorization section, policy rationale,MP_WRITE_SECURITY_ROLESreferences/ministryplatform.query-syntax.md— mandatory-sanitization sectionreferences/testing.md— Radix-under-jsdom patterns, inventory, "verify a gate test actually gates"docs/TestCoverage.md— §5.1, §5.4, §5.5, §6 marked fixed🤖 Generated with Claude Code