Files
pedwfrontend/context/portal-api-security-boundary-assessment.md
Robert Bond 27fceffbc9 Merged PR 2413: updated docs
updated docs

Related work items: #23754
2026-06-22 05:36:30 +00:00

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 (121 JS/TS files under pages/api).
  • Authentication is not consistently enforced at route level across sensitive handlers.
  • The shared pages/api/middleware/middleware.js middleware 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 uses getSession({ 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.md
  • context/integration-map.md
  • GUARDRAILS.md (repository root; no context/GUARDRAILS.md file exists in this workspace)
  • context/runbook.md
  • memory-bank/debt-list.md
  • memory-bank/open-questions.md
  • memory-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.js
    • pages/api/middleware/**

High-signal files reviewed directly:

  • pages/api/auth/[...nextauth].js
  • pages/api/endpoint/gethash_api.js
  • pages/api/middleware/middleware.js
  • pages/api/file/downloadblob.js
  • pages/api/file/getbloblist.js
  • pages/api/file/upload.js
  • pages/api/file/uploadsinglefile.js
  • pages/api/file/generateappealpdf.js
  • pages/api/file/createcaseinvolvement_api.js
  • pages/api/file/createrepinvolvement_api.js
  • pages/api/email/notify.js
  • pages/api/email/getall.js
  • pages/api/documents/download/[id].js
  • pages/api/endpoint/getmycases_api.js
  • pages/api/endpoint/getwatchedcases_api.js
  • pages/api/endpoint/getpersonalaccount_api.js
  • pages/api/endpoint/getportallogin_api.js
  • pages/api/endpoint/createcase_api.js
  • pages/api/endpoint/updateaccount_api.js
  • pages/api/endpoint/deletewatchedcases_api.js
  • pages/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].js is the NextAuth boundary.
  • pages/api/endpoint/gethash_api.js calls getSession({ req }) and returns 401 when unauthenticated.
  • Search across pages/api/**/*.js found no broad pattern of getSession, 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.body and req.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:

  • loggedInUserId
  • contactid / contactId
  • watchedCaseID
  • myRepresentationsID
  • incidentid / incidentId
  • emailAddress
  • container / containerID
  • casefolderID
  • blobname
  • tempcaseref

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.js
  • file/getbloblist.js
  • file/upload.js
  • file/uploadsinglefile.js
  • file/generateappealpdf.js
  • documents/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

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_api checks for a session, but does not resolve ownership of the target object.
  • Many portal-user routes use loggedInUserId or contactId directly 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

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.js
  • getawaitingsubmission_api.js
  • getmyrepresentations_api.js
  • getwatchedcases_api.js
  • proxy variants

Concern:

  • Routes filter CRM data using the supplied contact ID.
  • No in-route proof was found that loggedInUserId belongs 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.js
  • updateaccount_api.js
  • updatepassword_api.js
  • getpersonalaccount_api.js
  • createcaseinvolvement_api.js
  • createrepinvolvement_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.js
  • deletemyrepresentations_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.

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.js
  • getpreferredlanguage_api.js
  • getemailaccountcheck_api.js
  • email/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.js
  • getbloblist.js
  • upload.js
  • uploadsinglefile.js
  • generateappealpdf.js
  • createcaseinvolvement_api.js
  • createrepinvolvement_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.js
  • getall.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 / contactId from the request
  • write/delete routes show no sampled ownership verification step

Examples reviewed:

  • getmycases_api.js
  • getwatchedcases_api.js
  • getpersonalaccount_api.js
  • createcase_api.js
  • updateaccount_api.js
  • deletewatchedcases_api.js
  • deletemyrepresentations_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:

  1. How hashes are actually minted in the client flows
    • gethash_api is authenticated, but we have not yet mapped every client consumer and whether hashes can be replayed, shared, or over-broadened.
  2. 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.
  3. Administrative route exposure model
    • pages/api/admin/** needs a dedicated pass.
  4. Whether document download hashes are scoped narrowly enough to prevent cross-user use
    • especially documents/download/[id].js and blob/file routes.
  5. How much route access is implicitly protected by UI/session flow only
    • which is weaker evidence than explicit server-side authorization.
  6. 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]?.contactid as loggedInUser
    • then calls getPersonalAccount(loggedInUser), getMyCases(loggedInUser), getWatchedCases(loggedInUser)
  • lib/representation/pageLoaders.js
    • loadRepresentationBootstrap() resolves thisSession.user.email
    • calls getPortalLogin(...)
    • extracts loggedInUser = ...contactid
    • then calls getPersonalAccount(loggedInUser)
  • pages/myportal/case/[ticketnumber].js
    • resolves session -> getPortalLogin(email) -> CRM contactid
    • stores setLoggedInUserId(loggedInUser)
  • lib/auth/resolveMyPortalAuthContext.js
    • confirms session presence and session.user.id + session.user.email
    • also requires pinsUser cookie
    • returns all three identity values together:
      • sessionUserId
      • sessionUserEmail
      • pinsUser

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 contactid passed into CRM-facing APIs as:
    • loggedInUserId
    • contactid
    • contactId

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) in pages/myportal/index.js
    • getRepsFromBlob(thisSession.user.id)
    • getAwaitingSubmissionFromBlob(thisSession.user.id)
    • getProgressFromBlob(loggedInUserIdent, query.id) in lib/newappeal/loadNewAppealPage.js
    • getFilesFromBlob(loggedInUserIdent, query.casereference) in lib/myportal/loadMyPortalAppealPage.js

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.js
    • loggedInUser = cookies?.pinsUser
    • then getPersonalAccount(loggedInUser)
  • lib/myportal/loadMyPortalAppealPage.js
    • loggedInUser = cookies?.pinsUser
    • then getPersonalAccount(loggedInUser)
  • lib/auth/resolveMyPortalAuthContext.js
    • treats missing pinsUser as auth-context failure

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 pinsUser cookie 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
  • 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.js
  • pages/myportal/case/[ticketnumber].js
  • lib/representation/pageLoaders.js
  • lib/newappeal/loadNewAppealPage.js
  • lib/myportal/loadMyPortalAppealPage.js
  • lib/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.js
  • actions/services/accountDirectService.js
  • actions/services/documentDirectService.js

Responsibility:

  • package caller-supplied identifiers into API requests

Mechanism:

  • loggedInUserId is inserted into /api/endpoint/getmycases_api, getmyrepresentations_api, getwatchedcases_api, getawaitingsubmission_api
  • contactId / contactid inserted into account or involvement requests
  • session.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.js
  • pages/api/endpoint/getmyrepresentations_api.js
  • pages/api/endpoint/getwatchedcases_api.js
  • pages/api/endpoint/getawaitingsubmission_api.js
  • pages/api/endpoint/getpersonalaccount_api.js
  • pages/api/endpoint/createwatchedcases_api.js
  • pages/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 loggedInUserId
    • contacts(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 incidentId
    • pinswg_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.js
  • lib/myportal/loadMyPortalAppealPage.js
  • lib/representation/pageLoaders.js
  • pages/myportal/index.js
  • actions/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.js
  • getmyrepresentations_api.js
  • getwatchedcases_api.js
  • getpersonalaccount_api.js
  • updateaccount_api.js
  • deletewatchedcases_api.js
  • deletemyrepresentations_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.id as 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.email to CRM contactid in some page loaders
  • use of that CRM contactid in downstream CRM filters for user-owned lists
  • use of session.user.id as the blob container identity for draft/blob flows
  • pre-existing pinsUser cookie 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
  • pages/account/personaldetails.js
    • writes setCookie(null, "pinsUser", props.accountDetails.loggedinUserId, { path: "/" })
  • components/search/searchresults.js
    • writes setCookie(null, "pinsUser", props.accountDetails.loggedinUserId, { path: "/" })
  • components/search/addresssearchresults.js
    • also writes pinsUser from props.accountDetails.loggedinUserId

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.value is not empty:
        • writes pinsUser = portalUserObj.value[0].contactid
        • redirects to /myportal
    • if no CRM contact exists:
      • redirects to /account/register

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 pinsUser from props.accountDetails.loggedinUserId
  • search result components also rewrite the cookie from Redux-held account details

Assessment:

  • pinsUser is 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.js
    • clearSessionArtifacts() destroys pinsUser
  • pages/error.js
    • destroys pinsUser on go-home/sign-out action
  • pages/_error.js
    • destroys pinsUser on go-home/sign-out action

Assessment:

  • pinsUser is 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.pinsUser exists
    • treats absence as auth-context failure
  • SSR loaders such as:
    • lib/newappeal/loadNewAppealPage.js
    • lib/myportal/loadMyPortalAppealPage.js
    • read cookies.pinsUser and 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 pinsUser from session in the same loader before use in those cookie-based flows

Assessment:

  • pinsUser is 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.pinsUser as loggedInUser
    • calls getPersonalAccount(loggedInUser)
  • lib/myportal/loadMyPortalAppealPage.js
    • same pattern for resume-draft appeal flow
  • pages/myportal/advancedsearch.js
  • pages/myportal/dnsapplications.js
  • pages/newappeal/aboutyou.js
    • use cookie-derived contact identity in surrounding SSR flow

B. Client/runtime flows

  • components/search/searchresults.js
  • components/search/dnssearchresults.js
  • components/case/summary.js
  • components/myportal/topthree.js
  • components/myportal/topthree_reps.js
  • components/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 pinsUser often 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 pinsUser cookie value

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 pinsUser from fresh CRM contact resolution
  • later UI or SSR flows consume pinsUser directly

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.pinsUser and use it as loggedInUser
  • client components read parseCookies().pinsUser and 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.js
    • thisSession = 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 pinsUser and redirect to /myportal

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.email into 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.js
  • components/account/registerCheck.js
  • components/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:

  • firstname
  • lastname
  • emailaddress1 (session-derived, disabled in form)
  • telephone1
  • company
  • 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
  • pages/api/endpoint/createaccount_api.js
    • takes req.body
    • POSTs it directly to CRM contacts

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) then setAccCr("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 pinsUser directly
    • 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:

  • RegisterComplete success state links back to /
  • pages/index.js re-runs getPortalLogin(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 emailaddress1 comes from session email
  • email field is disabled in reviewed UI

What weakens binding visibility:

  • createaccount_api.js accepts 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:

  • emailaddress1 is 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:

  1. Detection stage at pages/index.js
    • session exists
    • CRM contact lookup is attempted
    • absence triggers registration
  2. 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.js does not visibly prove that created contact email is bound to current session email before forwarding to CRM

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.js
  • pages/account/changepassword.js
  • components/account/personaldetails.js
  • components/account/personaldetailsCheck.js
  • components/account/personaldetailsComplete.js
  • components/account/changepassword.js
  • components/myportal/youraccount.js
  • pages/index.js
  • pages/myportal/index.js
  • actions/services/accountDirectService.js
  • pages/api/endpoint/getpersonalaccount_api.js
  • pages/api/endpoint/updateaccount_api.js
  • pages/api/endpoint/updatepassword_api.js
  • pages/api/endpoint/getemailaccountcheck_api.js
  • pages/api/endpoint/getportallogin_api.js
  • store/accountDetails/action.js
  • store/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
  • 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 getServerSideProps CRM-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]?.contactid as loggedInUser
    • calls getPersonalAccount(loggedInUser)
    • dispatches:
      • setAccountDetails(accountDetails)
      • setLoggedInUserId(loggedInUser)
  • pages/account/personaldetails.js
    • consumes props.accountDetails.loggedinUserId
    • rewrites pinsUser cookie from that value

Assessment:

  • dashboard SSR bootstrap is where session email is freshly resolved to CRM contact
  • account page then relies on Redux-held loggedinUserId / accountDetails
  • pinsUser is 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
  • pages/myportal/index.js
    • if CRM contact lookup fails or yields no contact ID -> redirect to /auth/signin

Assessment:

  • missing CRM contact is handled before normal account-page entry, during signed-in bootstrap
  • pages/account/personaldetails.js itself 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.js
    • getPortalLogin(thisSession.user.email)
    • contacts[0]?.contactid

Secondary carried state:

  • store/accountDetails.reducers
    • loggedinUserId stored in Redux
  • pages/account/personaldetails.js
    • reads props.accountDetails.loggedinUserId
  • pinsUser
    • rewritten from loggedinUserId on account page load

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 contactid to 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
  • components/account/personaldetailsCheck.js
    • confirmation step shows current form values
    • continue sets setAccountUpdatedComplete(true)
  • components/account/personaldetailsComplete.js
    • reads loggedinUserId from Redux account state
    • reads formValues from personalDetailsForm
    • calls updateAccount(loggedinUserId, formValues) in useEffect

Submitted payload contents

Visible editable fields include:

  • pinswg_preferredlanguage
  • firstname
  • lastname
  • telephone1
  • pinswg_companyname
  • address fields

Visible email behavior:

  • emailaddress1 field 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 contactId against 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.js exists and is session-gated via useSession()
  • components/account/changepassword.js
    • reads props.props.accountDetails.loggedinUserId
    • reads newpassword from Redux form state
    • calls updatePassword(contactid, updateBody)

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, not updatepassword_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_api appears 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_password on the CRM contact
  • pages/api/endpoint/getlogin_api.js still queries pinswg_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.js
    • Field name="emailaddress1" ... disabled

Does API permit email mutation?

Visible route behavior suggests yes in principle.

Reason:

  • updateaccount_api.js forwards 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 emailaddress1 were 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 contactId directly
  • updateaccount_api does not reconcile submitted body with session.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.js and getpersonalaccount_api.js do 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 loggedinUserId from getPortalLogin(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
  • pinsUser is rewritten from that state on page load
  • reviewed profile update component does not source the target ID from pinsUser

Assessment:

  • pinsUser is 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.js trusts req.query.contactid
  • updateaccount_api.js trusts req.query.contactId
  • updatepassword_api.js trusts req.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/contactid to 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_api and updateaccount_api do 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.js exists but is not used by the reviewed live UI path
  • live change-password UI writes pinswg_custom_password via updateaccount_api
  • CRM password fields/routes are inconsistent with the primary passwordless NextAuth model

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.js
  • pages/api/endpoint/deletewatchedcases_api.js
  • pages/api/endpoint/createcase_api.js
  • pages/api/endpoint/updatecase_api.js
  • pages/api/endpoint/patchcase_api.js
  • pages/api/file/createcaseinvolvement_api.js
  • pages/api/file/createrepinvolvement_api.js
  • pages/api/endpoint/deletemyrepresentations_api.js
  • pages/api/file/createappealcompletemessage_api.js
  • pages/api/file/createrepcompletemessage_api.js
  • pages/api/file/createcase_api.js
  • pages/api/file/updatecase_api.js
  • actions/services/portalDirectService.js
  • actions/services/caseDirectService.js
  • components/case/summary.js
  • components/myportal/viewall.js
  • components/newappeal/createCase.js
  • components/newappeal/buildsection.js
  • components/case/representation/representationComplete.js
  • lib/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.js
    • selectWatchedCase(loggedInUser, incidentID, appealType) builds both binds directly
    • loggedInUser is passed in from UI state / props
  • components/myportal/viewall.js
    • selectEmailNotifications(...) builds the same contact/case bind payload from props.accountDetails.loggedinUserId
  • pages/api/endpoint/createwatchedcases_api.js
    • extracts incidentId and contactId from body binds
    • duplicate check uses supplied values only
    • no session resolution
    • no getPortalLogin(session.user.email) rebinding

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.js and components/myportal/viewall.js
    • delete uses pinswg_watchlistid
  • pages/api/endpoint/deletewatchedcases_api.js
    • requires only watchedCaseID
    • deletes pinswg_watchlists(watchedCaseID)
    • does not fetch the watchlist first to verify _pinswg_contact_value

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.loggedInUser as contactid into createNewCaseBlob
  • actions/services/caseDirectService.js
    • createNewCase(...) and createNewCaseBlob(...) take contactid as direct parameter
  • pages/api/endpoint/createcase_api.js
    • trusts req.query.contactid
    • binds customerid_contact@odata.bind using that value
    • no session read or contact verification

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 updateBody with pinswg_Appellant@odata.bind from legacyAccountDetails.loggedinUserId
    • uses caseReference / incidentid carried in page state
  • actions/services/caseDirectService.js
    • updateCase(...) resolves appealObj by caseReference
    • submits appealObj and updateFormCollection
  • pages/api/endpoint/updatecase_api.js
    • trusts appealObj and updateFormCollection
    • no session resolution
    • no ownership verification against contact or incident

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.js exposes patchCase(incidentid)
  • pages/api/endpoint/patchcase_api.js
    • accepts only incidentid
    • directly patches incidents(incidentid)
    • no ownership check

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 contactid and incidentid
    • creates relationship ref directly from supplied values
    • only special handling is CRM 412 -> { record: "exists" }

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
  • actions/services/portalDirectService.js
    • setRepInvolvment(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

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.js
    • deleteMyRepresentations(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

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
  • pages/api/file/createrepcompletemessage_api.js
    • trusts container, tempcaseref, repid, hash
    • does not resolve session or CRM contact

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.js
    • sendCaseCompleteMessageEffect(...) is a thin wrapper around portal service helper
  • pages/api/file/createappealcompletemessage_api.js
    • derives contactId from case blob content, not session
    • calls updateAccount(contactId, ...)
    • no session resolution or CRM-contact rebinding before updating contact role

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.js
  • pages/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.js
  • pages/api/endpoint/createcase_api.js
  • pages/api/file/createcaseinvolvement_api.js
  • pages/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.js
  • pages/api/endpoint/deletemyrepresentations_api.js
  • pages/api/endpoint/updatecase_api.js
  • pages/api/endpoint/patchcase_api.js
  • blob draft update helper pages/api/file/updatecase_api.js

E. CRM-filter-bound

  • createwatchedcases_api.js duplicate/upsert detection for supplied (incidentId, contactId)
  • createrepinvolvement_api.js duplicate 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

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.js
  • actions/clients/relayClient.js
  • actions/clients/signedRequestClient.js
  • actions/services/portalDirectService.js
  • pages/api/endpoint/deletewatchedcases_api.js
  • components/myportal/topthree.js
  • components/myportal/viewall.js
  • components/case/summary.js
  • components/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.js
    • buildHashedQueryUrl(queryUrl) calls:
/api/endpoint/gethash_api?path=${encodeURIComponent(queryUrl)}
- appends returned `hash` directly to the original `queryUrl`
  • actions/clients/signedRequestClient.js
    • buildSignedUrl(queryUrl) delegates to buildHashedQueryUrl(queryUrl)
    • deleteSignedJson(queryUrl) performs request against the signed URL

Session influence on hash generation

pages/api/endpoint/gethash_api.js:

  • calls getSession({ req })
  • returns 401 if 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.path as rawQueryPath
  • 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
  • components/myportal/viewall.js
    • delete button passes item.pinswg_watchlistid
  • components/search/searchresults.js
    • uses isWatchedCase(item.incidentid)[0].pinswg_watchlistid
  • components/case/summary.js
    • delete flows also work from watchlist data already loaded into UI state

Assessment:

  • normal portal flows obtain watchedCaseID from 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.js
    • deleteWatchedCases(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:

  • rawQueryPath passed to gethash_api includes the full query string
  • hashAPIPath(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 watchedCaseID values 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_api only 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, watchedCaseID is 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

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.js
  • lib/myportal/loadMyPortalAppealPage.js
  • lib/representation/pageLoaders.js
  • actions/services/documentDirectService.js
  • actions/azurestorage.js
  • pages/api/file/getprogressobjblob.js
  • pages/api/file/getbloblist.js
  • pages/api/file/getawaitingsubmissionfromblob.js
  • pages/api/file/upload.js
  • pages/api/file/uploadsinglefile.js
  • pages/api/file/deleteblobcase.js
  • pages/api/file/deleteblobrep.js
  • pages/api/file/downloadblob.js
  • pages/api/file/setupcontainer.js
  • pages/api/file/createappealcompletemessage_api.js
  • pages/api/file/createrepcompletemessage_api.js
  • pages/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.js
    • loggedInUserIdent = session.user.id
    • draft progress is loaded with getProgressFromBlob(loggedInUserIdent, query.id)
  • lib/myportal/loadMyPortalAppealPage.js
    • loggedInUserIdent = 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

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.js
    • req.query.container
  • pages/api/file/getbloblist.js
    • req.query.container
  • pages/api/file/getawaitingsubmissionfromblob.js
    • req.query.container
  • pages/api/file/upload.js
    • req.body.containerID
  • pages/api/file/uploadsinglefile.js
    • req.body.containerID
  • pages/api/file/deleteblobcase.js
    • req.query.container
  • pages/api/file/deleteblobrep.js
    • req.query.container
  • pages/api/file/downloadblob.js
    • req.query.container
  • pages/api/file/setupcontainer.js
    • req.query.ident

Assessment:

  • container identity is strongly derived from session.user.id in 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.id container identity
  • actions/services/documentDirectService.js
    • uploadFiles(...) appends containerID and casefolderID into form data
  • pages/api/file/upload.js
    • requires containerID and casefolderID
    • calls createBlob(appealData, containerID, casefolderID)
  • actions/azurestorage.js
    • createBlob(...) writes <caseID>/<caseID>_appeal.json into the supplied container
  • pages/api/file/createcase_api.js (reviewed earlier)
    • writes draft case JSON into the supplied containername

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.js
    • getRepsFromBlob(thisSession.user.id)
    • setContainerID(thisSession.user.id)
  • pages/api/file/upload.js
    • when repOrAppeal is true, route calls createRepBlob(appealData, containerID, casefolderID)
  • actions/azurestorage.js
    • createRepBlob(...) 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.js
    • getProgressFromBlob(loggedInUserIdent, query.id)
  • lib/myportal/loadMyPortalAppealPage.js
    • getProgressFromBlob(loggedInUserIdent, query.casereference)
  • actions/services/documentDirectService.js
    • getProgressFromBlob(containerName, casereference) builds route with caller-supplied container
  • pages/api/file/getprogressobjblob.js
    • accepts container and casefolderID
    • validates hash for that route/query pair
    • reads via getProgressBlobs(containerName, casefolderIDTrimmed) and downloadProgressFile(containerName, ...)

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.js
    • getRepsFromBlob(thisSession.user.id)
    • existing representation files use getRepsFilesBlobs(result.containerID || thisSession.user.id, ...)
  • actions/services/documentDirectService.js
    • getRepsFromBlob(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.js
    • getRepsBlobs(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.js
    • uploadFiles, uploadSingleFile, uploadRepFiles all append containerID and casefolderID
  • pages/api/file/upload.js
    • validates only route-level hash plus presence of containerID / casefolderID
    • passes supplied values to createBlob / createRepBlob
  • 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 containerName and foldername

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.js
    • getFilesFromBlob(containerName, casefolderID)
    • downloadBlob(containerName, blobName)
  • pages/api/file/getbloblist.js
    • accepts container and casefolderID
    • validates hash
    • reads from supplied container via getBlobs(...) or getRepsFilesBlobs(...)
  • pages/api/file/downloadblob.js
    • accepts container, casefolderID, blobname
    • validates hash
    • reads from supplied container via downloadFile(containerName, normalizedBlobName)

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.js
    • deleteAwaitingSubmissionsFromBlob(containerID, casefolderID)
  • pages/api/file/deleteblobcase.js
    • accepts container and casefolderID
    • validates hash
    • calls deleteBlobCase(containerName, casefolderIDTrimmed)
  • actions/azurestorage.js
    • deleteBlobCase(...) lists blobs under prefix blobName within 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.js
    • deleteMyRepresentationsFromBlob(containerID, casefolderID, repfile)
  • pages/api/file/deleteblobrep.js
    • accepts container, casefolderID, repfile
    • validates hash
    • calls deleteBlobRep(containerName, normalizedRepPath)
  • actions/azurestorage.js
    • deleteBlobRep(...) 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 containerName and tempCaseRef
    • calls createCaseCompleteMessage(containerName, tempCaseRef)
  • actions/azurestorage.js
    • createCaseCompleteMessage(...) sends queue message containing:
      • containerName
      • appealpath
      • casepath
      • filespath

Queue message does not visibly include:

  • session.user.id

It does include:

  • containerName (which in normal flow maps to session.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.js
    • createRepCompleteMessage(...) queue payload contains:
      • containerName
      • caseref
      • reppath
      • filespath

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.js
  • lib/myportal/loadMyPortalAppealPage.js
  • lib/representation/pageLoaders.js

These flows consistently use:

session.user.id -> container identity

C. Container supplied and trusted

Strongly visible at many storage API boundaries:

  • getprogressobjblob
  • getbloblist
  • getawaitingsubmissionfromblob
  • upload
  • uploadsinglefile
  • deleteblobcase
  • deleteblobrep
  • downloadblob
  • setupcontainer

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

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 / containerID values 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.