Skip to content

docs(portal): add API reference and architecture sections (v0.2.0 audit doc-drift)#103

Merged
voyvodka merged 1 commit into
mainfrom
docs/portal-api-architecture
May 11, 2026
Merged

docs(portal): add API reference and architecture sections (v0.2.0 audit doc-drift)#103
voyvodka merged 1 commit into
mainfrom
docs/portal-api-architecture

Conversation

@voyvodka
Copy link
Copy Markdown
Owner

Summary

Tur 3 of the v0.2.0 portal audit follow-up. Tur 1 (#101) was the security fix, Tur 2 (#102) was the test coverage; this PR closes the documentation drift the same audit flagged: the portal API surface existed in code and CHANGELOG but was missing from docs/API.md and docs/ARCHITECTURE.md.

What's added

docs/API.md §3.8 — Portal API (Customer-Facing JWT) (+195 lines)

  • HS256 JWT contract: algorithm pin, signing-key sourcing, lifetime cap, clock skew, 8 KiB token size cap, required + optional claims.
  • Capability table mapping every scope (endpoints:read|write|test, attempts:read) to the routes it grants.
  • Per-app CORS rules: no wildcards, https-only outside Development, RFC 6454 case-insensitive matching, preflight semantics, deny semantics (403 with no CORS headers).
  • Rate limit: shares the public API's send-by-appid partition.
  • Cross-tenant isolation: 404 PORTAL_NOT_FOUND (not 403, to avoid leaking other-tenant resource existence).
  • All 10 portal routes (List / Get / Create / Update / Delete / Enable / Disable / Test / Attempts / Event Types) with request/response shape.
  • 5 dashboard portal-admin routes with the secret-reveal-once contract.
  • Portal-specific error code table (9 codes).
  • End-to-end probe: server-side jose (Node.js) mint snippet → cURL list/test.

docs/ARCHITECTURE.md §4.3 — Portal Token Authentication (+46 lines)

  • Per-application secrets on Application (PortalSigningKey, AllowedPortalOriginsJson, PortalRotatedAt).
  • Pipeline ordering with the three invariants it encodes:
    1. ApiKeyAuth deliberately bypasses portal paths.
    2. PortalTokenAuth runs before PortalCors for non-OPTIONS requests.
    3. Both portal middlewares run before the rate limiter so the JWT-derived AppId is in HttpContext.Items when the limiter resolves its partition.
  • PortalLookupCache: 60 s TTL, instant local invalidation on dashboard mutating actions, atomic per-app CTS swap (regression for the Tur 1 fix).
  • Cross-tenant isolation via 2-arg GetByIdAsync(appId, …).
  • JWT validator defense-in-depth: HS256 pin, 8 KiB token cap, MapInboundClaims=false, hard exp - nbf cap, opaque error bodies.

Test plan

  • No code changes — docs only.
  • CHANGELOG entry under Unreleased > Documentation.
  • Cross-link from docs/API.md §3.8 → docs/PORTAL.md (existing host integration guide).
  • CI green on PR.

Follow-ups (tracked, not in this PR)

  • Tur 4: P1 behavior fixes (CORS deny-cache, validator merge, PUT→PATCH, disable preserves origins).
  • Pre-B1 backlog: DOCKERHUB_TOKEN scope fix + ~50 lines redundant comment cleanup.

…it doc-drift)

