# Track S Backend Contract — RBAC, Audit, Secrets, Rate Limiting

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.

---

## 1. Roles (17 total, 3 scopes, per plan §4.4)

```ts
type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';

type MarketplaceRole =
  | 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER' | 'ORDER_MANAGER'
  | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';

type SellerRole =
  | 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER'
  | 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
```

`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).

## 3. Audit log

```ts
interface AuditEvent {
  id: string;
  actor: string;
  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.

### 4.1 Partner credentials (inbound)

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.

## 5. Rate limiting

```
429 response: { error: { code: 'RATE_LIMITED', retryAfterSeconds: number } }
```

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`.

## 6. Step-up authentication

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.

## 8. Initial admin provisioning & self-service admin management

Each marketplace ships with one bootstrap `MARKETPLACE_ADMIN` account, seeded at provisioning time (Phase 9 launch step):

- `login` = marketplace slug (`projectName`)
- `password` = cryptographically random one-time secret delivered out of band,
  flagged `mustChangePassword: true` (never derive it from the marketplace slug)
- 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.

## 9. What the frontend will start doing once this ships

- Route guards and action-level permission checks across the entire backoffice — currently none exist.
- Backoffice **Audit & Security** section (missing from admin nav today): role changes, sensitive actions, login/security events, exports.
- Reconcile `AdminRole` (already de-duplicated to one canonical type this session) against the real 17-role table from §1.
- 429 interceptor + retry-after UI.
