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

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

4089 lines
142 KiB
Markdown

# 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.
#### `incidentid`, `caseId`, related case/document keys
Observed in many `endpoint/*_api.js` and document metadata routes.
Concern:
- Public/public-adjacent read routes often key by incident ID.
- In user-owned or side-effect routes, ownership and publication posture are not consistently visible in-handler.
### Blob/storage identifiers
#### `container`, `containerID`, `casefolderID`, `blobname`, `tempcaseref`
Observed in many `pages/api/file/**` routes.
Concern:
- Storage actions are scoped by caller-supplied path components.
- Signed hash protects path integrity but does not itself prove that the current portal user owns that container/blob/case folder.
Risk:
- High-value document/blob exposure or mutation risk if hash issuance or reuse is broader than intended.
### Email addresses
Observed in:
- `getportallogin_api.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:
```text
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`
#### C. Cookie-based CRM identity shortcut
Some SSR loaders use the `pinsUser` cookie directly as CRM contact identity rather than resolving it fresh from session email.
Observed in:
- `lib/newappeal/loadNewAppealPage.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
```text
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
```text
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
```text
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
```text
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:
```text
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
```text
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
```text
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
```text
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
```text
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
```text
NextAuth session
-> session.user.email
-> CRM contact lookup
-> CRM query filtered by that contact
```
#### For draft/blob-owned portal data
```text
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:
```text
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
```text
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
```text
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:
```text
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:
```text
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:
```text
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:
```text
Session
-> CRM Contact
-> CRM query scoping
```
For blob/draft-owned data, ownership is enforced through a second mechanism:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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
```text
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:
```text
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:
```text
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
## Recommended next assessment pass
Next step only:
**Trace account and profile mutation flows (`personaldetails`, password, account updates) to determine whether post-registration CRM-contact-backed users continue to rely on session-derived identity, `pinsUser`, or caller-supplied contact IDs when mutating their own account data.**
## Account and profile mutation trace
This pass focuses on CRM-contact-backed account/profile read and mutation flows.
Question under review:
```text
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:
```text
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:
```text
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:
```text
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:
```text
/api/endpoint/updateaccount_api?contactId=${contactId}
```
`pages/api/endpoint/updateaccount_api.js`:
- requires query `contactId`
- requires non-empty body
- patches CRM target:
```text
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:
```text
/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:
```text
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
```text
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
```text
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
```text
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:
```text
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
## Recommended next assessment pass
Next step only:
**Trace whether other user-owned CRM mutation routes (for example watchlist create/delete, case involvement creation, representation mutation, and case creation/update) follow the same caller-supplied contact-ID pattern without route-local session-to-contact rebinding.**
## User-owned CRM mutation route trace
This pass extends the account/profile finding to the wider user-owned CRM mutation surface.
Question under review:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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
```text
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
```text
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
```text
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
```text
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:
```text
NextAuth session
-> session.user.email
-> CRM contact lookup (in bootstrap/page flow)
-> contactid stored in Redux/props/cookie/helper params
-> mutation route trusts stored/supplied identifiers
```
Some routes add:
- duplicate/existence checks using supplied IDs
- signed hash validation for selected file/blob routes
But these do not visibly replace route-local session-to-contact ownership proof.
#### 4. Does the current architecture have a consistent mutation authorization boundary?
No consistent route-local mutation authorization boundary is visible.
Instead, the architecture appears to use a **distributed trust boundary**:
- session and CRM-contact derivation happen upstream
- service helpers propagate identifiers
- API routes frequently trust those propagated identifiers directly
- CRM filtering / record target selection often acts on caller-supplied contact or record IDs
## Recommended next assessment pass
Next step only:
**Trace hash-issuing and signed-route consumption together to determine whether `gethash_api` meaningfully strengthens the mutation boundary for record-ID and blob/container mutation routes, or whether it remains an integrity-only control layered on top of caller-supplied identity trust.**
## Hash issuance and watchlist delete vertical slice
This pass narrows to one question only:
```text
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:
```text
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:
```text
/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:
```text
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:
```text
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:
```text
/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:
```text
/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:
```text
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:
```text
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
```text
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
```text
User
-> watchedCaseID obtained from existing portal watchlist data
-> signed delete URL built for /api/endpoint/deletewatchedcases_api?watchedCaseID=...
-> deletewatchedcases_api
-> direct CRM delete pinswg_watchlists(watchedCaseID)
```
Conclusion:
- watchlist deletion is **provenance-based** in the reviewed flow
- referential ownership verification at delete time was not visible
### Architectural answers
#### 1. What does the hash actually protect?
In the reviewed slice, it protects:
- allowlisted route use
- route integrity
- query/parameter integrity
- identifier integrity for identifiers present in the signed query path
#### 2. Does hash issuance participate in authorization?
Only indirectly and partially.
More precise statement:
- it requires an authenticated session before a hash is issued
- but reviewed code does not show it performing object-level or relationship-level authorization decisions
#### 3. Is watchlist deletion provenance-based or relationship-verified?
- **Provenance-based** in the reviewed flow
#### 4. Where is ownership represented?
Ownership is represented most visibly in:
- CRM relationships on watchlist records (`pinswg_Contact`, `pinswg_WatchedCase`)
- prior portal retrieval flows that load watchlist data for the current user journey
#### 5. Where is ownership verified?
In this reviewed vertical slice:
- **route-local referential ownership verification was not visible** in `deletewatchedcases_api.js`
- upstream identity derivation and identifier provenance are visible
- CRM relationship ownership exists as data model structure, but delete-time relationship verification was not visible
## Recommended next assessment pass
Next step only:
**Perform the same vertical-slice integrity-vs-authorization trace for `deletemyrepresentations_api` and one blob/container mutation route, to determine whether the same provenance-based signed-request model is used consistently across CRM-record and blob/file deletion paths.**
## Draft storage ownership assessment
This pass focuses on the storage-owned boundary that exists before CRM submission.
Key model under review:
```text
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:
```text
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:
```text
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:
```text
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:
```text
<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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
session-derived container ownership upstream
+
caller-supplied container/path at route level
+
hash-protected blob-path integrity
```
## Storage boundary conclusion
### Container identity model
```text
NextAuth user
-> session.user.id
-> user storage container
```
### Draft creation / resume / delete / upload model
```text
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:
```text
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:
```text
storage-owned draft
-> completion route
-> queue message carrying storage paths/container
-> downstream submitted CRM record creation
```
## Recommended next assessment pass
Next step only:
**Perform a focused route-local verification pass on the highest-value storage APIs (`getprogressobjblob`, `getbloblist`, `downloadblob`, `uploadsinglefile`, `deleteblobcase`, `deleteblobrep`) to determine whether any of them derive container ownership from session server-side elsewhere in the stack, or whether they rely entirely on upstream container provenance plus hash-protected path integrity.**
## PEDW authorization architecture model
This section consolidates the completed investigation passes into one high-level authorization architecture model.
It is intended to describe:
- the visible authorization roots
- the main ownership / authorization patterns
- the integrity controls layered across those patterns
- what has been proven by code review
- what has not been proven
It is not a new endpoint review and does not change prior evidence or conclusions.
## Authorization roots
### 1. Anonymous public root
The public browsing root is:
```text
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:
```text
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:
```text
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:
```text
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:
```text
Anonymous user
-> public route/query inputs
-> published/public data access
```
Use case:
- public portal search and browsing journeys
### B. CRM contact scoped pattern
Model:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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
```text
session.user.email
-> CRM contact
-> CRM relationships / query scoping
```
##### Draft/blob-owned domain
```text
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
```text
Anonymous user
-> public search / case browsing
```
### CRM-owned authorization
```text
NextAuth session
-> session.user.email
-> getPortalLogin(email)
-> CRM Contact
-> CRM relationships / CRM query scoping
```
### Draft/blob-owned authorization
```text
NextAuth session
-> session.user.id
-> user-specific Azure Storage container
-> JSON drafts / uploaded files
```
### Execution / integrity layers
```text
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:
```text
Identity established
Ownership scope established
Ownership identifier propagated
Integrity controls applied
Operation executed
```
rather than:
```text
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.