4089 lines
142 KiB
Markdown
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.
|