Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Track S. Covers plan §4.4, §10.
**Status: ready to build. Gates the launch — this is the single most serious security gap identified in this session's audit.** Today the admin role model is decorative: `AdminRole` and permissions exist as types, but nothing gates any button, page, or action anywhere in the app. Any authenticated admin has full access.
`SellerRole` is already specified in [Phase 5's contract](PHASE-5-SELLER-PORTAL-CONTRACT.md) §4 — this doc adds the platform and marketplace scopes around it.
## 2. Enforcement (backend-side, non-negotiable)
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint must check `(role, tenantScope)` against the acting user's session — **before** touching data, not as a post-hoc filter. `tenant scope` here means: a `MARKETPLACE_ADMIN` for marketplace A must get a `403` (not an empty result) querying marketplace B's data, never a silently-scoped response that looks like "there's just nothing here."
```
GET /api/identity/v1/session/permissions -> { role, scopes: string[], marketplaceIds: string[] }
```
Frontend route/action guards derive from this endpoint's response — never hardcode role logic client-side beyond hiding UI affordances (which is convenience, not security).
Added 2026-08-21 (FH-2.3). §8 specifies password *change*; this specifies what a session actually is, because nothing in this series did.
- A session token is 32 random bytes, base64url. The server stores **only its SHA-256 hash**. A database read must not yield a usable credential.
- Delivered as `HttpOnly; Secure; SameSite=Lax` cookie. Never in a response body, never in `localStorage`, never readable by script. A token in web storage is a token every XSS gets for free.
- Rows carry `expiresAt`, `revokedAt`, `ip`, `userAgent`. Admin sessions expire in 12 hours; storefront customer sessions in 30 days.
- Validation rejects on any of: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not yet enrolled.
- **One cookie name per contour** — the backoffice, the order-manager portal (§8a) and the storefront must not share a session cookie. A customer session must never satisfy an admin guard, and the way to guarantee that is for them to be different cookies checked by different guards, not the same cookie checked more carefully.
- Password change revokes every live session for that user **in the same transaction** as the password write.
Credential storage: Argon2id, `memoryCost 65536, timeCost 3, parallelism 1`, minimum 16 characters. Second factor (TOTP) is **mandatory** for every platform- and marketplace-scope role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token plus the `otpauth://` URI, and issues no session until the factor is confirmed. An enrolment token is not a session and grants nothing else.
### 2.2 Origin allowlist on every cookie-authenticated mutation
Added 2026-08-21 (FH-2.4). Cookie auth without an origin check is CSRF. One hook, ahead of routing:
- Any non-`GET`/`HEAD`/`OPTIONS` request to `/api/admin/*`, `/api/platform/*` or `/api/manager/*` whose `Origin` header is not in the configured allowlist → `403`, before the handler runs.
- CORS uses the same allowlist with `credentials: true`. Not `*`, not reflected.
- The allowlist is configuration, not code, and is per-environment.
This is a dozen lines and it closes the entire class. It is cheap enough that there is no reason for it to arrive late.
action: string; // e.g. 'role.changed', 'offer.price_updated', 'refund.approved'
entityType: string;
entityId: string;
before?: unknown;
after?: unknown;
reason?: string;
occurredAt: string;
ip?: string;
}
```
Mandatory coverage (plan §10.1): permission changes, seller status changes, catalog moderation actions, price changes, payment/refund actions, manual order overrides, integration credential changes, production launch actions.
```
GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=
```
## 4. Secrets
All provider/connector credentials (payment providers, external marketplace connectors, VK/MAX/Telegram bot tokens, FX source keys) live in dedicated secret storage, referenced by opaque `credentialRef` strings in every other contract in this series — never returned in any API response body, never logged in plaintext.
Added 2026-08-18. Partners calling our API authenticate with signed requests, not bearer tokens. Full contract: [PARTNER-PROVISIONING-API-CONTRACT.md §6](PARTNER-PROVISIONING-API-CONTRACT.md).
These are the opposite direction from the rest of §4 and follow a different rule:
- We hold only the partner's **public** key. The private key is generated by the partner and never transmitted to us, never accepted by any endpoint, never logged. There is nothing to store in secret storage on our side.
- Authority is node-scoped: a credential may act on its `scopeNodeId` and that node's descendants, nothing above or beside it. This is a separate axis from the 17 roles in §1 — partner credentials never map onto a human role, and a partner credential can never be granted an admin role.
-`TEST` and `LIVE` credentials are disjoint. A `TEST` key addressing a `LIVE` node is `403`.
- Rotation runs with a bounded overlap window (default 7 days) during which both keys verify. Revocation is immediate and irreversible.
- A credential can never widen its own scope or register another credential at a wider scope.
Audit coverage (§3) extends to: `partner_credential.registered`, `partner_credential.rotated`, `partner_credential.revoked`, and every partner-initiated node write, with `actor` set to the `keyId` that signed the request.
Added 2026-08-21 (FH-2.9). §4 says credentials live in secret storage and never leave the backend. This is the storage format, so that "encrypted" is a specification rather than an adjective.
- AES-256-GCM. 12-byte random IV per value, never reused. Key is 32 bytes, supplied by environment or secret manager, never in the repository.
- The leading version tag exists so the algorithm can be rotated without guessing at the format of existing rows.
- Decryption happens inside the service that uses the secret. A decrypted value is never placed on a DTO, never logged, never returned by any endpoint — including to a `PLATFORM_OWNER`. Backoffice shows presence, last-rotated, and a fingerprint; it does not show the value.
- Fingerprints for display or matching are `HMAC-SHA256(key, value)`, not the value truncated.
- Redirect and callback URLs are built backend-side from the tenant's verified domain and validated against an allowlist before being returned. The browser receives a URL to navigate to, never the material used to construct it.
This covers payment provider credentials, connector credentials, bot tokens, FX source keys, and the per-tenant OAuth app secrets for [Phase 8](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md).
**Acceptance:** no credential value appears in any API response, JS bundle, log line, or browser storage. The bundle half is enforced in CI by `scripts/ci/scan-bundle.sh`.
Applies to storefront/auth/provider endpoints. Frontend currently has **zero** 429 handling anywhere — see [BACKEND-API-REFERENCE.md §5](../../BACKEND-API-REFERENCE.md) for the full error-envelope contract this should follow.
Partner API limits are per `partnerId`, by tier, with the tier set on `PartnerProfile`. Published in the partner OpenAPI spec — a partner must be able to read its own limit rather than discover it by getting `429`.
Required before: bank/payment detail changes (Phase 5 §5), production launch (Phase 9 §3 step 8), role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, and any manual financial override (refund approval outside normal flow, price override on a live order).
## 7. PII minimization
Customer/seller PII is exposed only to roles that need it for their scope (e.g. `FINANCE_VIEWER` sees payout totals, not raw bank account numbers unless `FINANCE_MANAGER`+). Export endpoints (`GET .../export`) are themselves audit-logged actions per §3.
- Login succeeds but every non-auth request 403s with `PASSWORD_CHANGE_REQUIRED` until password is changed.
```
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
```
A `MARKETPLACE_ADMIN` can then provision sub-admins scoped to their own marketplace only — mirrors the seller-team invite pattern in [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (`POST /api/seller/v1/team/invite`):
```
POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }
GET /api/admin/v2/team?marketplaceId=
PATCH /api/admin/v2/team/{userId} { role }
DELETE /api/admin/v2/team/{userId}
```
Invariants:
-`role` must be one of the `MarketplaceRole` set (§1) — never `PlatformRole`. Backend rejects any attempt to grant a platform-scope role through this endpoint (`403 SCOPE_ESCALATION_DENIED`).
-`marketplaceId` is forced server-side to the caller's own tenant scope — request body value is ignored/validated, never trusted.
- Every invite/role-change/removal is an audit-logged action (§3, `action: 'admin_team.invited' | 'admin_team.role_changed' | 'admin_team.removed'`).
- Role grants at `MARKETPLACE_ADMIN` level require step-up auth (§6).
- Invited admins get their own credentials (email + set-password flow), not the shared bootstrap login — the bootstrap account is for first login only and should be rotated/retired once real admins exist.
## 8a. Order manager is a separate contour, not a narrower menu
Added 2026-08-21 (FH-2.14). `ORDER_MANAGER` is one of the 17 roles in §1, which today implies a smaller version of the same backoffice. Make it a separate surface instead:
- Its own URL and its own shell, its own login, and its own session cookie (§2.1). An order manager who somehow obtained a backoffice URL gets `403` from the guard, not a half-rendered admin page.
- Scope comes from **membership rows**, never from configuration. (The reference implementation we reviewed pins the manager's marketplace with an environment variable — that is the one part of it not to copy. An env string is not an access-control decision and cannot express two marketplaces.)
- Reachable data is orders, their customers, and the fulfilment actions the role is permitted. Catalog, design, domains, payment settings, platform users and platform settings are not merely hidden — the endpoints refuse.
- PII is masked in list views and revealed in detail only with the permission for it. Both the reveal and any export are audit-logged (§3, §7).
The reason to spend a separate contour on this rather than more guards: the people who work orders all day are the largest group of accounts and the least likely to be security-trained. Reducing what their credential can reach is worth more than adding checks to what it can.