# 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= -> 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= ``` 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 `/_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 /_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=&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.