Closes the documentation half of the v0.2.0 portal audit. Tur 1 (#101)
was the security fix, Tur 2 (#102) was the test coverage; this PR is
the doc drift the same audit surfaced.

docs/API.md §3.8 — Portal API (Customer-Facing JWT):
- HS256 JWT contract: algorithm pin, signing key per-app, lifetime
  cap, clock skew, token size cap, required + optional claims.
- Capability table: endpoints:read|write|test, attempts:read.
- Per-app CORS rules: no wildcards, https-only, RFC 6454 case-
  insensitive matching, preflight semantics.
- Rate limit: shares send-by-appid partition; cross-tenant lookups
  return 404 (never 403, which would leak existence).
- All 10 portal routes documented with request/response shape.
- 5 dashboard portal-admin routes documented.
- Portal-specific error code table.
- End-to-end probe with jose (Node.js mint) + cURL.

docs/ARCHITECTURE.md §4.3 — Portal Token Authentication:
- Per-application secrets stored on Application (PortalSigningKey,
  AllowedPortalOriginsJson, PortalRotatedAt).
- Pipeline ordering with the three invariants it encodes (ApiKeyAuth
  bypass, PortalToken-before-PortalCors, both-before-RateLimiter).
- PortalLookupCache: TTL, instant local invalidation, atomic CTS swap.
- Cross-tenant isolation via 2-arg GetByIdAsync.
- JWT validator defense-in-depth (HS256 pin, 8 KiB token cap,
  MapInboundClaims=false, lifetime cap, opaque error bodies).
@voyvodka voyvodka added documentation Documentation improvements api API layer and endpoints labels May 11, 2026
@voyvodka voyvodka enabled auto-merge (squash) May 11, 2026 08:05
@voyvodka voyvodka merged commit 870cb03 into main May 11, 2026
7 checks passed
@voyvodka voyvodka deleted the docs/portal-api-architecture branch May 11, 2026 08:08
voyvodka added a commit that referenced this pull request May 11, 2026
…alidator merge, PUT→PATCH, disable preserves origins) (#104)

Closes the four P1 behaviour findings from the v0.2.0 portal audit. Tur 1
(#101) shipped the P0 security fixes, Tur 2 (#102) shipped the test
coverage, Tur 3 (#103) shipped the docs; this Tur 4 closes the behaviour
delta.

1. PortalCorsMiddleware deny-cache.
   HandlePreflightAsync now caches both allow and deny outcomes via
   IMemoryCache for the same TTL as the per-app signing-key lookup
   (default 60s). Browsers don't cache rejected preflights, so every
   OPTIONS from a disallowed origin used to re-scan the portal-enabled
   app set + deserialize the JSON allowlist — a free DB hammer vector
   for any caller that knew the portal prefix. Cache key is
   lowercased-origin scoped (RFC 6454 §4 case-insensitive). New test
   Preflight_Deny_Decision_Is_Cached_Within_Ttl pins the behaviour by
   mutating the DB to allow the origin after a 403 and asserting the
   second call still 403s within the TTL window.

2. EndpointValidationRules helper consolidation.
   New extension methods (EndpointUrlSyntax, EndpointDescription,
   EndpointTransformExpression, EndpointCustomHeaders,
   EndpointAllowedIpsCidrs, EndpointSecretOverride) become the single
   source of truth for the field-shape rules shared between the 4
   admin endpoint validators and the 2 portal endpoint validators.
   Without this consolidation, tightening a rule on one surface
   silently leaves the other surface weaker — exactly the drift
   pattern the audit flagged. Async DNS host-safety check stays in
   each validator (DependentRules + CustomAsync needs the full
   property selector). Behaviour unchanged.

3. PortalEndpointsController.Update → [HttpPatch].
   The action's body semantics were always partial-replace (every
   field optional, only non-null fields applied) — that's PATCH, not
   PUT. Switching the verb aligns the wire surface with reality and
   stops misleading REST consumers that expect PUT to be full-replace.
   Route, request shape, response shape unchanged. Two existing tests
   ported from PutAsJsonAsync to PatchAsync + JsonContent.Create.

4. DashboardPortalController.Disable preserves origins.
   Removed the line that nulled AllowedPortalOriginsJson on disable.
   Disable now revokes only the auth surface (PortalSigningKey,
   PortalRotatedAt) and keeps the operator-curated CORS allowlist so
   re-enable doesn't force re-curation. Explicit clear path remains:
   PUT /portal/origins with {origins: []}. Renamed test
   Disable_Clears_SigningKey_And_Origins → Disable_Clears_SigningKey_But_Preserves_Origins
   with assertion flip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api API layer and endpoints documentation Documentation improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant