142 KiB
Portal API Security & Access Boundary Assessment
Executive summary
This first pass reviews pages/api/** with a priority focus on pages/api/file/**, pages/api/email/**, pages/api/auth/**, document retrieval, and account / my-portal CRM endpoints.
Primary conclusion:
- The API surface is large (
121JS/TS files underpages/api). - Authentication is not consistently enforced at route level across sensitive handlers.
- The shared
pages/api/middleware/middleware.jsmiddleware is multipart parsing only and does not perform session or ownership checks. - The only explicit session gate found in the reviewed API surface is
pages/api/endpoint/gethash_api.js, which usesgetSession({ req })before issuing hashes for a narrow allowlist of sensitive downstream routes. - Many sensitive file/blob and CRM write/read routes appear to rely on:
- a signed path hash,
- caller-supplied identifiers such as
contactId,loggedInUserId,incidentId,watchedCaseID,container,casefolderID,blobname, - and indirect client trust, rather than proving in-handler that the authenticated portal user owns the referenced object.
This creates a notable architectural distinction:
- Session boundary exists in NextAuth and in
gethash_api - Access boundary enforcement is fragmented and often not visible in the endpoint itself
- Ownership enforcement is not consistently self-evident in user-owned CRM and blob/file routes
Scope reviewed
Reviewed context documents:
context/architecture.mdcontext/integration-map.mdGUARDRAILS.md(repository root; nocontext/GUARDRAILS.mdfile exists in this workspace)context/runbook.mdmemory-bank/debt-list.mdmemory-bank/open-questions.mdmemory-bank/change-log.md
Reviewed API surface:
pages/api/**- route inventory via directory listing and command-line count
- targeted code review of high-risk handlers in:
pages/api/auth/**pages/api/file/**pages/api/email/**pages/api/documents/**pages/api/endpoint/*_api.jspages/api/middleware/**
High-signal files reviewed directly:
pages/api/auth/[...nextauth].jspages/api/endpoint/gethash_api.jspages/api/middleware/middleware.jspages/api/file/downloadblob.jspages/api/file/getbloblist.jspages/api/file/upload.jspages/api/file/uploadsinglefile.jspages/api/file/generateappealpdf.jspages/api/file/createcaseinvolvement_api.jspages/api/file/createrepinvolvement_api.jspages/api/email/notify.jspages/api/email/getall.jspages/api/documents/download/[id].jspages/api/endpoint/getmycases_api.jspages/api/endpoint/getwatchedcases_api.jspages/api/endpoint/getpersonalaccount_api.jspages/api/endpoint/getportallogin_api.jspages/api/endpoint/createcase_api.jspages/api/endpoint/updateaccount_api.jspages/api/endpoint/deletewatchedcases_api.jspages/api/endpoint/deletemyrepresentations_api.js
Endpoint inventory
Surface size by top-level API area
| Area | Approx. file count | Primary role |
|---|---|---|
endpoint/ |
74 | CRM relay/read-write endpoints |
file/ |
26 | blob/file/document/PDF/upload flows |
email/ |
6 | notification and mailing workflows |
admin/ |
5 | administrative/internal reporting |
middleware/ |
4 | relay and multipart helpers |
auth/ |
2 | auth/session and locale resolution |
documents/ |
1 | document download proxy |
other (health, notices, doc.ts) |
3 | utility/meta |
Endpoint family classification table
| Endpoint family | Examples | Classification | Auth visible in route? | Ownership visible in route? | Risk |
|---|---|---|---|---|---|
| Auth/session | auth/[...nextauth].js, auth/resolve-locale.js |
auth/session | Yes in NextAuth route; no obvious session gate in locale resolver | N/A | High |
| Hash minting | endpoint/gethash_api.js |
auth/session, relay support | Yes (getSession) |
No object ownership check; only path allowlist | High |
| Public/read search & case discovery | endpoint/getbasicsearch*_api.js, getadvancedsearch*_api.js, getdns*, getlinkedcases_api.js, getappealtypes_api.js |
public read | Usually no | Usually N/A / relies on publish filters | Low-Medium |
| Public/document metadata | getsearchdocumentdetails*_api.js, getsearchdocumenthistory*_api.js, getsearchdocumentTypes_api.js, getappealpdfdocuments_api.js |
public read / document access | Usually no | Limited visibility; mostly incident/document keyed | Medium-High |
| Direct document retrieval | documents/download/[id].js |
document access | No visible session gate | No visible ownership check; trusts id + hash |
High |
| Portal login/account lookup by email | getportallogin_api.js, getemailaccountcheck_api.js, getpreferredlanguage_api.js, getaccounts_api.js |
authenticated/read-adjacent or auth-support lookup | getportallogin_api uses hash, others vary; session not visible |
No | Medium-High |
| User-owned account/profile read | getpersonalaccount_api.js |
authenticated user-owned read | No visible session gate | No visible ownership verification; trusts contactid |
High |
| User-owned account/profile write | updateaccount_api.js, updatepassword_api.js, createaccount_api.js |
authenticated write | No visible session gate | No visible ownership verification; trusts contactId |
High-Very High |
| User-owned case/representation reads | getmycases_api.js, getawaitingsubmission_api.js, getmyrepresentations_api.js, getwatchedcases_api.js |
authenticated user-owned read | No visible session gate | Query filters use caller-supplied loggedInUserId; no proof of session-to-contact binding in route |
High |
| User-owned delete/write actions | deletewatchedcases_api.js, deletemyrepresentations_api.js, createwatchedcases_api.js, createcase_api.js, patchcase_api.js, updatecase_api.js |
authenticated write | No visible session gate in sampled handlers | No visible ownership verification; trusts IDs from query/body | Very High |
| Blob/file read/list/download | file/getbloblist.js, getprogressobjblob.js, downloadblob.js, getawaitingsubmissionfromblob.js, getrepsblob.js |
file/blob access | No visible session gate | No visible ownership check; relies on signed hash + container/path identifiers | Very High |
| Blob/file delete/upload | file/upload.js, uploadsinglefile.js, deleteblob*.js, setupcontainer.js |
authenticated write / file/blob access | No visible session gate | No visible ownership verification; relies on signed hash + identifiers | Very High |
| Generated PDFs / completion files | file/generatepdf.js, generateappealpdf.js, createappealcompletemessage_api.js, createrepcompletemessage_api.js |
file/blob access, write side effects | No visible session gate | No visible ownership verification; signed hash only | Very High |
| Involvement creation | file/createcaseinvolvement_api.js, file/createrepinvolvement_api.js |
authenticated write | No visible session gate | No visible ownership verification; trusts contactid + incidentid |
Very High |
| Email/notification send and batch | email/notify.js, email/getall.js, email/getevents.js, email/getdocuments.js |
email/notification | No visible session gate in sampled routes | No per-user ownership check; some are batch/operational | High-Very High |
| Administrative/internal | admin/get* |
administrative/internal | Not assessed deeply in pass 1 | Unknown | High (pending) |
| Health/meta | health.js, doc.ts, notices/index.js |
public/internal utility | Likely none | N/A | Low |
Risk classification table
| Risk level | Endpoint families |
|---|---|
| Low | health.js, likely doc.ts, static/meta utilities |
| Medium | public search/read families where only published/public data is expected |
| High | auth/session, public document metadata, account lookup by email, user-owned reads, direct document download, notification APIs |
| Very High | blob/file access, generated PDF/file routes, user-owned writes/deletes, involvement creation, account mutation routes |
Access-control observations
1. Route-level session enforcement is sparse
Evidence reviewed:
pages/api/auth/[...nextauth].jsis the NextAuth boundary.pages/api/endpoint/gethash_api.jscallsgetSession({ req })and returns401when unauthenticated.- Search across
pages/api/**/*.jsfound no broad pattern ofgetSession,getServerSession, or server-side NextAuth enforcement outside that narrow area.
Assessment:
- Authentication appears to be enforced primarily at the application/session layer and selectively through hash minting, not consistently at each sensitive endpoint.
2. Shared API middleware is not a security boundary
pages/api/middleware/middleware.js:
- parses multipart form data with
multiparty - sets
req.bodyandreq.files - performs no authentication, authorization, ownership, CSRF, or session resolution
Assessment:
- Any route using this middleware gains parsing convenience, not access-control protection.
3. Sensitive routes often trust caller-supplied identifiers
Observed trusted identifiers include:
loggedInUserIdcontactid/contactIdwatchedCaseIDmyRepresentationsIDincidentid/incidentIdemailAddresscontainer/containerIDcasefolderIDblobnametempcaseref
In sampled handlers, these values are usually:
- validated for presence / basic shape
- interpolated into CRM OData queries, CRM write URLs, or Azure blob operations
- not cross-checked against the authenticated session in the route itself
4. File/blob routes rely heavily on signed path hashes
Evidence:
file/downloadblob.jsfile/getbloblist.jsfile/upload.jsfile/uploadsinglefile.jsfile/generateappealpdf.jsdocuments/download/[id].js(document hash passed through to relay)
Assessment:
- Signed hashes are an important trust control.
- However, a valid hash is not equivalent to ownership proof.
- Current visible model suggests:
- authenticated user ->
gethash_api-> signed path -> sensitive route - but downstream route often does not re-check session or resolve the portal user to the referenced CRM/blob object
- authenticated user ->
Architecturally, that is a capability-token style boundary, not a clearly enforced per-route authorization boundary.
5. Session-to-contact resolution is not consistently visible in the endpoint layer
What was found:
gethash_apichecks for a session, but does not resolve ownership of the target object.- Many portal-user routes use
loggedInUserIdorcontactIddirectly from client input. - No common visible pattern in sampled routes for:
- reading
session.user.email - resolving CRM contact server-side from session
- comparing resolved contact to query/body identifiers
- reading
Assessment:
- This is the central access-boundary visibility gap for the current architecture stream.
Ownership enforcement concerns
Caller-supplied portal identity values
loggedInUserId
Observed in:
getmycases_api.jsgetawaitingsubmission_api.jsgetmyrepresentations_api.jsgetwatchedcases_api.js- proxy variants
Concern:
- Routes filter CRM data using the supplied contact ID.
- No in-route proof was found that
loggedInUserIdbelongs to the current authenticated session.
Risk:
- Potential insecure direct object reference if caller can obtain or guess another valid contact ID and reach the route.
contactid / contactId
Observed in:
createcase_api.jsupdateaccount_api.jsupdatepassword_api.jsgetpersonalaccount_api.jscreatecaseinvolvement_api.jscreaterepinvolvement_api.js
Concern:
- Account read/update and case/involvement creation use direct client-supplied contact identifiers.
- Sampled routes do not visibly prove session ownership of that contact.
Risk:
- Cross-account read/update or unauthorized involvement linkage if route is callable with substituted IDs.
Caller-supplied record IDs
watchedCaseID, myRepresentationsID
Observed in:
deletewatchedcases_api.jsdeletemyrepresentations_api.js
Concern:
- Delete routes operate directly on a provided CRM record ID.
- Sampled routes do not first fetch-and-verify ownership against current portal user.
Risk:
- Unauthorized deletion risk if identifiers are exposed or enumerable enough through user flows.
incidentid, caseId, related case/document keys
Observed in many endpoint/*_api.js and document metadata routes.
Concern:
- Public/public-adjacent read routes often key by incident ID.
- In user-owned or side-effect routes, ownership and publication posture are not consistently visible in-handler.
Blob/storage identifiers
container, containerID, casefolderID, blobname, tempcaseref
Observed in many pages/api/file/** routes.
Concern:
- Storage actions are scoped by caller-supplied path components.
- Signed hash protects path integrity but does not itself prove that the current portal user owns that container/blob/case folder.
Risk:
- High-value document/blob exposure or mutation risk if hash issuance or reuse is broader than intended.
Email addresses
Observed in:
getportallogin_api.jsgetpreferredlanguage_api.jsgetemailaccountcheck_api.jsemail/notify.js
Concern:
- Some routes reveal or act on account existence / preference state using email address input.
- Authentication and anti-enumeration posture are not consistently visible in sampled handlers.
Highest-risk endpoint categories
1. pages/api/file/**
Why highest risk:
- document/blob retrieval and mutation
- upload/write side effects
- generated PDFs and completion artifacts
- reliance on hash + path parameters rather than visible route-level ownership proof
Examples reviewed:
downloadblob.jsgetbloblist.jsupload.jsuploadsinglefile.jsgenerateappealpdf.jscreatecaseinvolvement_api.jscreaterepinvolvement_api.js
2. pages/api/email/**
Why high risk:
- sends user-facing external notifications
- touches contact data and preference data
- includes batch mail generation and unsubscribe links
- no visible route-level auth in sampled handlers
Examples reviewed:
notify.jsgetall.js
3. pages/api/auth/**
Why high risk:
- session/authentication boundary
- locale-sensitive redirect behavior
- identity and email sign-in flows
Observation:
- NextAuth route is clearly security-sensitive and better structured than most non-auth handlers, but it is not itself an ownership enforcement layer for downstream CRM/blob operations.
4. User-owned CRM routes in pages/api/endpoint/*_api.js
Why high risk:
- often operate on contact-owned data
- commonly trust
loggedInUserId/contactIdfrom the request - write/delete routes show no sampled ownership verification step
Examples reviewed:
getmycases_api.jsgetwatchedcases_api.jsgetpersonalaccount_api.jscreatecase_api.jsupdateaccount_api.jsdeletewatchedcases_api.jsdeletemyrepresentations_api.js
5. Document retrieval endpoints
Why high risk:
- direct access to published or semi-sensitive documents
- document IDs and hashes act as capability inputs
- logging includes document identifiers and filenames in places
Examples reviewed:
documents/download/[id].js- search document detail/history families
Unknowns / evidence gaps
This pass is intentionally endpoint-focused. The following remain unresolved and should be tested or traced in the next pass:
- How hashes are actually minted in the client flows
gethash_apiis authenticated, but we have not yet mapped every client consumer and whether hashes can be replayed, shared, or over-broadened.
- Whether some ownership checks are enforced outside the route
- for example in page-level flow logic, hidden upstream middleware, relay-side controls, or CRM-side permissions.
- Administrative route exposure model
pages/api/admin/**needs a dedicated pass.
- Whether document download hashes are scoped narrowly enough to prevent cross-user use
- especially
documents/download/[id].jsand blob/file routes.
- especially
- How much route access is implicitly protected by UI/session flow only
- which is weaker evidence than explicit server-side authorization.
- Logging/redaction consistency in sensitive handlers
- some sampled routes still log identifiers or payload-adjacent context.
Ownership enforcement trace
This second pass narrows scope from broad endpoint inventory to one question only:
Where is ownership enforcement actually performed?
Important scope clarification for this pass:
- relay hash = request integrity / portal-to-relay trust
- relay hash != user authentication
- relay hash != object ownership authorization
- relay upstream authentication to CRM is treated as infrastructure and is out of scope
Session -> email -> portal user -> CRM contact flow
The dominant portal-owned identity chain is:
NextAuth session
-> session.user.email
-> getPortalLogin(email)
-> CRM contactid
-> portal/account/my-cases/my-watched-cases CRM queries
Observed evidence:
pages/myportal/index.js- gets NextAuth session with
getSession(ctx) - calls
getPortalLogin(thisSession.user.email) - extracts
contacts[0]?.contactidasloggedInUser - then calls
getPersonalAccount(loggedInUser),getMyCases(loggedInUser),getWatchedCases(loggedInUser)
- gets NextAuth session with
lib/representation/pageLoaders.jsloadRepresentationBootstrap()resolvesthisSession.user.email- calls
getPortalLogin(...) - extracts
loggedInUser = ...contactid - then calls
getPersonalAccount(loggedInUser)
pages/myportal/case/[ticketnumber].js- resolves session ->
getPortalLogin(email)-> CRMcontactid - stores
setLoggedInUserId(loggedInUser)
- resolves session ->
lib/auth/resolveMyPortalAuthContext.js- confirms session presence and
session.user.id+session.user.email - also requires
pinsUsercookie - returns all three identity values together:
sessionUserIdsessionUserEmailpinsUser
- confirms session presence and
Split identity model
The current architecture does not use one single identity key consistently.
Instead it uses a split model:
A. CRM ownership identity
Used for CRM-owned user data and many portal lists:
session.user.email- resolved to CRM contact via
getPortalLogin(email) - resulting
contactidpassed into CRM-facing APIs as:loggedInUserIdcontactidcontactId
B. Blob/storage ownership identity
Used for draft/blob/container-scoped data:
session.user.id- used as container identifier in SSR/page loaders and store state
- examples:
setContainerID(thisSession.user.id)inpages/myportal/index.jsgetRepsFromBlob(thisSession.user.id)getAwaitingSubmissionFromBlob(thisSession.user.id)getProgressFromBlob(loggedInUserIdent, query.id)inlib/newappeal/loadNewAppealPage.jsgetFilesFromBlob(loggedInUserIdent, query.casereference)inlib/myportal/loadMyPortalAppealPage.js
C. Cookie-based CRM identity shortcut
Some SSR loaders use the pinsUser cookie directly as CRM contact identity rather than resolving it fresh from session email.
Observed in:
lib/newappeal/loadNewAppealPage.jsloggedInUser = cookies?.pinsUser- then
getPersonalAccount(loggedInUser)
lib/myportal/loadMyPortalAppealPage.jsloggedInUser = cookies?.pinsUser- then
getPersonalAccount(loggedInUser)
lib/auth/resolveMyPortalAuthContext.js- treats missing
pinsUseras auth-context failure
- treats missing
Contact resolution location map
| File | Responsibility | Resolution mechanism | Server-side or client-side |
|---|---|---|---|
pages/myportal/index.js |
my-portal dashboard SSR bootstrap | getSession(ctx) -> getPortalLogin(session.user.email) -> CRM contactid |
server-side |
lib/representation/pageLoaders.js |
representation SSR bootstrap | getSession(ctx) -> getPortalLogin(session.user.email) -> CRM contactid |
server-side |
pages/myportal/case/[ticketnumber].js |
my-portal case detail SSR bootstrap | getSession(ctx) -> getPortalLogin(session.user.email) -> CRM contactid |
server-side |
lib/newappeal/loadNewAppealPage.js |
new-appeal SSR bootstrap | getSession(ctx) + pinsUser cookie already treated as CRM contact |
server-side |
lib/myportal/loadMyPortalAppealPage.js |
resume-draft appeal SSR bootstrap | getSession(ctx) + pinsUser cookie already treated as CRM contact |
server-side |
actions/services/accountDirectService.js |
account service access | getPortalLogin(email) calls signed API lookup returning CRM contact |
shared helper |
Assessment:
- There is a shared resolution pattern, but it is only partially centralized.
- The dominant server-side CRM-contact resolution mechanism is
getPortalLogin(session.user.email). - Some flows instead rely on the pre-existing
pinsUsercookie as the CRM contact source.
Ownership enforcement map
Location 1: SSR / page loaders
This is the most visible ownership-establishment layer.
Observed responsibilities:
- ensure a NextAuth session exists
- derive either:
- CRM contact ID from
session.user.email, or - container ID from
session.user.id
- CRM contact ID from
- hydrate Redux/store and page props using those identifiers
Mechanism:
- explicit session gating
- explicit portal identity derivation
- ownership then passed downstream as identifiers to service helpers / APIs
Files with this behavior:
pages/myportal/index.jspages/myportal/case/[ticketnumber].jslib/representation/pageLoaders.jslib/newappeal/loadNewAppealPage.jslib/myportal/loadMyPortalAppealPage.jslib/auth/resolveMyPortalAuthContext.js
Assessment:
- ownership is often established here, before API requests are made
- but it is not always re-validated later in downstream API handlers
Location 2: Client/service helper query construction
Files:
actions/services/portalDirectService.jsactions/services/accountDirectService.jsactions/services/documentDirectService.js
Responsibility:
- package caller-supplied identifiers into API requests
Mechanism:
loggedInUserIdis inserted into/api/endpoint/getmycases_api,getmyrepresentations_api,getwatchedcases_api,getawaitingsubmission_apicontactId/contactidinserted into account or involvement requestssession.user.id-derived container values inserted into blob routes
Assessment:
- these helpers do not enforce ownership
- they propagate identity/ownership assumptions established earlier
- therefore ownership at this layer is advisory/pass-through, not authoritative
Location 3: API route query construction
Files:
pages/api/endpoint/getmycases_api.jspages/api/endpoint/getmyrepresentations_api.jspages/api/endpoint/getwatchedcases_api.jspages/api/endpoint/getawaitingsubmission_api.jspages/api/endpoint/getpersonalaccount_api.jspages/api/endpoint/createwatchedcases_api.jspages/api/endpoint/updateaccount_api.js
Mechanism:
- ownership is represented mainly as CRM filtering or CRM target record selection
- examples:
_customerid_value eq loggedInUserId_pinswg_contact_value eq loggedInUserIdcontacts(contactid)pinswg_watchlists(watchedCaseID)
Assessment:
- CRM query construction is a distributed enforcement location in the sense that ownership is expressed in the query or record target
- but many handlers still trust the incoming identifier rather than resolving it from session themselves
- so enforcement is only as strong as the provenance of that identifier
Location 4: CRM-side filtering / record existence checks
Observed in:
pages/api/endpoint/createwatchedcases_api.js
Mechanism:
- checks whether a watchlist already exists for a given
(incidentId, contactId)pair using:pinswg_WatchedCase/incidentid eq incidentIdpinswg_Contact/contactid eq contactId
Assessment:
- this is not an independent ownership proof
- it is a CRM uniqueness/association lookup based on caller-supplied values
Location 5: blob/container scoping
Observed in:
lib/newappeal/loadNewAppealPage.jslib/myportal/loadMyPortalAppealPage.jslib/representation/pageLoaders.jspages/myportal/index.jsactions/services/documentDirectService.js
Mechanism:
- draft/blob ownership is implicitly scoped to
session.user.id - that value is used as:
- container name
- container lookup key
- draft file/progress retrieval key
Assessment:
- for draft/blob flows, the actual ownership boundary appears to be:
- knowledge/use of the correct session user container identity
- this is a different mechanism from CRM contact ownership
- in reviewed code, this is mostly established in SSR and then passed through to services/routes
Location 6: nowhere visible in route
In many sampled sensitive routes, there is no visible route-local check that:
- resolves current session
- derives the CRM contact server-side
- compares resolved contact to incoming
loggedInUserId/contactId - verifies that record IDs belong to the resolved contact
This is especially true in sampled handlers for:
getmycases_api.jsgetmyrepresentations_api.jsgetwatchedcases_api.jsgetpersonalaccount_api.jsupdateaccount_api.jsdeletewatchedcases_api.jsdeletemyrepresentations_api.js
High-risk flow traces
My Cases
User
-> NextAuth session
-> SSR loader resolves session.user.email
-> getPortalLogin(email)
-> CRM contactid
-> getMyCases(contactid)
-> /api/endpoint/getmycases_api?loggedInUserId=contactid
-> CRM query filters _customerid_value eq loggedInUserId
Where ownership is established:
- SSR/page loader (
pages/myportal/index.js,pages/myportal/case/[ticketnumber].js)
Where ownership is enforced:
- implicitly in CRM query filter
Whether explicit or implicit:
- partially explicit in SSR
- implicit / trust-based in API route
My Representations
There are two parallel ownership models:
CRM/contact model
User
-> session.user.email
-> getPortalLogin(email)
-> CRM contactid
-> getMyRepresentations(contactid)
-> /api/endpoint/getmyrepresentations_api?loggedInUserId=contactid
-> CRM filter _pinswg_contact_value eq loggedInUserId
Blob draft model
User
-> session.user.id
-> getRepsFromBlob(session.user.id)
-> blob container scoped by session.user.id
Where ownership is established:
- SSR bootstrap in
lib/representation/pageLoaders.js
Where ownership is enforced:
- CRM filter for submitted/contact-owned representation records
- blob container identity for draft/representation blob state
Whether explicit or implicit:
- distributed split model
Watched Cases
User
-> session.user.email
-> getPortalLogin(email)
-> CRM contactid
-> getWatchedCases(contactid)
-> /api/endpoint/getwatchedcases_api?loggedInUserId=contactid
-> CRM filter _pinswg_contact_value eq loggedInUserId
Create/update flow:
Client creates pinswg_WatchedCase@odata.bind + pinswg_Contact@odata.bind
-> /api/endpoint/createwatchedcases_api
-> route extracts incidentId/contactId from request body
-> checks recordExists(incidentId, contactId)
-> upserts watchlist
Where ownership is established:
- SSR resolution of contact ID for reads
- client/request body for create/update
Where ownership is enforced:
- read path: CRM filter by contact ID
- create path: association uniqueness lookup only
Whether explicit or implicit:
- read path: implicit via CRM filter
- create path: largely client-trusting
Draft Appeals
User
-> NextAuth session
-> session.user.id
-> getProgressFromBlob(session.user.id, draftId)
-> getFilesFromBlob(session.user.id, caseReference)
Where ownership is established:
- SSR loader (
lib/newappeal/loadNewAppealPage.js,lib/myportal/loadMyPortalAppealPage.js)
Where ownership is enforced:
- by using
session.user.idas blob container identity
Whether explicit or implicit:
- explicit at SSR identity selection
- implicit at blob access layer
Draft Representations
User
-> NextAuth session
-> session.user.id
-> getRepsFromBlob(session.user.id)
-> representation blobs listed from session-scoped container
-> optional rep file retrieval with container + rep paths
Where ownership is established:
- SSR representation bootstrap
Where ownership is enforced:
- blob container scoping on
session.user.id
Whether explicit or implicit:
- implicit storage ownership model
Document Retrieval
Two distinct models exist:
Published/search document retrieval
User
-> portal page/search result
-> document hash link / document reference
-> /api/documents/download/[id]
-> relay document fetch
Observed ownership behavior:
- no user-specific ownership enforcement was visible in reviewed route
- appears to behave as document-reference + hash based access to a publishable document path
User blob retrieval
User
-> session.user.id or caller-provided container/casefolder/blobname
-> /api/file/getbloblist or /api/file/downloadblob
-> blob route fetch
Observed ownership behavior:
- ownership is inferred from correct container/path identity
- not visibly re-proven against current session in the sampled route itself
Client-supplied identifier review
| Identifier | Main locations | Role in architecture | Classification |
|---|---|---|---|
loggedInUserId |
portalDirectService, getmycases_api, getmyrepresentations_api, getwatchedcases_api, getawaitingsubmission_api |
CRM contact selector for user-owned reads | Advisory at request boundary; authoritative only if derived server-side beforehand; validated for presence, not session-bound in route |
contactId / contactid |
account routes, involvement routes, create case, create watched cases | CRM contact target / association key | Advisory to API route; presence-validated; not independently ownership-validated in sampled routes |
userId |
mainly session.user.id in SSR/store/blob flows |
blob/container ownership identity | Authoritative when sourced from session server-side; becomes advisory once passed onward |
incidentId / incidentid / caseId |
watched cases, involvements, search/case details | CRM case/incident target | Validated for presence/format in places; not generally ownership-validated in sampled handlers |
representationId / myRepresentationsID |
delete representation routes | target record ID | Unvalidated ownership; presence-validated only |
watchedCaseID |
delete watched case route | target record ID | Unvalidated ownership; presence-validated only |
blobname |
file download/delete routes | blob object selector | Path-level validated / hash-protected in some routes; not visibly user-ownership validated |
casefolderID |
draft/blob list/progress/download routes | draft case folder selector | Advisory; often combined with container identity; not visibly session-compared in route |
container / containerID |
blob routes, draft flows | storage container identity | Authoritative when taken from session.user.id in SSR; otherwise advisory at route boundary |
Evidence-backed architectural conclusion
1. What mechanism prevents User A accessing User B's data?
There is no single visible centralized mechanism.
Instead, prevention appears to rely on a combination of:
- SSR/session guards that require a valid NextAuth session
- server-side resolution of
session.user.emailto CRMcontactidin some page loaders - use of that CRM
contactidin downstream CRM filters for user-owned lists - use of
session.user.idas the blob container identity for draft/blob flows - pre-existing
pinsUsercookie in some SSR flows as a CRM-contact shortcut
For many routes, what prevents cross-user access is therefore:
- correct upstream derivation and propagation of the right identifier,
- not an independently visible authorization check inside each API handler.
2. Is ownership enforcement centralized, partially centralized, distributed, or unclear?
Partially centralized at SSR/auth-context bootstrap, but overall distributed.
More precisely:
- session establishment is centralized in NextAuth
- session-to-contact resolution pattern exists and is repeated in several SSR loaders
- ownership enforcement itself is distributed across:
- SSR/page loaders
- cookies
- service helper parameter passing
- CRM query filters
- blob container naming conventions
It is not fully centralized in:
- one middleware
- one API guard
- one reusable authorization helper
- one API boundary layer
3. What is the actual authorization boundary?
The actual authorization boundary appears to be:
For CRM-owned portal data
NextAuth session
-> session.user.email
-> CRM contact lookup
-> CRM query filtered by that contact
For draft/blob-owned portal data
NextAuth session
-> session.user.id
-> blob container identity
-> blob/file operations scoped to that container
Therefore the current practical authorization boundary is not simply pages/api/**.
It is a cross-layer boundary spanning:
- NextAuth session
- SSR/page-loader identity derivation
- cookie/session-carried identifiers
- downstream CRM query scoping
- downstream blob container scoping
That means ownership enforcement is real in some flows, but it is often implicit, propagated, and distributed, rather than visibly re-proven at the API route boundary itself.
pinsUser lifecycle
What pinsUser contains
Evidence indicates pinsUser stores a CRM contact identifier, not a session ID and not an email address.
Observed writes:
pages/index.js- writes
setCookie(null, "pinsUser", loggedInUserId.value[0].contactid, { path: "/" }) - source value comes from
getPortalLogin(thisSession.user.email)result
- writes
pages/account/personaldetails.js- writes
setCookie(null, "pinsUser", props.accountDetails.loggedinUserId, { path: "/" })
- writes
components/search/searchresults.js- writes
setCookie(null, "pinsUser", props.accountDetails.loggedinUserId, { path: "/" })
- writes
components/search/addresssearchresults.js- also writes
pinsUserfromprops.accountDetails.loggedinUserId
- also writes
Assessment:
- cookie content = CRM contact id
- source of truth at creation time = CRM contact lookup result or account details already holding CRM contact id
Creation path
The clearest canonical creation path is:
NextAuth session
-> session.user.email
-> getPortalLogin(email)
-> CRM contactid
-> setCookie("pinsUser", contactid)
Primary evidence:
pages/index.js- on successful signed-in home flow:
portalUserObj = await getPortalLogin(thisSession.user.email)- if
portalUserObj.valueis not empty:- writes
pinsUser = portalUserObj.value[0].contactid - redirects to
/myportal
- writes
- if no CRM contact exists:
- redirects to
/account/register
- redirects to
- on successful signed-in home flow:
This means the homepage/login landing flow explicitly creates pinsUser only after CRM contact existence has been established.
Update / refresh path
Observed refresh/update behavior is lightweight and mostly overwrite-based.
Evidence:
pages/account/personaldetails.js- rewrites
pinsUserfromprops.accountDetails.loggedinUserId
- rewrites
- search result components also rewrite the cookie from Redux-held account details
Assessment:
pinsUseris refreshed by simply overwriting the cookie with the CRM contact id already held in page state- there is no distinct refresh protocol or expiry/verification flow visible in reviewed files
Deletion / clear path
Observed in:
lib/auth/sessionClient.jsclearSessionArtifacts()destroyspinsUser
pages/error.js- destroys
pinsUseron go-home/sign-out action
- destroys
pages/_error.js- destroys
pinsUseron go-home/sign-out action
- destroys
Assessment:
pinsUseris treated as a session-adjacent artifact and cleared on sign-out/error-reset paths
Validation path
What is visible:
lib/auth/resolveMyPortalAuthContext.js- checks whether
ctx.req.cookies.pinsUserexists - treats absence as auth-context failure
- checks whether
- SSR loaders such as:
lib/newappeal/loadNewAppealPage.jslib/myportal/loadMyPortalAppealPage.js- read
cookies.pinsUserand proceed to account lookups
What is not visible:
- no independent server-side validation that cookie value matches
session.user.email - no signature or server-issued verification wrapper around
pinsUser - no explicit re-resolution of
pinsUserfrom session in the same loader before use in those cookie-based flows
Assessment:
pinsUseris usually presence-checked, but not strongly revalidated in the flows that consume it directly
Consumption path
pinsUser is consumed in two main ways:
A. SSR loaders
lib/newappeal/loadNewAppealPage.js- uses
cookies.pinsUserasloggedInUser - calls
getPersonalAccount(loggedInUser)
- uses
lib/myportal/loadMyPortalAppealPage.js- same pattern for resume-draft appeal flow
pages/myportal/advancedsearch.jspages/myportal/dnsapplications.jspages/newappeal/aboutyou.js- use cookie-derived contact identity in surrounding SSR flow
B. Client/runtime flows
components/search/searchresults.jscomponents/search/dnssearchresults.jscomponents/case/summary.jscomponents/myportal/topthree.jscomponents/myportal/topthree_reps.jscomponents/myportal/viewall.js
These components read parseCookies().pinsUser and use it directly in watched-case / awaiting-submission / representation-related requests.
Identity comparison
Path A: session email -> CRM contact
session.user.email
-> getPortalLogin(email)
-> CRM contact
Properties:
- server-side derivation from active session
- explicitly re-established in several SSR/dashboard flows
- aligned to the expected business boundary of authenticated portal user -> CRM contact
Path B: pinsUser
pinsUser
-> CRM contact id (cached cookie value)
Properties:
- cookie-carried CRM contact identity
- created from Path A or from state already derived from Path A
- later reused directly in some flows without repeating Path A
Do they always resolve to the same CRM contact?
They appear intended to resolve to the same contact, because pinsUser is initially set from CRM-contact results.
However, based on visible code, divergence is possible in principle because:
- some flows derive CRM contact fresh from
session.user.email - other flows trust existing
pinsUser - reviewed cookie-based flows do not always re-resolve cookie value from session email before use
Is divergence handled?
What is handled:
- missing
pinsUseroften causes redirect/auth-context failure - missing CRM contact from
getPortalLogin(email)often causes redirect or registration flow
What is not clearly handled:
- mismatch between:
- current session-derived CRM contact from
getPortalLogin(session.user.email) - existing
pinsUsercookie value
- current session-derived CRM contact from
Assessment:
- divergence handling is not visibly explicit in reviewed files
- the architecture appears to assume consistency rather than prove it everywhere
Flow dependency map
Flows depending primarily on CRM contact resolution
| Flow | Dependency |
|---|---|
| Homepage signed-in routing | session.user.email -> getPortalLogin(email) |
| Dashboard / my portal index | fresh CRM contact resolution from session email |
| My Cases | CRM contact resolution then CRM-filtered query |
| My Representations (CRM side) | CRM contact resolution then CRM-filtered query |
| Watched Cases (read side) | CRM contact resolution then CRM-filtered query |
| My portal case detail pages | CRM contact resolution in SSR loader |
Flows depending primarily on pinsUser
| Flow | Dependency |
|---|---|
| New appeal SSR loader | pinsUser used as CRM contact for getPersonalAccount(...) |
| Resume appeal SSR loader | pinsUser used as CRM contact for account lookup |
| Some myportal SSR/search utility flows | pinsUser read directly from cookies |
| Watched-case client interactions in search/case UI | parseCookies().pinsUser used directly |
| Awaiting-submission / top-three client widgets | pinsUser used directly in some portal helper calls |
Flows depending on both paths
Some flows are hybrid:
- Dashboard/home establishes
pinsUserfrom fresh CRM contact resolution - later UI or SSR flows consume
pinsUserdirectly
This makes pinsUser operationally a bridge/cache between:
NextAuth session-derived CRM resolution
and
later cookie-based contact reuse
Trust boundary review
Is pinsUser trusted directly?
Yes, in several reviewed flows it is trusted directly once present.
Evidence:
- SSR loaders read
cookies.pinsUserand use it asloggedInUser - client components read
parseCookies().pinsUserand pass it to portal-service helpers
Is pinsUser revalidated?
Not consistently.
Visible validation is mostly:
- existence check
- redirect if missing
Not visibly present in reviewed flows:
- resolve session email -> CRM contact -> compare with cookie before use
Is pinsUser derived from session?
Originally, yes.
Primary creation flow in pages/index.js derives it from:
- active session
getPortalLogin(session.user.email)- CRM contact result
But later consumption is not always re-derived.
Is pinsUser treated as authoritative?
Operationally, yes in some flows.
Architecturally, it looks more like:
- a cached CRM contact identity
- a convenience shortcut
- and likely a legacy compatibility mechanism for flows built around cookie-carried contact identity
It does not look like the best evidence for canonical business identity, because fresh CRM-contact resolution from session email remains present and appears closer to the intended business boundary.
pinsUser architectural conclusion
1. What is the canonical portal identity?
The strongest canonical portal identity visible in the codebase is:
NextAuth session user
-> session.user.email
-> CRM contact lookup
This is the most direct path from authenticated user to business-owned portal identity.
2. Is CRM Contact the true business authorization boundary?
Yes, for CRM-owned dashboard/user data, CRM contact appears to be the true business authorization boundary.
The visible business model is:
Authenticated NextAuth user
-> matching CRM contact
-> permitted dashboard / user-owned CRM views
3. What role does pinsUser play?
pinsUser appears to be:
- A) a cached CRM Contact identity
- B) a convenience shortcut
- C) in some flows, a legacy compatibility mechanism
It does not appear to be the primary canonical ownership authority.
4. Can pinsUser and CRM Contact diverge?
Based on reviewed code, divergence appears possible because:
- one path resolves CRM contact fresh from session email
- another path trusts cookie state already set earlier
- no consistent explicit cookie-vs-session reconciliation was found
Whether divergence happens in practice is not proven here, but architectural protection against divergence is not strongly visible.
5. Is ownership ultimately enforced through session -> CRM Contact, or through some alternative mechanism?
For CRM-owned data, ownership is ultimately most convincingly enforced through:
Session
-> CRM Contact
-> CRM query scoping
For blob/draft-owned data, ownership is enforced through a second mechanism:
Session
-> session.user.id
-> blob container identity
pinsUser sits between those models as a reused CRM-contact cache, not as a separate business authorization system.
Registration and post-registration bootstrap trace
This pass focuses on the transition from:
session exists
-> no CRM contact
-> registration required
-> CRM contact created
-> dashboard access becomes valid
Registration entry map
Detection point: signed-in session but no CRM contact
The clearest decision point is in pages/index.js.
Observed flow:
getSession(ctx)
-> if session exists:
getPortalLogin(session.user.email)
-> if CRM contact exists:
set pinsUser and redirect to /myportal
-> if CRM contact does not exist:
redirect to /account/register
Evidence:
pages/index.jsthisSession = await getSession(ctx)portalUserObj = await getPortalLogin(thisSession.user.email)- client-side branch on returned
loggedInUserId.value- empty ->
router.replace({ pathname: "/account/register", query: { id: encodeURI(loggedInUserEmail) } }) - non-empty -> set
pinsUserand redirect to/myportal
- empty ->
Assessment:
- registration entry occurs after authentication succeeds but before dashboard access is granted
- the deciding condition is effectively:
session exists && getPortalLogin(session.user.email) returns no CRM contact
Auth route alignment
pages/api/auth/[...nextauth].js configures:
newUser: /account/register
Assessment:
- the auth layer is aware of registration as the next-step destination for new users
- however, the concrete dashboard-vs-register branching evidence reviewed here is strongest in
pages/index.js
Registration flow map
Route/page entry
File:
pages/account/register.js
Behavior:
- requires active NextAuth session in
getServerSideProps - if no session -> redirect to
/auth/signin - if session exists -> passes
loggedInUserEmail: thisSession.user.emailinto page props
Assessment:
- registration page entry is gated by session
- session email is injected server-side into the page
Registration form state
Files:
components/account/registerform.jscomponents/account/registerCheck.jscomponents/account/registerComplete.js
Flow:
Register page
-> form entry (`registerform.js`)
-> confirmation screen (`registerCheck.js`)
-> completion/create effect (`registerComplete.js`)
Form inputs and identity source
Evidence from components/account/registerform.js:
initialValues.emailaddress1 = props.loggedInUserEmail- email field is rendered as:
name="emailaddress1"disabled
Assessment:
- registration uses the authenticated session email as the prefilled email
- the user does not appear able to edit it in the reviewed form implementation
- this strongly suggests registration email identity is intended to come from session, not free user input
Submitted data
Data collected in form includes:
firstnamelastnameemailaddress1(session-derived, disabled in form)telephone1company- address fields
API endpoint called
Evidence from components/account/registerComplete.js:
- loads body from Redux form state:
props.props.props.form.accountRegisterForm.values
- assigns:
pinswg_typeofinvolvement: 846040061
- calls:
getEmailAccountCheck(accountBody.emailaddress1)- if none exists ->
createAccount(accountBody)
API/service chain:
RegisterComplete
-> createAccount(accountBody)
-> actions/services/accountDirectService.createAccount(...)
-> /api/endpoint/createaccount_api
-> CRM contacts create
CRM contact creation payload
Evidence:
components/account/registerComplete.js- mutates account body with
pinswg_typeofinvolvement = 846040061 - strips
custom_password_check
- mutates account body with
pages/api/endpoint/createaccount_api.js- takes
req.body - POSTs it directly to CRM
contacts
- takes
Assessment:
- CRM contact creation payload is largely client-assembled form data
- the API route does not visibly enrich the payload with server-derived session identity
- instead it forwards the request body to CRM
Default involvement / role assignment
Observed assignment:
pinswg_typeofinvolvement = 846040061
Comment in code indicates this is not appellant and appears to be a default role path.
Post-registration bootstrap map
After submit
components/account/registerCheck.js:
- confirmation page sets:
setAccCr(false)setAccountCreatedComplete(true)
components/account/registerComplete.js then runs creation logic in useEffect.
Duplicate handling and contact creation
components/account/registerComplete.js:
- calls
getEmailAccountCheck(email) - if
@odata.count > 0->setAccCr("exists") - else ->
createAccount(accountBody)thensetAccCr("created")
Assessment:
- duplicate detection is email-based
- handling is deterministic in the reviewed code path:
- existing email -> not created
- missing email -> create contact
How contact becomes active portal identity
What is visible:
- on successful creation, UI shows a created-state message and a link back to
/ - there is no immediate server-side bootstrap in the reviewed registration components that:
- fetches the newly created contact ID
- sets
pinsUserdirectly - redirects immediately to
/myportal
Instead, the visible model is:
registration completes
-> user returns to /
-> homepage runs signed-in flow again
-> getPortalLogin(session.user.email) now expected to find CRM contact
-> pinsUser set
-> redirect to /myportal
Assessment:
- dashboard access does not appear to be granted directly by the registration completion component itself
- it becomes valid after a subsequent homepage/bootstrap pass that re-runs CRM contact lookup
Is getPortalLogin(session.user.email) re-run?
Indirectly, yes.
Evidence:
RegisterCompletesuccess state links back to/pages/index.jsre-runsgetPortalLogin(thisSession.user.email)on signed-in load
Is pinsUser set immediately?
Not visibly in the reviewed registration flow.
The visible setting point remains the homepage signed-in flow in pages/index.js.
Authorization boundary observations
Does registration bind created CRM contact to authenticated session identity?
Partially, but mostly through UI flow and prefilled/disabled form state rather than route-local server-side enforcement.
What supports binding:
- registration route requires session
- page props inject
thisSession.user.email - form initial value for
emailaddress1comes from session email - email field is disabled in reviewed UI
What weakens binding visibility:
createaccount_api.jsaccepts arbitrary request body and forwards it to CRM- no visible API-route check that
req.body.emailaddress1 === session.user.email - no visible server-side session resolution inside
createaccount_api.js
Assessment:
- authoritative identity during registration appears intended to be
session.user.email - but the final server-side binding is not strongly enforced in the sampled API route itself
Can submitted email differ from session.user.email?
In the reviewed UI flow, it appears not meant to:
emailaddress1is prefilled from session- field is disabled in the form
However, at the API boundary, this is not visibly enforced server-side.
Is duplicate CRM contact handling safe and deterministic?
Observed logic is deterministic but email-based:
getEmailAccountCheck(email)
-> if existing contact count > 0: exists
-> else create contact
This appears operationally deterministic in the reviewed flow, but is still dependent on caller/body email matching session-derived email.
Identity transition map
Anonymous
-> public portal browsing
Authenticated session exists
-> pages/index.js signed-in path
-> getPortalLogin(session.user.email)
If CRM contact missing
-> redirect to /account/register
Registration page
-> session-gated entry
-> session email passed into props
-> disabled email field prefilled
-> form confirmation
-> createAccount API call
-> CRM contact created
Post-registration
-> user returns to /
-> pages/index.js re-runs getPortalLogin(session.user.email)
-> CRM contact now found
-> pinsUser set
-> redirect to /myportal
Dashboard access granted
Registration boundary conclusion
1. Where does the system transition from session-only to CRM-contact-backed portal user?
The transition becomes operationally real in two stages:
- Detection stage at
pages/index.js- session exists
- CRM contact lookup is attempted
- absence triggers registration
- Activation stage after registration, when homepage/bootstrap is revisited and
getPortalLogin(session.user.email)can now resolve a CRM contact
So the effective session-only -> CRM-contact-backed transition completes when homepage/bootstrap re-runs contact lookup successfully after registration.
2. What identity is authoritative during registration?
The intended authoritative identity is:
session.user.email
because:
- session is required
- email is injected from session
- form email is prefilled and disabled
But this authority is enforced more clearly in UI/bootstrap flow than in the create-account API boundary itself.
3. When does dashboard access become valid?
Dashboard access becomes valid after a CRM contact exists and a subsequent signed-in bootstrap resolves that contact successfully.
In reviewed code, that practical access point is:
return to /
-> pages/index.js
-> getPortalLogin(session.user.email)
-> contact found
-> pinsUser set
-> redirect to /myportal
4. Does registration strengthen or weaken the Session -> CRM Contact boundary?
It strengthens the business model conceptually by creating the missing CRM contact needed for dashboard authorization.
But from an implementation-visibility perspective, the boundary remains only partially enforced server-side because:
- registration UI is session-bound and email-prefilled from session
- yet
createaccount_api.jsdoes not visibly prove that created contact email is bound to current session email before forwarding to CRM
Recommended next assessment pass
Next step only:
Trace account and profile mutation flows (personaldetails, password, account updates) to determine whether post-registration CRM-contact-backed users continue to rely on session-derived identity, pinsUser, or caller-supplied contact IDs when mutating their own account data.
Account and profile mutation trace
This pass focuses on CRM-contact-backed account/profile read and mutation flows.
Question under review:
What actually protects account/profile read and mutation?
session.user.email -> CRM contact?
pinsUser?
caller-supplied contactId/contactid/loggedInUserId?
Scope reviewed directly:
pages/account/personaldetails.jspages/account/changepassword.jscomponents/account/personaldetails.jscomponents/account/personaldetailsCheck.jscomponents/account/personaldetailsComplete.jscomponents/account/changepassword.jscomponents/myportal/youraccount.jspages/index.jspages/myportal/index.jsactions/services/accountDirectService.jspages/api/endpoint/getpersonalaccount_api.jspages/api/endpoint/updateaccount_api.jspages/api/endpoint/updatepassword_api.jspages/api/endpoint/getemailaccountcheck_api.jspages/api/endpoint/getportallogin_api.jsstore/accountDetails/action.jsstore/accountDetails/reducer.js
Account/profile entry map
User entry point
The visible account entry point is the dashboard “Your details” card.
Evidence:
components/myportal/youraccount.js- links to
/account/personaldetails
- links to
- the password card/link is commented out in the same component
Assessment:
- live user-facing account navigation visibly exposes personal-details update
- change-password route still exists, but its primary dashboard entry is commented out
Session requirement on account pages
pages/account/personaldetails.js:
- calls
useSession() - while loading -> renders
NoSessionWarning - when unauthenticated ->
router.push("/auth/signin")
pages/account/changepassword.js:
- same
useSession()pattern - unauthenticated users are redirected to
/auth/signin
Assessment:
- account/profile pages are session-gated at page/UI level
- these pages do not perform their own server-side
getServerSidePropsCRM-contact resolution
How account identity is established for page use
The live account page does not freshly resolve CRM identity from session email.
Instead:
pages/myportal/index.js- server-side:
getSession(ctx) - resolves
getPortalLogin(thisSession.user.email) - extracts
contacts[0]?.contactidasloggedInUser - calls
getPersonalAccount(loggedInUser) - dispatches:
setAccountDetails(accountDetails)setLoggedInUserId(loggedInUser)
- server-side:
pages/account/personaldetails.js- consumes
props.accountDetails.loggedinUserId - rewrites
pinsUsercookie from that value
- consumes
Assessment:
- dashboard SSR bootstrap is where session email is freshly resolved to CRM contact
- account page then relies on Redux-held
loggedinUserId/accountDetails pinsUseris rewritten from Redux state, not freshly derived from the current session on this page
Missing CRM contact behavior
Fresh missing-contact behavior is most visible in signed-in bootstrap routes, not the account page itself.
Evidence:
pages/index.js- signed-in flow calls
getPortalLogin(session.user.email) - if no CRM contact result -> redirect to
/account/register
- signed-in flow calls
pages/myportal/index.js- if CRM contact lookup fails or yields no contact ID -> redirect to
/auth/signin
- if CRM contact lookup fails or yields no contact ID -> redirect to
Assessment:
- missing CRM contact is handled before normal account-page entry, during signed-in bootstrap
pages/account/personaldetails.jsitself does not freshly detect “no CRM contact, redirect to register”; it assumes account state already exists
Account read path
End-to-end read flow
Observed flow:
Session
-> session.user.email
-> getPortalLogin(session.user.email) [dashboard/bootstrap SSR]
-> CRM contactid
-> setLoggedInUserId(loggedInUser)
-> getPersonalAccount(loggedInUser)
-> /api/endpoint/getpersonalaccount_api?contactid=...
-> CRM contacts(contactid)
-> Redux accountDetails
-> /account/personaldetails form initialValues
Source of contact ID
Primary source in the reviewed live flow:
pages/myportal/index.jsgetPortalLogin(thisSession.user.email)contacts[0]?.contactid
Secondary carried state:
store/accountDetails.reducersloggedinUserIdstored in Redux
pages/account/personaldetails.js- reads
props.accountDetails.loggedinUserId
- reads
pinsUser- rewritten from
loggedinUserIdon account page load
- rewritten from
Assessment:
- read path originates from
getPortalLogin(session.user.email)in bootstrap - once hydrated, account page itself relies on Redux/carried contact ID rather than resolving again from session
API boundary behavior for account read
actions/services/accountDirectService.js:
getPersonalAccount(contactid)calls:/api/endpoint/getpersonalaccount_api?contactid=${contactid}
pages/api/endpoint/getpersonalaccount_api.js:
- requires only query
contactid - constructs CRM query:
contacts(contactid)?$select=...
- does not call
getSession - does not call
getPortalLogin - does not compare
contactidto session-derived CRM contact
Assessment:
- account read route trusts caller-supplied
contactid - ownership is not re-checked server-side in the route
Account update path
UI submission flow
Observed UI flow:
Profile form
-> redux-form personalDetailsForm values
-> confirmation screen
-> PersonalDetailsComplete useEffect
-> updateAccount(loggedinUserId, formValues)
-> /api/endpoint/updateaccount_api?contactId=...
-> CRM contacts(contactId) PATCH
Evidence:
components/account/personaldetails.js- form fields are populated from
state.accountDetails.accountDetails - submit only sets
personalDetailsComplete=true
- form fields are populated from
components/account/personaldetailsCheck.js- confirmation step shows current form values
- continue sets
setAccountUpdatedComplete(true)
components/account/personaldetailsComplete.js- reads
loggedinUserIdfrom Redux account state - reads
formValuesfrompersonalDetailsForm - calls
updateAccount(loggedinUserId, formValues)inuseEffect
- reads
Submitted payload contents
Visible editable fields include:
pinswg_preferredlanguagefirstnamelastnametelephone1pinswg_companyname- address fields
Visible email behavior:
emailaddress1field is present in the form- rendered with
disabled
Assessment:
- live profile UI submits the full form object, but the email field is not editable in the reviewed page implementation
- account updates are targeted by Redux-held
loggedinUserId
API boundary behavior for account update
actions/services/accountDirectService.js:
updateAccount(contactId, updateBody)calls:
/api/endpoint/updateaccount_api?contactId=${contactId}
pages/api/endpoint/updateaccount_api.js:
- requires query
contactId - requires non-empty body
- patches CRM target:
contacts(contactId)
- does not resolve current session
- does not resolve CRM contact from
session.user.email - does not compare submitted/target
contactIdagainst session-derived contact
Assessment:
- account update route trusts caller-supplied
contactId - there is no visible route-local session binding or ownership check
Password / auth-account update path
Live UI path
Observed flow:
pages/account/changepassword.jsexists and is session-gated viauseSession()components/account/changepassword.js- reads
props.props.accountDetails.loggedinUserId - reads
newpasswordfrom Redux form state - calls
updatePassword(contactid, updateBody)
- reads
But in actions/services/accountDirectService.js:
updatePassword(contactId, newpassword)actually calls:
/api/endpoint/updateaccount_api?contactId=${contactId}
body = { pinswg_custom_password: newpassword }
Assessment:
- live password UI updates the CRM contact through
updateaccount_api, notupdatepassword_api
Standalone updatepassword_api route
pages/api/endpoint/updatepassword_api.js:
- exists
- accepts query
contactId - PATCHes
contacts(contactId)with supplied body - does not resolve session or compare to session-derived CRM contact
Search evidence:
- no active reviewed UI/service path calls
updatepassword_api - the main dashboard password entry point is commented out in
components/myportal/youraccount.js
Assessment:
updatepassword_apiappears exposed but not used by the reviewed live UI path- it is likely legacy or inconsistent with the active passwordless NextAuth model
Relationship to NextAuth identity
Observed auth model elsewhere in assessment:
- active sign-in uses NextAuth passwordless email login
Observed account password behavior here:
- password change writes
pinswg_custom_passwordon the CRM contact pages/api/endpoint/getlogin_api.jsstill queriespinswg_custom_password
Assessment:
- CRM password fields/routes appear legacy relative to the passwordless NextAuth identity model
- reviewed password mutation does not appear to update SQL/Prisma/NextAuth identity
- reviewed password mutation appears CRM-contact-only
Email identity observations
Is email editable in account/profile UI?
In the reviewed live personal-details form, no.
Evidence:
components/account/personaldetails.jsField name="emailaddress1" ... disabled
Does API permit email mutation?
Visible route behavior suggests yes in principle.
Reason:
updateaccount_api.jsforwards the provided body to CRM without allowlisting fields- there is no route-local block on
emailaddress1
Assessment:
- reviewed live UI does not expose editable email change
- reviewed API boundary does not visibly prevent caller-supplied email mutation if
emailaddress1were posted directly
Can CRM email diverge from NextAuth session email?
Based on visible code, yes in principle.
Reasoning:
- canonical contact resolution uses
getPortalLogin(session.user.email) - CRM contact read/update targets use
contactIddirectly updateaccount_apidoes not reconcile submitted body withsession.user.email- no reviewed route updates NextAuth/SQL email identity from CRM email changes
Potential divergence model:
NextAuth session.user.email remains A
CRM contact emailaddress1 changed to B
future getPortalLogin(A) may no longer find the same CRM contact
Whether this occurs in the current live UI is limited by the disabled email field, but the API boundary does not visibly guard against it.
Is there visible reconciliation between SQL/NextAuth email and CRM email?
No explicit reconciliation was found in the reviewed account mutation path.
Assessment:
- the strongest alignment mechanism is initial bootstrap via
getPortalLogin(session.user.email) - no visible post-mutation reconciliation path was found in the account/profile APIs reviewed here
Authorization boundary observations
1. Is account/profile mutation bound to authenticated session?
At page-entry level: yes.
At API route boundary: not visibly.
Reason:
- account pages require a session in the UI
- but
updateaccount_api.jsandgetpersonalaccount_api.jsdo not resolve or verify session server-side
2. Is mutation bound to the CRM contact resolved from session email?
Indirectly in the normal bootstrap flow: yes.
At the mutation route itself: not visibly.
Reason:
- normal dashboard bootstrap derives
loggedinUserIdfromgetPortalLogin(session.user.email) - mutation route then trusts the already-supplied
contactId
3. Is mutation bound to pinsUser?
Not primarily in the reviewed live personal-details flow.
Reason:
- mutation target comes from Redux
loggedinUserId pinsUseris rewritten from that state on page load- reviewed profile update component does not source the target ID from
pinsUser
Assessment:
pinsUseris adjacent/cached state here, not the primary authoritative mutation input
4. Is mutation bound to caller-supplied contact ID?
At API route level: yes.
Evidence:
getpersonalaccount_api.jstrustsreq.query.contactidupdateaccount_api.jstrustsreq.query.contactIdupdatepassword_api.jstrustsreq.query.contactId
5. Can User A attempt to mutate User B's CRM contact if they know or can supply another contact ID?
Based on reviewed route code, the route-local protection is not visibly preventing this.
Evidence-backed statement only:
- the sampled account mutation/read routes do not compare caller-supplied
contactId/contactidto a session-derived CRM contact - therefore the server-side route boundary appears caller-ID-trusting
Whether upstream controls elsewhere prevent exploitation was not proven in this pass.
Account mutation boundary conclusion
Account entry map
User reaches /myportal
-> pages/myportal/index.js requires NextAuth session
-> getPortalLogin(session.user.email)
-> CRM contactid
-> getPersonalAccount(contactid)
-> Redux accountDetails + loggedinUserId hydrated
-> dashboard Your details link
-> /account/personaldetails
-> page checks useSession()
-> page uses Redux-held loggedinUserId/accountDetails
Account read map
Session
-> session.user.email
-> getPortalLogin(session.user.email) [SSR bootstrap]
-> contactid
-> getPersonalAccount(contactid)
-> /api/endpoint/getpersonalaccount_api?contactid=...
-> CRM contacts(contactid)
-> Redux accountDetails
-> personal details form initialValues
Account update map
Profile form
-> personalDetailsForm values
-> confirmation screen
-> updateAccount(loggedinUserId, formValues)
-> /api/endpoint/updateaccount_api?contactId=...
-> CRM contacts(contactId) PATCH
Architectural answers
1. What identity protects account/profile mutation?
In normal live flow, the practical identity chain is:
Session
-> session.user.email
-> getPortalLogin(session.user.email)
-> CRM contactid
-> Redux loggedinUserId
-> caller-supplied contactId to updateaccount_api
So protection is strongest at bootstrap/session-derivation time, not at the final API mutation boundary.
2. Does the account mutation path strengthen or weaken the Session -> CRM Contact authorization boundary?
It weakens that boundary at the final route layer.
Reason:
- UI/bootstrap starts from session-derived CRM contact
- but
getpersonalaccount_apiandupdateaccount_apido not re-bind target contact to session-derived identity server-side
3. Is pinsUser authoritative in account mutation flows?
No, not in the reviewed personal-details mutation path.
It is:
- rewritten from Redux-held contact identity
- session-adjacent/cached
- not the primary target-ID source in the reviewed profile mutation components
4. Are any account/password update routes legacy or inconsistent with passwordless NextAuth?
Yes.
Evidence suggests:
updatepassword_api.jsexists but is not used by the reviewed live UI path- live change-password UI writes
pinswg_custom_passwordviaupdateaccount_api - CRM password fields/routes are inconsistent with the primary passwordless NextAuth model
Recommended next assessment pass
Next step only:
Trace whether other user-owned CRM mutation routes (for example watchlist create/delete, case involvement creation, representation mutation, and case creation/update) follow the same caller-supplied contact-ID pattern without route-local session-to-contact rebinding.
User-owned CRM mutation route trace
This pass extends the account/profile finding to the wider user-owned CRM mutation surface.
Question under review:
Is caller-supplied CRM identity trust isolated to account/profile routes,
or systemic across watchlists, case mutation, involvements, representations,
and completion/finalisation side effects?
Scope reviewed directly:
pages/api/endpoint/createwatchedcases_api.jspages/api/endpoint/deletewatchedcases_api.jspages/api/endpoint/createcase_api.jspages/api/endpoint/updatecase_api.jspages/api/endpoint/patchcase_api.jspages/api/file/createcaseinvolvement_api.jspages/api/file/createrepinvolvement_api.jspages/api/endpoint/deletemyrepresentations_api.jspages/api/file/createappealcompletemessage_api.jspages/api/file/createrepcompletemessage_api.jspages/api/file/createcase_api.jspages/api/file/updatecase_api.jsactions/services/portalDirectService.jsactions/services/caseDirectService.jscomponents/case/summary.jscomponents/myportal/viewall.jscomponents/newappeal/createCase.jscomponents/newappeal/buildsection.jscomponents/case/representation/representationComplete.jslib/newappeal/journeyEffects.js
Mutation route inventory
| Route | Purpose | Caller / helper | Input identifiers | CRM entity affected | Reads session in route? | Resolves CRM contact from session email? | Trusts caller-supplied contact / record IDs? | Ownership check before mutation? | Classification |
|---|---|---|---|---|---|---|---|---|---|
pages/api/endpoint/createwatchedcases_api.js |
Create or update watchlist entry / email notifications | portalDirectService.createWatchedCases; components/case/summary.js; components/myportal/viewall.js; search result components; representation completion |
pinswg_WatchedCase@odata.bind, pinswg_Contact@odata.bind, pinswg_appealcasetype, optional pinswg_emailnotifications, pinswg_representationsubmitted, pinswg_representationtype |
pinswg_watchlists |
No | No | Yes | Only duplicate/existing record check for supplied (incidentId, contactId) pair |
C / E |
pages/api/endpoint/deletewatchedcases_api.js |
Delete watched case entry | portalDirectService.deleteWatchedCases; components/case/summary.js; components/myportal/viewall.js; top-three widgets |
watchedCaseID |
pinswg_watchlists |
No | No | Yes (record ID) | No visible ownership check | D |
pages/api/endpoint/createcase_api.js |
Create CRM case / appeal record | caseDirectService.createNewCase; SSR/UI new appeal flow conceptually |
contactid, appealTypeId, containername, lpaID, body |
incidents |
No | No | Yes (contactid, lpaID) |
No visible ownership check on contact binding | C |
pages/api/endpoint/updatecase_api.js |
Update CRM appeal-type-specific record | caseDirectService.updateCase via new appeal progress save flow |
appealObj, updateFormCollection, body |
updateFormCollection(appealObj) |
No | No | Yes (appealObj, collection) |
No visible record ownership check | D |
pages/api/endpoint/patchcase_api.js |
Patch CRM incident servicestage |
caseDirectService.patchCase |
incidentid |
incidents(incidentid) |
No | No | Yes (incidentid) |
No visible ownership check | D |
pages/api/file/createcaseinvolvement_api.js |
Create case-contact involvement relationship | direct caller not prominent in active UI; intended portal helper family | contactid, incidentid |
case/contact relationship ref | No | No | Yes | No visible ownership check; only CRM 412 exists handling | C |
pages/api/file/createrepinvolvement_api.js |
Create representation/contact involvement relationship | portalDirectService.setRepInvolvment; components/case/representation/representationComplete.js |
contactid, incidentid, involvement |
pinswg_contactinvolvements |
No | No | Yes | Only duplicate existing involvement check for supplied (contactid, incidentid, type) |
C / E |
pages/api/endpoint/deletemyrepresentations_api.js |
Delete representation record | portalDirectService.deleteMyRepresentations |
myRepresentationsID |
pinswg_representationses |
No | No | Yes (record ID) | No visible ownership check | D |
pages/api/file/createappealcompletemessage_api.js |
Appeal finalisation side effects; blob finalisation; contact role update | portalDirectService.sendCaseCompleteMessage; lib/newappeal/journeyEffects.js |
container, tempcaseref, inv, hash |
blob state + contacts(contactId) via updateAccount |
No | No | Yes (container/case-derived contact) | No visible session/contact rebinding before updateAccount(contactId, ...) |
B / C |
pages/api/file/createrepcompletemessage_api.js |
Representation finalisation side effect message creation | portalDirectService.sendRepCompleteMessage; components/case/representation/representationComplete.js |
container, tempcaseref, repid, hash |
blob/message side effect | No | No | Yes (container / rep file identifiers) | No visible user ownership proof beyond signed path and caller inputs | B / F |
pages/api/file/createcase_api.js |
Blob-only draft case creation mirror | caseDirectService.createNewCaseBlob; components/newappeal/createCase.js |
contactid, appealTypeId, containername, lpaID, body |
blob draft payload, not active CRM write | No | No | Yes | No route-local ownership proof; blob-side draft helper only | C |
pages/api/file/updatecase_api.js |
Blob-side draft appeal-type record update mirror | caseDirectService.updateCaseBlob |
appealObj, incident, updateFormCollection, body |
blob-side / file route mutation helper path | No | No | Yes | No visible ownership check | D / F |
Watchlist mutation boundary
Add / upsert path
Observed caller flow:
User
-> UI builds pinswg_WatchedCase@odata.bind + pinswg_Contact@odata.bind
-> createWatchedCases(updateBody)
-> /api/endpoint/createwatchedcases_api
-> extract incidentId + contactId from caller-supplied binds
-> recordExists(incidentId, contactId)
-> POST or PATCH pinswg_watchlists
Evidence:
components/case/summary.jsselectWatchedCase(loggedInUser, incidentID, appealType)builds both binds directlyloggedInUseris passed in from UI state / props
components/myportal/viewall.jsselectEmailNotifications(...)builds the same contact/case bind payload fromprops.accountDetails.loggedinUserId
pages/api/endpoint/createwatchedcases_api.js- extracts
incidentIdandcontactIdfrom body binds - duplicate check uses supplied values only
- no session resolution
- no
getPortalLogin(session.user.email)rebinding
- extracts
Assessment:
- add/update watchlist flow is contact-ID-bound but caller-supplied
- route-local check is only:
- are the binds present?
- does a watchlist already exist for this supplied
(incidentId, contactId)pair?
- no visible proof that the supplied contact belongs to the authenticated user
Delete path
Observed flow:
User
-> deleteWatchedCases(watchedCaseID)
-> /api/endpoint/deletewatchedcases_api?watchedCaseID=...
-> CRM delete pinswg_watchlists(watchedCaseID)
Evidence:
components/case/summary.jsandcomponents/myportal/viewall.js- delete uses
pinswg_watchlistid
- delete uses
pages/api/endpoint/deletewatchedcases_api.js- requires only
watchedCaseID - deletes
pinswg_watchlists(watchedCaseID) - does not fetch the watchlist first to verify
_pinswg_contact_value
- requires only
Assessment:
- watchlist delete is record-ID-bound with no visible ownership check
Case / appeal mutation boundary
CRM case creation path
Observed flow:
New appeal UI
-> createNewCase(appealTypeId, lpaID, contactid, createBody, containerName)
-> /api/endpoint/createcase_api?contactid=...&appealTypeId=...&containername=...&lpaID=...
-> CRM incidents POST
-> customerid_contact@odata.bind = /contacts(contactid)
Evidence:
components/newappeal/createCase.js- passes
props.loggedInUserascontactidintocreateNewCaseBlob
- passes
actions/services/caseDirectService.jscreateNewCase(...)andcreateNewCaseBlob(...)takecontactidas direct parameter
pages/api/endpoint/createcase_api.js- trusts
req.query.contactid - binds
customerid_contact@odata.bindusing that value - no session read or contact verification
- trusts
Assessment:
- CRM case creation is contact-ID-bound but caller-supplied
- contact ownership is assumed from upstream UI/bootstrap flow, not re-proven in-route
Case / appeal update path
Observed flow:
Appeal progress save
-> updateCaseProgress(...)
-> updateBody includes pinswg_Appellant@odata.bind = /contacts(loggedinUserId)
-> caseDirectService.updateCase(...)
-> getAppealID(caseReference, updateFormCollection, primaryAttribute)
-> /api/endpoint/updatecase_api?updateFormCollection=...&appealObj=...
-> CRM PATCH updateFormCollection(appealObj)
Evidence:
components/newappeal/buildsection.js- builds
updateBodywithpinswg_Appellant@odata.bindfromlegacyAccountDetails.loggedinUserId - uses
caseReference/incidentidcarried in page state
- builds
actions/services/caseDirectService.jsupdateCase(...)resolvesappealObjbycaseReference- submits
appealObjandupdateFormCollection
pages/api/endpoint/updatecase_api.js- trusts
appealObjandupdateFormCollection - no session resolution
- no ownership verification against contact or incident
- trusts
Assessment:
- case update is session-bound upstream only, but record-ID-bound at the mutation route
- mutation route trusts supplied record/collection target
Case patch path
Observed flow:
patchCase(incidentid)
-> /api/endpoint/patchcase_api?incidentid=...
-> CRM PATCH incidents(incidentid) { servicestage: 0 }
Evidence:
actions/services/caseDirectService.jsexposespatchCase(incidentid)pages/api/endpoint/patchcase_api.js- accepts only
incidentid - directly patches
incidents(incidentid) - no ownership check
- accepts only
Assessment:
- case patch is record-ID-bound with no visible ownership check
Involvement creation boundary
Case involvement creation
Observed flow:
contactid + incidentid
-> /api/file/createcaseinvolvement_api
-> CRM incidents(incidentid)/.../$ref
-> @odata.id points to contacts(contactid)
Evidence:
pages/api/file/createcaseinvolvement_api.js- requires body
contactidandincidentid - creates relationship ref directly from supplied values
- only special handling is CRM
412 -> { record: "exists" }
- requires body
Assessment:
- case involvement creation is contact-ID-bound but caller-supplied
- route proves only parameter presence and duplicate/existing involvement behavior via CRM response
- no visible authorization proof that caller may create involvement for that contact/case pair
Representation involvement creation
Observed flow:
Representation completion UI
-> setRepInvolvment(caseid, contactid, involvement)
-> /api/file/createrepinvolvement_api
-> existing involvement check for supplied (contactid, incidentid, type)
-> create pinswg_contactinvolvements record with supplied contact/case binds
Evidence:
components/case/representation/representationComplete.js- passes
props...accountDetails.accountDetails.contactid - case ID comes from
currentView.caseReference.incidentid
- passes
actions/services/portalDirectService.jssetRepInvolvment(caseid, contactid, involvement)forwards those values directly
pages/api/file/createrepinvolvement_api.js- trusts
contactid,incidentid,involvement - uses
getPersonalAccount(contactid)only to populate email/name fields, not to verify ownership - only checks for existing involvement on the supplied pair/type
- trusts
Assessment:
- representation involvement creation is also contact-ID-bound but caller-supplied
- duplicate prevention is not the same as authorization
Representation mutation boundary
Representation delete path
Observed flow:
deleteMyRepresentations(myRepresentationsID)
-> /api/endpoint/deletemyrepresentations_api?myRepresentationsID=...
-> CRM delete pinswg_representationses(myRepresentationsID)
Evidence:
actions/services/portalDirectService.jsdeleteMyRepresentations(myRepresentationsID)forwards the record ID directly
pages/api/endpoint/deletemyrepresentations_api.js- accepts only
myRepresentationsID - deletes the target representation record directly
- does not resolve contact/session or verify ownership of the representation record first
- accepts only
Assessment:
- representation delete is record-ID-bound with no visible ownership check
Representation finalisation path
Observed flow:
Representation complete page
-> setRepInvolvment(incidentid, contactid, involvement)
-> sendRepCompleteMessage(containerID, ticketnumber, repfile_name)
-> sendEmail(...)
-> createWatchedCases({ watched case bind, contact bind, representationsubmitted, representationtype })
Evidence:
components/case/representation/representationComplete.js- performs all of the above side effects in
useEffect - contact ID comes from account details state
- case ID comes from current representation/case state
- performs all of the above side effects in
pages/api/file/createrepcompletemessage_api.js- trusts
container,tempcaseref,repid,hash - does not resolve session or CRM contact
- trusts
Assessment:
- representation finalisation is session-bound upstream only
- final side effects still rely on caller-supplied IDs / blob identifiers
- route-local user ownership proof is not visible
Awaiting submissions / draft completion observations
Appeal completion / finalisation path
Observed flow:
sendCaseCompleteMessage(containerID, caseReference, typeOfInvolvement)
-> /api/file/createappealcompletemessage_api?container=...&tempcaseref=...&inv=...&hash=...
-> blob progress + case file loaded
-> contactId extracted from caseObj[customerid_contact@odata.bind]
-> updateAccount(contactId, { pinswg_typeofinvolvement: ... })
-> createCaseCompleteMessage(...)
Evidence:
lib/newappeal/journeyEffects.jssendCaseCompleteMessageEffect(...)is a thin wrapper around portal service helper
pages/api/file/createappealcompletemessage_api.js- derives
contactIdfrom case blob content, not session - calls
updateAccount(contactId, ...) - no session resolution or CRM-contact rebinding before updating contact role
- derives
Assessment:
- appeal finalisation is session-bound upstream only and then caller/blob-identity-bound
- it performs CRM contact mutation as a side effect without visible route-local session/contact verification
Blob draft helper variants
pages/api/file/createcase_api.js and pages/api/file/updatecase_api.js appear to be blob-side draft helpers rather than the primary live CRM mutation boundary.
Even so:
- they also accept caller-supplied identifiers (
contactid,appealObj,incident,updateFormCollection) - they do not read session or verify ownership in-route
Pattern classification
A. session-bound at route
- No reviewed mutation route in this pass provided clear evidence of route-local session binding
B. session-bound upstream only
pages/api/file/createappealcompletemessage_api.jspages/api/file/createrepcompletemessage_api.js- practical case update flow leading to
updatecase_api.js - practical representation completion flow leading to watchlist/involvement/message side effects
C. contact-ID-bound but caller-supplied
pages/api/endpoint/createwatchedcases_api.jspages/api/endpoint/createcase_api.jspages/api/file/createcaseinvolvement_api.jspages/api/file/createrepinvolvement_api.js- blob draft create helper
pages/api/file/createcase_api.js
D. record-ID-bound with no visible ownership check
pages/api/endpoint/deletewatchedcases_api.jspages/api/endpoint/deletemyrepresentations_api.jspages/api/endpoint/updatecase_api.jspages/api/endpoint/patchcase_api.js- blob draft update helper
pages/api/file/updatecase_api.js
E. CRM-filter-bound
createwatchedcases_api.jsduplicate/upsert detection for supplied(incidentId, contactId)createrepinvolvement_api.jsduplicate detection for supplied(contactid, incidentid, type)
Important note:
- these are not independent ownership proofs
- they are filter/existence checks built from caller-supplied identifiers
F. unclear
createrepcompletemessage_api.js- ownership of the blob-side representation identifiers is not re-proven in-route
- side effect is clear, but full user-ownership proof remains indirect
Mutation-family conclusion
Watchlist mutation map
User
-> UI builds watched case bind + contact bind
-> createWatchedCases(updateBody)
-> /api/endpoint/createwatchedcases_api
-> route extracts incidentId/contactId from body
-> duplicate check on supplied pair only
-> CRM watchlist create/patch
User
-> deleteWatchedCases(watchedCaseID)
-> /api/endpoint/deletewatchedcases_api
-> CRM watchlist delete by record ID
Case / appeal mutation map
New appeal
-> createNewCase(..., contactid, ...)
-> /api/endpoint/createcase_api?contactid=...
-> CRM incidents create with customerid_contact@odata.bind
Appeal progress save
-> updateBody includes pinswg_Appellant@odata.bind from loggedinUserId
-> updateCase(...)
-> /api/endpoint/updatecase_api?appealObj=...&updateFormCollection=...
-> CRM patch target record by supplied record/collection identifiers
Patch
-> patchCase(incidentid)
-> /api/endpoint/patchcase_api?incidentid=...
-> CRM patch incidents(incidentid)
Involvement creation map
contactid + incidentid
-> createcaseinvolvement_api
-> CRM relationship ref create
contactid + incidentid + involvement
-> createrepinvolvement_api
-> duplicate check on supplied values
-> CRM contact involvement create
Representation mutation map
deleteMyRepresentations(myRepresentationsID)
-> /api/endpoint/deletemyrepresentations_api
-> CRM representation delete by record ID
Representation completion
-> setRepInvolvment(incidentid, contactid, involvement)
-> sendRepCompleteMessage(container, caseRef, repid)
-> createWatchedCases(contact/case binds + rep submitted state)
-> CRM/blob side effects via supplied IDs
Architectural answers
1. Is caller-supplied CRM identity trust isolated or systemic?
It is systemic across the reviewed user-owned CRM mutation families.
The same pattern appears in:
- watchlist upsert
- case creation
- case involvement creation
- representation involvement creation
- account/profile mutation (previous pass)
And a parallel caller-record-ID trust pattern appears in:
- watchlist delete
- representation delete
- case update/patch
2. Which mutation families are strongest / weakest?
Strongest visible family in this pass:
- none of the reviewed mutation routes showed strong route-local session rebinding
Relatively stronger upstream-only flows:
- new appeal progress / completion flows where IDs are first derived in authenticated SSR/UI state
Weakest route-local families:
- delete routes keyed only by record ID (
deletewatchedcases_api,deletemyrepresentations_api) - update/patch routes keyed by supplied record identifiers (
updatecase_api,patchcase_api) - contact-binding mutation routes that trust supplied contact IDs (
createcase_api, involvement routes)
3. Where is ownership actually enforced for mutation flows?
Mostly upstream in UI/bootstrap/state derivation, not in the final route.
The practical pattern is:
NextAuth session
-> session.user.email
-> CRM contact lookup (in bootstrap/page flow)
-> contactid stored in Redux/props/cookie/helper params
-> mutation route trusts stored/supplied identifiers
Some routes add:
- duplicate/existence checks using supplied IDs
- signed hash validation for selected file/blob routes
But these do not visibly replace route-local session-to-contact ownership proof.
4. Does the current architecture have a consistent mutation authorization boundary?
No consistent route-local mutation authorization boundary is visible.
Instead, the architecture appears to use a distributed trust boundary:
- session and CRM-contact derivation happen upstream
- service helpers propagate identifiers
- API routes frequently trust those propagated identifiers directly
- CRM filtering / record target selection often acts on caller-supplied contact or record IDs
Recommended next assessment pass
Next step only:
Trace hash-issuing and signed-route consumption together to determine whether gethash_api meaningfully strengthens the mutation boundary for record-ID and blob/container mutation routes, or whether it remains an integrity-only control layered on top of caller-supplied identity trust.
Hash issuance and watchlist delete vertical slice
This pass narrows to one question only:
Does the signed-hash model strengthen watchlist deletion beyond request integrity,
or does it remain a provenance/integrity control layered on top of upstream identity derivation?
Scope reviewed directly:
pages/api/endpoint/gethash_api.jsactions/clients/relayClient.jsactions/clients/signedRequestClient.jsactions/services/portalDirectService.jspages/api/endpoint/deletewatchedcases_api.jscomponents/myportal/topthree.jscomponents/myportal/viewall.jscomponents/case/summary.jscomponents/search/searchresults.js
Hash issuance model
End-to-end issuance flow
Observed path:
User action
-> service helper builds queryUrl
-> relayClient.buildHashedQueryUrl(queryUrl)
-> GET /api/endpoint/gethash_api?path=<encoded queryUrl>
-> gethash_api validates session and allowlisted path prefix
-> returns hash = hashAPIPath(rawQueryPath)
-> signed URL is queryUrl + hash
Evidence:
actions/clients/relayClient.jsbuildHashedQueryUrl(queryUrl)calls:
/api/endpoint/gethash_api?path=${encodeURIComponent(queryUrl)}
- appends returned `hash` directly to the original `queryUrl`
actions/clients/signedRequestClient.jsbuildSignedUrl(queryUrl)delegates tobuildHashedQueryUrl(queryUrl)deleteSignedJson(queryUrl)performs request against the signed URL
Session influence on hash generation
pages/api/endpoint/gethash_api.js:
- calls
getSession({ req }) - returns
401if no session exists
Assessment:
- active session presence is a precondition for hash issuance
- however, the visible hash value is still produced from
hashAPIPath(rawQueryPath) - no reviewed code shows session identity being mixed into the signed payload itself
Allowlist model
gethash_api.js allowlists path prefixes only:
/api/endpoint/getportallogin_api/api/endpoint/deletemyrepresentations_api/api/endpoint/deletewatchedcases_api- selected upload / delete blob / completion message / PDF generation routes
Assessment:
- the allowlist constrains which route families may receive a signed hash
- it does not visibly encode per-user object ownership rules
Inputs signed
gethash_api.js:
- reads
req.query.pathasrawQueryPath - validates only the prefix/path portion for allowlisting via
queryPath = rawQueryPath.split("?")[0] - returns:
hash = hashAPIPath(rawQueryPath)
Assessment:
- the full raw query path is signed
- therefore signed material can include:
- route path
- query-string parameters
- record identifiers such as
watchedCaseID
- in the reviewed watchlist-delete path, contact identifiers are not included because the delete URL only carries
watchedCaseID
What the hash proves
Based on reviewed code, the hash most clearly proves:
- A) route integrity
- B) route + identifier integrity for identifiers embedded in the signed query string
It does not visibly prove:
- CRM contact ownership
- session-to-object relationship ownership
- route-local authorization to mutate the referenced record
So for the requested classification:
- A) route integrity only -> partially true
- B) route + identifier integrity -> strongest fit
- C) route + ownership -> not visibly supported by reviewed code
Watchlist delete trace
End-to-end flow
Observed flow:
User
-> watched-case UI list
-> watchedCaseID obtained from already loaded watchlist data
-> deleteWatchedCases(watchedCaseID)
-> portalDirectService builds /api/endpoint/deletewatchedcases_api?watchedCaseID=...
-> signedRequestClient requests hash for that exact query URL
-> signed delete request sent
-> deletewatchedcases_api deletes pinswg_watchlists(watchedCaseID)
-> CRM delete executes directly
Where watchedCaseID originates
Evidence:
components/myportal/topthree.js- delete button passes
showTopThreeArr[key].pinswg_watchlistid
- delete button passes
components/myportal/viewall.js- delete button passes
item.pinswg_watchlistid
- delete button passes
components/search/searchresults.js- uses
isWatchedCase(item.incidentid)[0].pinswg_watchlistid
- uses
components/case/summary.js- delete flows also work from watchlist data already loaded into UI state
Assessment:
- normal portal flows obtain
watchedCaseIDfrom previously fetched watchlist records already associated with the user-facing journey - identifier provenance is therefore tied to prior portal data retrieval and state propagation
How watchedCaseID reaches the delete route
Evidence:
actions/services/portalDirectService.jsdeleteWatchedCases(watchedCaseID)builds:
/api/endpoint/deletewatchedcases_api?watchedCaseID=${watchedCaseID}
- then calls `deleteSignedJson(queryUrl)`
actions/clients/signedRequestClient.js- signs the exact query URL before making the delete request
Does the hash include watchedCaseID?
Yes.
Reason:
rawQueryPathpassed togethash_apiincludes the full query stringhashAPIPath(rawQueryPath)therefore covers:
/api/endpoint/deletewatchedcases_api?watchedCaseID=<value>
Assessment:
- the hash protects the integrity of the route + this record identifier in transit between client helper and route
Delete route behavior
pages/api/endpoint/deletewatchedcases_api.js:
- requires only
watchedCaseID - obtains token
- constructs:
pinswg_watchlists(watchedCaseID)
- performs direct CRM delete
What was not visible in the reviewed route:
- no session read
- no CRM contact lookup from
session.user.email - no fetch of the watchlist record to compare its contact relationship before delete
Assessment:
- CRM delete path is direct record deletion by supplied
watchedCaseID
Referential ownership verification findings
Reviewed target question:
Current Session
-> CRM Contact
-> Load watchedCaseID
-> Verify watchedCase.Contact == CRM Contact
-> Delete
Finding:
No referential ownership verification was visible in the reviewed watchlist delete flow.
More precisely:
- session presence is required for hash issuance
- watchlist delete route itself does not visibly:
- resolve current session
- resolve CRM contact from session email
- load watchlist record for relationship comparison
- verify
pinswg_Contact/contactid == current CRM contact
Identifier provenance assessment
The reviewed design most strongly fits:
- A. provenance-based
Reason:
- normal flow assumes valid
watchedCaseIDvalues come from prior portal watchlist retrievals and UI state - signed hash protects the requested delete URL including
watchedCaseID - route-local referential ownership verification is not visible
This is weaker evidence for:
- B. relationship-verified
because the reviewed delete route does not visibly perform a CRM relationship verification step before mutation.
It is not the clearest fit for hybrid, because the visible control stack is:
- session-gated hash issuance
- provenance of identifier through earlier portal flows
- route + identifier integrity protection
rather than explicit relationship verification at delete time.
Hash security assessment
Protects route integrity?
Yes, visibly.
gethash_apionly issues hashes for allowlisted route prefixes- downstream route compares supplied hash against the target route/query path shape through
hashAPIPath(...)
Protects parameter integrity?
Yes, for parameters included in the signed query path.
- in watchlist delete flow,
watchedCaseIDis included in the signed URL
Protects identifier integrity?
Yes, in the sense that the exact signed identifier value in the query string is protected from tampering without a new valid hash.
Protects ownership?
No route-local ownership protection was visible from the hash mechanism alone.
The reviewed code does not show the hash being derived from:
- CRM contact relationship ownership
- session-derived object ownership mapping
- per-record authorization state
Protects authorization?
Not visibly by itself.
More precise statement:
- the hash mechanism visibly participates in request integrity control
- session requirement for hash issuance adds an authenticated gateway to signing
- but object authorization or referential ownership verification is not visibly encoded into hash generation or the watchlist delete route itself
Vertical-slice conclusion
Hash issuance model
User
-> helper requests hash for exact query URL
-> gethash_api requires session
-> gethash_api checks allowlisted path prefix
-> hashAPIPath(rawQueryPath)
-> signed URL returned
Conclusion:
- hash issuance is influenced by session presence only as a gate to signing
- reviewed code does not show session identity influencing the signed value itself
Watchlist delete model
User
-> watchedCaseID obtained from existing portal watchlist data
-> signed delete URL built for /api/endpoint/deletewatchedcases_api?watchedCaseID=...
-> deletewatchedcases_api
-> direct CRM delete pinswg_watchlists(watchedCaseID)
Conclusion:
- watchlist deletion is provenance-based in the reviewed flow
- referential ownership verification at delete time was not visible
Architectural answers
1. What does the hash actually protect?
In the reviewed slice, it protects:
- allowlisted route use
- route integrity
- query/parameter integrity
- identifier integrity for identifiers present in the signed query path
2. Does hash issuance participate in authorization?
Only indirectly and partially.
More precise statement:
- it requires an authenticated session before a hash is issued
- but reviewed code does not show it performing object-level or relationship-level authorization decisions
3. Is watchlist deletion provenance-based or relationship-verified?
- Provenance-based in the reviewed flow
4. Where is ownership represented?
Ownership is represented most visibly in:
- CRM relationships on watchlist records (
pinswg_Contact,pinswg_WatchedCase) - prior portal retrieval flows that load watchlist data for the current user journey
5. Where is ownership verified?
In this reviewed vertical slice:
- route-local referential ownership verification was not visible in
deletewatchedcases_api.js - upstream identity derivation and identifier provenance are visible
- CRM relationship ownership exists as data model structure, but delete-time relationship verification was not visible
Recommended next assessment pass
Next step only:
Perform the same vertical-slice integrity-vs-authorization trace for deletemyrepresentations_api and one blob/container mutation route, to determine whether the same provenance-based signed-request model is used consistently across CRM-record and blob/file deletion paths.
Draft storage ownership assessment
This pass focuses on the storage-owned boundary that exists before CRM submission.
Key model under review:
NextAuth user
-> session.user.id
-> storage container
-> draft JSON + uploaded files
Scope reviewed directly:
lib/newappeal/loadNewAppealPage.jslib/myportal/loadMyPortalAppealPage.jslib/representation/pageLoaders.jsactions/services/documentDirectService.jsactions/azurestorage.jspages/api/file/getprogressobjblob.jspages/api/file/getbloblist.jspages/api/file/getawaitingsubmissionfromblob.jspages/api/file/upload.jspages/api/file/uploadsinglefile.jspages/api/file/deleteblobcase.jspages/api/file/deleteblobrep.jspages/api/file/downloadblob.jspages/api/file/setupcontainer.jspages/api/file/createappealcompletemessage_api.jspages/api/file/createrepcompletemessage_api.jspages/api/file/editRepJson.js
Container identity model
Root mapping
The strongest visible storage ownership mapping is:
NextAuth user
-> session.user.id
-> container identity
Evidence:
lib/newappeal/loadNewAppealPage.jsloggedInUserIdent = session.user.id- draft progress is loaded with
getProgressFromBlob(loggedInUserIdent, query.id)
lib/myportal/loadMyPortalAppealPage.jsloggedInUserIdent = session.user.id- blob list and progress are loaded with:
getFilesFromBlob(loggedInUserIdent, query.casereference)getProgressFromBlob(loggedInUserIdent, query.casereference)getAwaitingSubmissionFromBlob(session.user.id)
lib/representation/pageLoaders.js- draft representations are loaded with
getRepsFromBlob(thisSession.user.id) - representation file lists default to
result.containerID || thisSession.user.id
- draft representations are loaded with
Persistence / reuse properties
Assessment:
- container identity is based on the persistent NextAuth user ID, not the transient session token
- multiple sessions for the same user would resolve to the same container identity because the code repeatedly uses
session.user.id - container identity is also persisted in Redux/store state via
setContainerID(session.user.id)in loader hydration paths
Are container names ever caller-supplied?
Yes, at API route level many storage routes accept container or containerID as request input.
Examples:
pages/api/file/getprogressobjblob.jsreq.query.container
pages/api/file/getbloblist.jsreq.query.container
pages/api/file/getawaitingsubmissionfromblob.jsreq.query.container
pages/api/file/upload.jsreq.body.containerID
pages/api/file/uploadsinglefile.jsreq.body.containerID
pages/api/file/deleteblobcase.jsreq.query.container
pages/api/file/deleteblobrep.jsreq.query.container
pages/api/file/downloadblob.jsreq.query.container
pages/api/file/setupcontainer.jsreq.query.ident
Assessment:
- container identity is strongly derived from
session.user.idin normal SSR/page flows - but many storage APIs operate on caller-provided container identifiers rather than deriving container identity inside the route
Draft creation trace
Draft appeal creation
Observed path:
User starts new appeal
-> loader resolves session.user.id as loggedInUserIdent
-> client/service sends containerID + casefolderID
-> /api/file/upload or /api/file/createcase_api style draft writes
-> Azure blob write into containerID
Evidence:
lib/newappeal/loadNewAppealPage.js- reads draft progress using
session.user.idcontainer identity
- reads draft progress using
actions/services/documentDirectService.jsuploadFiles(...)appendscontainerIDandcasefolderIDinto form data
pages/api/file/upload.js- requires
containerIDandcasefolderID - calls
createBlob(appealData, containerID, casefolderID)
- requires
actions/azurestorage.jscreateBlob(...)writes<caseID>/<caseID>_appeal.jsoninto the supplied container
pages/api/file/createcase_api.js(reviewed earlier)- writes draft case JSON into the supplied
containername
- writes draft case JSON into the supplied
Assessment:
- in normal portal flow, draft appeal creation uses a container that originates from
session.user.id - at the final storage API boundary, the container is supplied to the route rather than derived there
Draft representation creation
Observed path:
User starts representation
-> representation loader uses session.user.id as container identity
-> draft rep JSON/files written into that container
Evidence:
lib/representation/pageLoaders.jsgetRepsFromBlob(thisSession.user.id)setContainerID(thisSession.user.id)
pages/api/file/upload.js- when
repOrAppealis true, route callscreateRepBlob(appealData, containerID, casefolderID)
- when
actions/azurestorage.jscreateRepBlob(...)writes representation JSON into the supplied container under:
<caseRef>/<repfile_name>_rep.json
Assessment:
- draft representation creation follows the same model: session-derived container upstream, caller-supplied container at route level
Draft resume / read trace
Draft appeal resume / read
Observed flow:
User resumes draft appeal
-> SSR loader gets session.user.id
-> getProgressFromBlob(session.user.id, caseReference)
-> /api/file/getprogressobjblob?container=<session.user.id>&casefolderID=...
-> route validates hash
-> Azure read from supplied container + casefolderID
Evidence:
lib/newappeal/loadNewAppealPage.jsgetProgressFromBlob(loggedInUserIdent, query.id)
lib/myportal/loadMyPortalAppealPage.jsgetProgressFromBlob(loggedInUserIdent, query.casereference)
actions/services/documentDirectService.jsgetProgressFromBlob(containerName, casereference)builds route with caller-suppliedcontainer
pages/api/file/getprogressobjblob.js- accepts
containerandcasefolderID - validates hash for that route/query pair
- reads via
getProgressBlobs(containerName, casefolderIDTrimmed)anddownloadProgressFile(containerName, ...)
- accepts
Assessment:
- draft resume/read is container-scoped in normal flow
- route-local container derivation from session is not visible; the route trusts supplied container once hash passes
Draft representation resume / read
Observed flow:
User opens representation drafts
-> loader calls getRepsFromBlob(session.user.id)
-> representation details and files resolved from that container
Evidence:
lib/representation/pageLoaders.jsgetRepsFromBlob(thisSession.user.id)- existing representation files use
getRepsFilesBlobs(result.containerID || thisSession.user.id, ...)
actions/services/documentDirectService.jsgetRepsFromBlob(containerName)builds/api/file/getrepsblob?container=...
pages/api/file/getrepsblob.js(reviewed earlier in searches)- accepts container as query input and validates hash
actions/azurestorage.jsgetRepsBlobs(containerName)reads representation blobs by tags within the supplied container
Assessment:
- representation resume/read is also container-scoped in normal flow
- route-local session-to-container rebinding was not visible in the reviewed storage read routes
File upload / download trace
Upload
Observed flow:
User uploads file
-> documentDirectService appends containerID + casefolderID
-> signed POST to /api/file/upload or /api/file/uploadsinglefile
-> route validates hash
-> route uses supplied containerID + casefolderID
-> Azure blob write into that container/path
Evidence:
actions/services/documentDirectService.jsuploadFiles,uploadSingleFile,uploadRepFilesall appendcontainerIDandcasefolderID
pages/api/file/upload.js- validates only route-level hash plus presence of
containerID/casefolderID - passes supplied values to
createBlob/createRepBlob
- validates only route-level hash plus presence of
pages/api/file/uploadsinglefile.js- validates route-level hash
- requires body
containerID/casefolderID - passes those values to
uploadSingleFile(...)
actions/azurestorage.js- upload helpers write into paths built from supplied
containerNameandfoldername
- upload helpers write into paths built from supplied
Assessment:
- uploads are container-scoped by the provided container value
- in normal application flow, that container value originates from
session.user.id - at the route itself, container identity is caller-supplied rather than freshly session-derived
Download / list
Observed flow:
User requests files
-> helper builds route with container + casefolderID + blobname
-> route validates hash
-> route reads from supplied container/path
Evidence:
actions/services/documentDirectService.jsgetFilesFromBlob(containerName, casefolderID)downloadBlob(containerName, blobName)
pages/api/file/getbloblist.js- accepts
containerandcasefolderID - validates hash
- reads from supplied container via
getBlobs(...)orgetRepsFilesBlobs(...)
- accepts
pages/api/file/downloadblob.js- accepts
container,casefolderID,blobname - validates hash
- reads from supplied container via
downloadFile(containerName, normalizedBlobName)
- accepts
Assessment:
- blob-path integrity is visibly protected by hash validation
- storage reads are container-scoped by supplied container/path
- route-local container derivation from session was not visible in these routes
Draft delete trace
Draft appeal delete
Observed flow:
User deletes draft appeal
-> helper sends container + casefolderID
-> /api/file/deleteblobcase
-> route validates hash
-> Azure delete within supplied container, prefix = casefolderID
Evidence:
actions/services/documentDirectService.jsdeleteAwaitingSubmissionsFromBlob(containerID, casefolderID)
pages/api/file/deleteblobcase.js- accepts
containerandcasefolderID - validates hash
- calls
deleteBlobCase(containerName, casefolderIDTrimmed)
- accepts
actions/azurestorage.jsdeleteBlobCase(...)lists blobs under prefixblobNamewithin the supplied container and deletes them
Assessment:
- draft appeal deletion is container-scoped by supplied container
- route-local session-to-container verification was not visible
Draft representation delete
Observed flow:
User deletes draft representation
-> helper sends container + casefolderID + repfile
-> /api/file/deleteblobrep
-> route validates hash
-> Azure delete within supplied container, prefix = casefolderID/repfile
Evidence:
actions/services/documentDirectService.jsdeleteMyRepresentationsFromBlob(containerID, casefolderID, repfile)
pages/api/file/deleteblobrep.js- accepts
container,casefolderID,repfile - validates hash
- calls
deleteBlobRep(containerName, normalizedRepPath)
- accepts
actions/azurestorage.jsdeleteBlobRep(...)deletes blobs under the supplied prefix within the supplied container
Assessment:
- draft representation deletion follows the same model: container-scoped, caller-supplied container at route level
Submission boundary trace
Appeal submission transition
Observed flow:
Draft in storage
-> /api/file/createappealcompletemessage_api?container=...&tempcaseref=...&hash=...
-> route reads draft JSON and case JSON from storage container
-> route rewrites blob progress / case data
-> route calls createCaseCompleteMessage(containerName, tempCaseRef)
-> queue message contains containerName + storage paths
-> downstream CRM creation happens after queue handoff
Evidence:
pages/api/file/createappealcompletemessage_api.js- loads draft data from storage using supplied
containerNameandtempCaseRef - calls
createCaseCompleteMessage(containerName, tempCaseRef)
- loads draft data from storage using supplied
actions/azurestorage.jscreateCaseCompleteMessage(...)sends queue message containing:containerNameappealpathcasepathfilespath
Queue message does not visibly include:
session.user.id
It does include:
containerName(which in normal flow maps tosession.user.id)
It may indirectly carry CRM contact linkage later because case JSON contains CRM contact bindings, but the explicit queue payload is storage-path based.
Representation submission transition
Observed flow:
Draft representation in storage
-> /api/file/createrepcompletemessage_api?container=...&tempcaseref=...&repid=...&hash=...
-> route calls createRepCompleteMessage(containerName, tempCaseRef, filename)
-> queue message contains containerName + representation storage paths
-> downstream CRM creation happens after queue handoff
Evidence:
pages/api/file/createrepcompletemessage_api.js- passes supplied storage identifiers into queue helper
actions/azurestorage.jscreateRepCompleteMessage(...)queue payload contains:containerNamecaserefreppathfilespath
Ownership transition point
The clearest visible transition is:
Storage ownership
-> queue message creation (`createCaseCompleteMessage` / `createRepCompleteMessage`)
-> downstream CRM creation / submitted-record model
Assessment:
- before queue handoff, ownership is primarily storage/container-scoped
- after queue handoff, the model transitions toward CRM-owned submitted data
- the queue payloads are primarily storage-location based, not explicit session-ID payloads
Storage authorization classification
A. Session-derived container ownership
Strongly visible in upstream loaders and store hydration:
lib/newappeal/loadNewAppealPage.jslib/myportal/loadMyPortalAppealPage.jslib/representation/pageLoaders.js
These flows consistently use:
session.user.id -> container identity
C. Container supplied and trusted
Strongly visible at many storage API boundaries:
getprogressobjblobgetbloblistgetawaitingsubmissionfromblobuploaduploadsinglefiledeleteblobcasedeleteblobrepdownloadblobsetupcontainer
These routes accept container identifiers as request inputs and, in the reviewed code, do not visibly derive the container from session within the route itself.
D. Hybrid
Best-fit overall classification for the storage model:
- D. Hybrid
Reason:
- upstream application flow strongly derives container ownership from
session.user.id - many final file/blob routes then operate on caller-supplied container identity plus signed path/hash validation
So the end-to-end draft authorization boundary is best described as:
session-derived container ownership upstream
+
caller-supplied container/path at route level
+
hash-protected blob-path integrity
Storage boundary conclusion
Container identity model
NextAuth user
-> session.user.id
-> user storage container
Draft creation / resume / delete / upload model
SSR/page loader
-> derives session.user.id
-> passes container identity into service helpers
-> service helpers send container/casefolder values to file APIs
-> file APIs validate hash and operate inside supplied container/path
Architectural answers
1. What is the storage ownership root?
The storage ownership root is most visibly:
session.user.id
2. Is container ownership derived from session.user.id?
Yes, strongly in the normal application loaders and store hydration flows.
3. Are storage operations consistently container-scoped?
Yes, in the sense that the reviewed draft operations are all organized around container + folder/blob path.
But an important precision:
- they are not always route-locally session-derived
- many routes are container-scoped using caller-supplied container identifiers
4. Can container identity be influenced by callers?
Yes, at the reviewed file-route boundaries container identity is commonly supplied by the caller.
The reviewed routes do not visibly re-derive container identity from session inside the handler.
5. Where does ownership transition from storage ownership to CRM ownership?
The clearest visible transition point is queue/finalisation:
storage-owned draft
-> completion route
-> queue message carrying storage paths/container
-> downstream submitted CRM record creation
Recommended next assessment pass
Next step only:
Perform a focused route-local verification pass on the highest-value storage APIs (getprogressobjblob, getbloblist, downloadblob, uploadsinglefile, deleteblobcase, deleteblobrep) to determine whether any of them derive container ownership from session server-side elsewhere in the stack, or whether they rely entirely on upstream container provenance plus hash-protected path integrity.
PEDW authorization architecture model
This section consolidates the completed investigation passes into one high-level authorization architecture model.
It is intended to describe:
- the visible authorization roots
- the main ownership / authorization patterns
- the integrity controls layered across those patterns
- what has been proven by code review
- what has not been proven
It is not a new endpoint review and does not change prior evidence or conclusions.
Authorization roots
1. Anonymous public root
The public browsing root is:
Anonymous user
-> public search / case browsing
This root most clearly applies to:
- public search
- public case browsing
- public document and case-discovery style routes where publication posture is the governing boundary
2. NextAuth session root
The primary authenticated root is:
NextAuth session
This is the most fundamental authenticated boundary visible in the application.
From this session, two major downstream ownership models emerge.
3. CRM contact root
For CRM-owned portal data, the strongest visible business identity root is:
NextAuth session
-> session.user.email
-> getPortalLogin(email)
-> CRM Contact
This root then feeds CRM relationship scoping, CRM query filtering, and CRM record targeting in portal-owned user data flows.
4. Storage container root
For draft/blob-owned data, the strongest visible ownership root is:
NextAuth session
-> session.user.id
-> user-specific Azure Storage container
This root governs:
- draft appeals
- draft representations
- uploaded draft files
- progress JSON and related storage-owned artefacts before submission
Pattern catalogue
A. Public anonymous pattern
Model:
Anonymous user
-> public route/query inputs
-> published/public data access
Use case:
- public portal search and browsing journeys
B. CRM contact scoped pattern
Model:
NextAuth session
-> session.user.email
-> getPortalLogin(email)
-> CRM contactid
-> CRM query / CRM target record
Use case:
- dashboard/account bootstrap
- my cases
- watched cases read path
- submitted representation list paths
- account/profile flows upstream of final API mutation route
C. CRM relationship scoped pattern
Model:
CRM contact
-> CRM relationship
-> CRM query scoping / association records
Use case:
- contact-linked CRM relationships
- watched-case relationships
- representation/contact involvement relationships
Important note:
- CRM relationship ownership is represented in the data model
- route-local referential verification of those relationships was not consistently visible in every reviewed mutation route
D. Record-ID provenance-based pattern
Model:
Upstream identity derivation
-> identifier propagated through UI/service flow
-> route accepts record ID
-> mutation/read occurs by target ID
Use case:
- watchlist delete
- representation delete
- several mutation routes where the visible route boundary trusts propagated record identifiers
Preferred wording for this model:
- provenance-based trust
E. Storage container scoped pattern
Model:
NextAuth session
-> session.user.id
-> container identity
-> casefolder/blob path
-> Azure Storage operation
Use case:
- draft appeal create/read/update/delete
- draft representation create/read/update/delete
- uploaded draft files
- pre-submission PDF/completion artefacts
Important note:
- storage ownership is strongly session-derived upstream
- many final file routes then operate on caller-supplied
container/containerIDvalues rather than deriving container identity in-route
Integrity controls
1. Signed hash as route/query/path integrity control
Visible model:
helper builds exact route/query path
-> gethash_api issues hash for allowlisted route family
-> downstream route validates signed path/query
What this most clearly protects:
- route integrity
- query integrity
- parameter integrity
- path / identifier integrity where the identifier is part of the signed route/query path
What it does not by itself visibly prove:
- CRM object ownership
- storage object ownership
- route-local referential ownership verification
Preferred wording:
- integrity control rather than object-authorization control
2. Azure SDK / server-mediated storage execution
Visible model:
PEDW API
-> Azure SDK
-> storage account credentials / SAS generation
-> Azure Storage
Important conclusion:
- users do not directly access Azure Storage in the reviewed architecture
- storage execution is server-mediated through PEDW API routes and Azure SDK helpers
3. Relay path validation model
Visible model:
PEDW API
-> signed path hash
-> Azure Relay
-> CRM
Important conclusion:
- relay hash and signed path validation visibly strengthen request integrity
- they do not, by themselves, prove object ownership
- relay upstream authentication to CRM remains out of scope for this assessment
What is proven / not proven
What is proven by the reviewed code
The completed assessment passes support the following conclusions:
1. The authorization model is distributed
Authorization is not most clearly expressed as one route-local guard model.
Instead, the visible model is a:
- distributed authorization model
spanning:
- NextAuth session
- SSR/page-loader identity derivation
- CRM contact lookup
- session-derived storage container identity
- service/helper propagation of identifiers
- downstream CRM query scoping
- downstream storage container/path scoping
2. Route-local authorization is not consistently visible
Across many reviewed CRM and storage routes:
- route-local referential verification is not consistently visible
- ownership is often established upstream
- identifiers are then propagated into final handlers
3. Signed hash strengthens integrity, not ownership proof
The reviewed signed-path model most clearly strengthens:
- route/path/query integrity
- identifier integrity where applicable
It does not, on reviewed evidence, independently prove object ownership.
4. Storage access is server-mediated
Storage operations are visibly mediated by:
- PEDW API routes
- Azure SDK helpers
- storage account credentials / generated SAS operations
This is not a direct browser-to-storage model.
5. Ownership roots differ by domain
The completed traces support two distinct ownership roots:
CRM-owned domain
session.user.email
-> CRM contact
-> CRM relationships / query scoping
Draft/blob-owned domain
session.user.id
-> user-specific storage container
-> draft JSON / uploaded files
What has not been proven
The completed review does not prove the following:
1. No exploitability has been demonstrated
The assessment has identified architectural visibility gaps and provenance-based trust patterns.
It has not demonstrated a working exploit.
2. No confirmed User A -> User B mutation has been demonstrated
The reviewed routes often accept propagated identifiers and do not always visibly re-bind them in-route.
However:
- no confirmed User A -> User B data mutation has been demonstrated in this assessment
3. No evidence that health checks or penetration tests are invalid
This assessment does not invalidate prior testing posture.
Specifically, it has produced:
- no evidence that existing health checks are invalid
- no evidence that existing OWASP / pentest outcomes are invalid
4. No evidence of direct CRM or storage exposure
The reviewed architecture does not show:
- direct browser-to-CRM access
- direct browser-to-storage account access
The execution model remains server-mediated.
Risk characterization
Based on the completed investigation set, the best-supported characterization is:
1. Architectural integrity / auditability risk
Why:
- ownership and authorization are frequently distributed across upstream derivation, helper propagation, query scoping, and integrity controls
- route-local referential verification is not consistently self-evident
- this can make the effective authorization boundary harder to audit quickly and confidently
2. Maintainability risk
Why:
- multiple ownership models coexist:
- public anonymous
- CRM contact scoped
- CRM relationship scoped
- record-ID provenance based
- storage container scoped
- this increases the chance of misunderstanding or uneven implementation in future changes
3. Future-change risk
Why:
- the architecture relies significantly on upstream identity derivation and propagated identifiers
- future modifications could weaken assumptions if contributors do not understand which routes rely on provenance-based trust versus route-local verification
4. Not currently a confirmed exploitable vulnerability
Based on reviewed evidence and wording discipline for this assessment:
- this is not currently a confirmed exploitable vulnerability
- it is better understood as an architectural clarity, integrity, and future-hardening concern unless and until exploitability is independently demonstrated
Architectural conclusion
The completed assessment supports the following high-level model:
Public anonymous
Anonymous user
-> public search / case browsing
CRM-owned authorization
NextAuth session
-> session.user.email
-> getPortalLogin(email)
-> CRM Contact
-> CRM relationships / CRM query scoping
Draft/blob-owned authorization
NextAuth session
-> session.user.id
-> user-specific Azure Storage container
-> JSON drafts / uploaded files
Execution / integrity layers
PEDW API
-> signed path hash / route validation
-> Azure Relay or Azure SDK
-> CRM or Azure Storage
Most important synthesis statement:
- PEDW currently exhibits a distributed authorization model
- ownership is usually established upstream
- identifiers are then propagated into downstream routes
- signed hash is an integrity control rather than object-authorization control
- no exploitability has been demonstrated by this assessment alone
Recommendation
Next step only:
Capture this authorization architecture model as the baseline for future assessment and change review, and if later implementation work is explicitly approved, consider narrow helper/guard patterns that re-bind selected sensitive CRM mutation routes to session-derived CRM contact and selected storage mutation routes to session-derived container identity without changing current successful behaviour or testing posture by default.
Programme status
Portal API Security & Access Boundary Assessment
Status: COMPLETE
The architecture is now understood sufficiently for this stream.
Stable programme-level conclusions:
- PEDW uses a distributed authorization model
- authorization is generally established upstream and propagated through later flows
- CRM-owned operations and draft/storage-owned operations use different ownership roots
- integrity controls are visible and meaningful, but they are not the same as route-local object-authorization proof
- no confirmed exploitability has been demonstrated by this assessment
The dominant observed model is:
Identity established
↓
Ownership scope established
↓
Ownership identifier propagated
↓
Integrity controls applied
↓
Operation executed
rather than:
Operation
↓
Identity re-derived
↓
Ownership re-proven
↓
Operation executed
Programme conclusion
No immediate remediation programme is recommended on the basis of the current architecture evidence alone.
If future work is commissioned in this area, it should be framed as:
- authorization hardening
- consistency improvements
- maintainability improvements
and not as emergency security remediation.