docs(portal): add API reference and architecture sections (v0.2.0 audit doc-drift)#103
Merged
Merged
Conversation
…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).
4 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdanddocs/ARCHITECTURE.md.What's added
docs/API.md§3.8 — Portal API (Customer-Facing JWT) (+195 lines)endpoints:read|write|test,attempts:read) to the routes it grants.403with no CORS headers).send-by-appidpartition.404 PORTAL_NOT_FOUND(not 403, to avoid leaking other-tenant resource existence).jose(Node.js) mint snippet → cURL list/test.docs/ARCHITECTURE.md§4.3 — Portal Token Authentication (+46 lines)Application(PortalSigningKey,AllowedPortalOriginsJson,PortalRotatedAt).ApiKeyAuthdeliberately bypasses portal paths.PortalTokenAuthruns beforePortalCorsfor non-OPTIONSrequests.AppIdis inHttpContext.Itemswhen 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).GetByIdAsync(appId, …).MapInboundClaims=false, hardexp - nbfcap, opaque error bodies.Test plan
docs/API.md§3.8 →docs/PORTAL.md(existing host integration guide).Follow-ups (tracked, not in this PR)
DOCKERHUB_TOKENscope fix + ~50 lines redundant comment cleanup.