Skip to content

Latest commit

 

History

History
825 lines (712 loc) · 51.7 KB

11-security.md

File metadata and controls

825 lines (712 loc) · 51.7 KB

11 — Security Standards

Status: Active Derives from: ADR-0003 Tenant Isolation Defense in Depth (Amendment 1: Organization Scope; Amendment 3: corrected RLS template, database role model, and session-variable placement), ADR-0004 Authentication Strategy (Amendment 1: learnstack-hub realm), ADR-0015 API Gateway: APISIX, ADR-0017 Tenant + Organization Hierarchy, ADR-0036 Tenant Resolution and Trusted Inputs (the resolution matrix and the authority ceiling), ADR-0040 The Ambient Unit of Work (§ Tenant Context's closed setter set), ADR-0019 LearnStack Hub, ADR-0020 Triple Deployment + Hybrid License, ADR-0033 Audit Durability Model, ADR-0034 Hub Contract Surface Invariant, ADR-0035 Demand-Gated Infrastructure, ADR-0044 The Audit Write Path (the audit row's tenant, capture, redaction, and the append-only layers). Public-read additions derive from ADR-0052; the private web ingress/caller and anonymous visitor budgets derive from ADR-0053; coordinated accounting, native admission and redirect/query rules derive from ADR-0054. Delivery/recovery additions derive from ADR-0046.

Producer announcement and common execution ownership derive from ADR-0057.

Security is layered. No single control is sufficient. The standards here apply to every PR.

Threat Model Summary

LearnStack defends against:

  • Cross-tenant data leakage (the highest-severity class).
  • Authentication bypass (token theft, weak session handling).
  • Authorization bypass (privilege escalation within a tenant).
  • Code injection (SQL, XSS, command, deserialization).
  • Webhook spoofing.
  • File-upload abuse (malware, polyglot files, path traversal).
  • Denial of service (rate, payload size, expensive queries).
  • Sensitive data exposure (logs, errors, exports).

OWASP Top 10 (2021) Coverage

Every security review walks this lens before signing off.

OWASP LearnStack control
A01 Broken Access Control 4-step auth order (auth → tenant membership → role/permission → resource scope); [Authorize(Policy = ...)] on every endpoint; RLS as defense in depth.
A02 Cryptographic Failures TLS 1.2+; secrets in secret manager; Keycloak-managed credentials; no handwritten crypto.
A03 Injection EF Core LINQ + parameterised raw SQL; React auto-escape; CSP nonces; DOMPurify wrapper for dangerouslySetInnerHTML.
A04 Insecure Design Domain invariants in aggregates, not controllers; cross-module rules go through 01-architecture-standards.md § Distributed-Consistency Tiers.
A05 Security Misconfiguration Strict secure headers (HSTS, CSP, COOP, CORP); container hygiene; no public buckets.
A06 Vulnerable & Outdated Components Renovate/Dependabot; CVE patch SLA 7 days; locked install in CI.
A07 Identification & Auth Failures OIDC via Keycloak; PKCE only; HttpOnly cookies; MFA enforcement per role.
A08 Software & Data Integrity Failures Webhook HMAC verification; outbox + idempotency keys; signed images by digest.
A09 Logging & Monitoring Failures OpenTelemetry traces/metrics/logs; correlation id end-to-end; Sentry on errors; audit log on privileged ops (18-audit-coverage.md).
A10 SSRF Outbound calls allow-listed; rendered outbound URLs validated against an allow-list before emit.

Multi-Tenant + Organization Isolation Review Checklist

A PR that touches tenant- or organization-owned data, queries, or background jobs must answer yes to each item, or attach a written justification:

  • Every new tenant-owned entity is [TenantOwned] and has both an EF query filter and a Postgres RLS policy.
  • Every new organization-scoped entity carries OrganizationId (nullable when the entity may be tenant-wide) and has an org-aware EF query filter + RLS policy per ADR-0017. The architecture test Every_OrgScoped_Entity_HasOrgIdAndFilter checks the marker.
  • No IgnoreQueryFilters() outside the audited EnterPlatformAdminScope(reason) call path.
  • Every background job payload carries TenantId (and OrganizationId? when relevant); the worker sets ambient tenant + org before any work.
  • Every integration event payload carries tenant_id (and organization_id? when relevant); consumers restore tenant + org context before handling.
  • Raw SQL queries (if any) include tenant_id (and organization_id when applicable) in the predicate explicitly.
  • Cache keys, search index names/filters, storage prefixes, and metric labels all carry tenant id (and org id where applicable) — never as a high-cardinality metric label, see 10-observability.md.
  • At least one tenant-isolation integration test asserts that tenant A cannot read tenant B's data via the new surface, and that org X cannot read org Y's data within the same tenant where the entity is org-scoped.

Transport

  • TLS everywhere. HTTP redirects to HTTPS at the edge.
  • HSTS enabled (max-age=31536000; includeSubDomains; preload).
  • Minimum TLS version: 1.2; prefer 1.3.
  • Certificates auto-renewed via Let's Encrypt or cloud-managed certs.

HTTP Headers

Every response from the API and the rendered apps must set:

Header Value
Strict-Transport-Security max-age=31536000; includeSubDomains
X-Content-Type-Options nosniff
X-Frame-Options DENY (except embeddable LTI surfaces)
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy restrict to required APIs only (camera, microphone for classroom routes)
Content-Security-Policy strict with nonces; documented per app surface
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-site

Authentication

LearnStack runs two Keycloak realms per ADR-0004 Amendment 1:

  • learnstack — the tenant-facing realm. All tenant admins, instructors, and learners authenticate here. The realm includes the tenant id in token claims; the API re-validates against the resolved tenant context.
  • learnstack-hub — the operator realm, used only by the Hub operator portal (hub.learnstack.dev). Tenant-facing apps never accept learnstack-hub tokens; the internal API (/api/internal/*) only accepts learnstack-hub tokens plus the mTLS client certificate.

General rules:

  • OIDC. No handwritten password code; no handwritten token rotation.
  • Refresh tokens stored as HttpOnly, Secure, SameSite=Lax cookies; never accessible to JS.
  • Access tokens short-lived (≤ 1 hour) and refreshed silently by the BFF.
  • MFA enrollment supported for tenant-admin roles; required for platform-admin and for every Hub operator account.
  • Password policy delegated to Keycloak: minimum 12 chars, breach check via HIBP, no password reuse for the last 5.
  • Account lockout after 5 failed attempts in 10 minutes; lifted after 15 minutes or admin unlock.

Ingress / Gateway (APISIX)

All tenant-facing traffic enters through APISIX in standalone mode per ADR-0015. The gateway is a defense-in-depth layer, not the sole control — the API re-verifies everything.

When this becomes live. APISIX is demand-gated per ADR-0035: its trigger is the first non-development deployment that needs edge rate limiting, host routing, or JWT pre-validation, and it lands in Phase 11. Until then ASP.NET middleware carries the same responsibilities in-process. Nothing in this section is optional once the gateway is in front of production traffic; none of it is a reason to weaken the in-process control, because the API re-verifies either way.

  • jwt-auth plugin verifies the access token against the learnstack realm's public key set; the API also verifies. Both must pass.
  • cors plugin handles preflight; authenticated cross-origin traffic is allow-listed per environment.
  • limit-req / limit-count plugins enforce the rate-limit policy below.
  • The /api/internal/* route set is gated by mTLS configured on the APISIX SSL object (client.ca + client.depth per APISIX 3.x SSL-config; mTLS is not a route plugin) plus an ip-restriction (and, when applicable, consumer-restriction) route plugin that only admits the documented Hub egress; the client certificate must be signed by the LearnStack-internal CA. The route-level pattern is shown in the commented /api/internal/* stub in infra/apisix/apisix.yaml.
  • Gateway config lives in infra/apisix/ as YAML, version-controlled. No live edits.

Direct ingress to backend pods (bypassing APISIX) is blocked at the network policy level.

Hub Contract Surface

The LearnStack ↔ Hub API is a separate, narrow surface with stronger controls than tenant-facing endpoints. Per ADR-0019 and ADR-0034:

  • mTLS with LearnStack-internal CA-signed client certs. Certs are rotated yearly.
  • Signed JWT (RS256) carrying iss, aud=learnstack-internal, exp ≤ 5 min, jti (replay-protected via short-TTL inbox).
  • HMAC body signature in the X-Signature header (HMAC-SHA256 of the raw body with a per-deployment shared secret).
  • All three layers must validate on every endpoint in the surface, but they fail at two different layers. /api/internal/* is bound to its own mTLS listener and is never proxied by APISIX, so a missing, expired or untrusted client certificate is rejected during the TLS handshake — no HTTP request reaches the application, so there is no status code and no body to leak. Only the JWT and HMAC checks return 401, with no detail. Do not write a handler that returns 401 for a certificate failure; it would never be reached, and its existence implies a listener that terminates TLS without requiring the client certificate.
  • The surface is governed by two invariants, not by an endpoint count: the Hub stores no tenant content, and every crossing goes through IEntitlementProvider, IUsageReporter, or IHubTenantSync. The enumerated endpoint set lives in 20-infrastructure-stack.md § Hub HTTPS Contract Surface; adding an endpoint still requires an ADR, because the surface is a cross-repository contract.
  • TLS certificates and private keys never travel in the entitlement payload. That payload is cached, logged, audited and mirrored. Cert material moves by secret-store replication and is referenced from PUT /api/internal/tenants/{id}/host-mappings by path, never by value (ADR-0034).
  • Host resolution never calls the Hub. IHostToTenantResolver reads platform_host_to_tenant and nothing else, so a Hub outage cannot take anonymous public pages down.

Authorization

Every write use case checks, in order:

  1. Authentication (valid token).
  2. Tenant membership (user has a Membership in the resolved tenant).
  3. Role / permission (user's roles include the required permission).
  4. Resource scope (e.g. instructor can only edit their own courses).

A failure at any step returns a Problem Details response with the right code (unauthorized, tenant_mismatch, forbidden, resource_scope_violation).

Tenant + Organization Isolation

See docs/architecture/09-tenant-isolation.md for the full strategy. Standards-side:

  • Every [TenantOwned] entity has a query filter and an RLS policy. Architecture tests enforce this.
  • Every [OrganizationScoped] entity additionally carries an OrganizationId column and an org-aware EF filter + RLS policy (ADR-0017). Nullable OrganizationId means the row may be tenant-wide.
  • IgnoreQueryFilters() is allowed only inside the audited EnterPlatformAdminScope(reason) call path, which is itself the record of the access: the scope writes its security-event row through IAuditStore.WritePlatformScopeAsync on its own platform-role connection before the operation runs, and commits it on its own transaction rather than the scope's — which an abandoned frame rolls back — so an operation that later fails is still recorded (ADR-0044 § 10, 05-database.md § How EnterPlatformAdminScope(reason) reaches learnstack_platform). The architecture test No_IgnoreQueryFilters_Outside_PlatformAdminScope is a path check: no per-call-site attribute or comment marker exempts a call (09-tenant-isolation.md § Platform admin access, 21-architecture-tests-catalogue.md).
  • Background jobs must receive TenantId (and OrganizationId?) in their payload. From Phase 02b, which ships Hangfire, a job enqueued without it fails at enqueue time (Hangfire_Job_Payloads_Include_TenantId).
  • The app.tenant_id and app.organization_id session variables are set with SET LOCAL inside the ambient transaction — see § Tenant Context immediately below, which is the single authority for that placement.

Confined event dispatcher

ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; both review rounds are complete. Step 3 implements mandatory worker startup resolution and connectivity checks; both independent review rounds are complete. Step 4 credential/publication enforcement and local closeout are complete; all four steps completed both independent review rounds. The merge closeout owns CI/merge status. The named keyed, lazy dispatcher source belongs only to the API host. Configuration validates the exact username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical connection verifies exact session/current learnstack_outbox_admin, BYPASSRLS, NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION and no outgoing role membership or incoming membership by a non-superuser, directly or transitively. Superusers inherently reach every role; this check detects grant drift on a new physical connection, not continuous revocation of an existing session. No Seeder/web forwarding or shared app credential is allowed. Database Standards owns exact status grants and audit INSERT-only; no business access or audit read, update or delete is admitted.

Routine confined poll/claim/mark receives ADR-0046's bounded audit exception; terminal state is MUST/SecurityEvent and atomic with its row-bound witness audit. INSERT-only and GUCs cannot tenant-bind a bypass credential. Real mismatch controls must roll back terminal state and audit together. Existing platform-admin entry audit and gate stay unchanged. P02b-3 Steps 3–4 implement separate app-role business scopes, restore validated tenant/organization and announce RLS context before reading the envelope and constructing a handler. The subscriber scheduler shares the confined source for enumerated routing/status columns; it receives no payload, causal-envelope or inbox SELECT privilege. The ordinary executor refuses privileged subscriptions until their Phase 03 execution contract exists.

Tenant Context

This section is the single authority for RLS session-variable placement. Every other document links here rather than restating the mechanism.

Row Level Security predicates read four PostgreSQL session variables — app.tenant_id, app.organization_id, app.scope, and app.resolving_host — whose canonical spellings and canonical policy templates live in 05-database.md § Tenant-Owned and Organization-Scoped Tables and 05-database.md § Table classes. This section fixes where the first three belong. app.resolving_host is set by CachedHostToTenantResolver alone, in its own short read-only transaction before the host lookup, because the row that determines the tenant must be readable before any tenant context exists; it is read by exactly one policy, on platform_host_to_tenant. Its value is the normalized effective host as EffectiveHost.Normalize produces it — the single producer of both this variable and the lookup key, per ADR-0036 § Effective host and the trusted hop. That ADR, not this section, is the authority for which request input the host comes from; this section remains the authority for session-variable placement only.

The rule

app.tenant_id and app.organization_id are announced inside the ambient transaction by the step-6 TransactionBehavior adapter through TransactionExecutionCoordinator, from the context admitted at step 4. Default ReadWrite transactions announce them as their first SQL statement.

For a physical PublicSurface ReadOnly owner only, ADR-0052 and ADR-0040 Amendment 8 authorize one predecessor: begin READ COMMITTED, execute and await SET TRANSACTION READ ONLY, then return the owner frame. The existing tenant/ organization setter follows before any data SQL or handler dispatch. Mode setup alone never stamps the tenant-announced marker. If setup fails or is cancelled, Begin cleans up the partial transaction and marks rollback-only before rethrowing. Mode resets per transaction; poisoning remains sticky for the unit's lifetime.

Every institution public read dispatches a marked request through ISender, audit Off and GET/HEAD only. Factory HostScope is an independent host ceiling intersected with normal reconciliation, query filters and RLS; it cannot widen through claims or the tenant-scope read hatch. Publication, access and lifecycle remain explicit application predicates. The four context writers and eight tenant setters are unchanged. Host resolution, flag-loader connections and rejected-assertion audit keep their separately sanctioned paths.

READ ONLY protects the enlisted ambient transaction. Structural guards detect controller/helper/independent-connection bypasses; real app-role HTTP/database proofs are required. No public-data endpoint may use a direct persistence path.

SET LOCAL and set_config(name, value, true) are transaction-local. PostgreSQL discards them when the transaction ends, and they have no effect at all outside one. Two consequences follow, and both are load-bearing:

  • Not from a MediatR behavior that runs before TransactionBehavior. A value set at step 4 is gone before the step-6 transaction opens, so every subsequent query runs with an unset app.tenant_id.
  • Not from a DbConnectionInterceptor. Interceptors fire when the connection opens, not when a transaction starts. Under PgBouncer transaction pooling the connection is shared across transactions, so the value is either absent or — worse — left over from another tenant's transaction.

app.scope has no carrier. No application path sets it, and Packet 7 ships nothing that does: ITenantContext carries no app.scope authority member; ADR-0052's immutable HostScope is an independent narrowing ceiling, never a scope-widening carrier (ADR-0040 Amendment 1), and the flag derives from the actor's role plus a declared tenant-wide operation, and the role is the part that does not exist yet. Phase 02b delivers the authenticated principal the carrier needs and says in its own scope that it delivers "only the authentication plumbing"; Membership, Role and Permission land in Phase 03, which is therefore the earliest phase that can own a working carrier. The deferral is forced, not chosen. Until it lifts, the cross-organization read hatch in the policy template is unreachable at runtime — the hatch term reads an unset variable and is never true, so reads stay inside the caller's organization plus the tenant-wide rows — which is the correct default. The placement rule above is unchanged and governs app.scope the moment a carrier exists.

P02b-1 Step 1 adds a read-only announcement snapshot through IUnitOfWork.GetTenantContextAnnouncement(DbTransaction?), under ADR-0057. Its generation/normalized values describe successful SQL on the current physical transaction. It is cleared before attempts and terminal transitions. Foreign transactions and failed/no-tenant announcements supply no usable snapshot; unresolved ambient provisioning still uses its sanctioned announcement. Frames compare the live snapshot with their enrolled generation. This adds no setter, context writer, factory privilege or tenant retargeting authority.

The out-of-band setters

TransactionBehavior delegates the general announcement path to TransactionExecutionCoordinator. Eight logical setter paths exist in total and the set is closed (ADR-0040 § Who sets app.tenant_id, completely, with its Amendments 3 and 7, is the authority; it is reproduced here because this section is the placement authority). Two set it on the ambient transaction; six own a short transaction of their own, because they run where no ambient transaction exists yet:

Setter Transaction Why it is not TransactionBehavior
TransactionBehavior → TransactionExecutionCoordinator ambient — the existing general announcement path; common execution adds no authority
The integration-event transport, per delivery ambient — it opens it DeliveryExecutor uses the common transaction owner and validated retained envelope before restoring EventTenantContext, claiming inbox and constructing the handler. InProcessEventBus owns durable acceptance only; nested commands join the prepared business transaction
IOrganizationScopeValidator its own short read-only one The organization assertion is validated in the request edge, before the pipeline reaches step 6 (ADR-0036)
IIdempotencyStore (durable) its own short one A claim is taken before the pipeline reaches step 6 (ADR-0037)
IAuditStore.WriteStandaloneAsync its own short one An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction (ADR-0033)
IAuditStore.WriteBestEffortAsync its own short one Same shape, SHOULD/MAY class; failures are logged and dropped
The AuditConfig override loader its own short read-only one An out-of-band cached projection, never a request-path query
The tenant-flag loader (FeatureFlags) its own short read-only one A cached projection of tenant_feature_flags, read on a cache miss wherever IFeatureFlags is asked — inside a request's transaction or outside any (ADR-0045 § 2, ADR-0040 Amendment 7)

IOrganizationScopeValidator is registered and has no reachable caller yet. Its only non-vacuous caller is the reconciliation matrix's row 7, which needs a validated claim, and there is no UseAuthentication until Phase 02b — so no request can reach the call. The assertion path ADR-0036 § What the assertions do names as its caller is subsumed by TenantAssertionMiddleware, which refuses on any difference between the asserted and the resolved value before belonging can matter. This table lists it because the set of setters is closed and a closed set is worth stating whole; it is not evidence that a seventh short transaction runs on any Packet 7 request path.

P02d-2 closes G15 without a new setter. Seed ownership verification now sends module-owned contextual ISender queries in fresh trusted scopes; its old private transaction and direct SetTenantContextAsync call are removed. The caller fence and planted companion reject direct calls, method groups, private transactions, accessor assignment and announcing SQL. G22/G23's projection loader and settings reader decisions remain owned by P02d-3; this change admits no out-of-band reader.

Both audit writers announce both variables. WriteStandaloneAsync and WriteBestEffortAsync issue app.tenant_id and app.organization_id from the draft, as the first statements of their short transaction, because audit_log is organization-scoped: a row whose organization_id is non-null while the organization variable is unset fails WITH CHECK (ADR-0033 Amendment 2 § 5, ADR-0044 § 9).

Every one of the eight setters connects as learnstack_app. A setter that reached for learnstack_platform would be invisible to the isolation suite, which is the failure mode ADR-0003 names by hand.

Because every current_setting read is called with its missing-OK argument (true) and wrapped in NULLIF(…, ''), both an unset and a reset variable yield NULL and the policy predicate filters the row out. The failure mode is an empty result set, not a leak — but an empty result set arriving from production is an outage, so TenantContextGuardInterceptor asserts that a sanctioned setter has already issued the SET LOCAL pair on this transaction before any command a module DbContext issues runs on it.

Keyed on the transaction, not on the table, and marked through the ambient unit's announcement seam. Matching [TenantOwned] table names would put a parser between every query and the database to decide something the transaction already answers. Of the eight logical paths, the MediatR transaction owner and P02b-3's integration transport mark the ambient transaction through sanctioned IUnitOfWork announcement methods. Durable acceptance and prepared subscriber execution both announce before their first app-role command; the production call-site census closes these implementations without adding a ninth logical setter. The durable idempotency store remains unimplemented. The five other paths, OrganizationScopeValidator, the two standalone audit writers and the two cached-projection loaders, issue raw NpgsqlCommands on connections of their own, which EF interception never sees. CachedHostToTenantResolver is not in this set at all: it sets app.resolving_host, not app.tenant_id. That is also why the exemption list is empty, and why PlatformAdminScope, whose BYPASSRLS connection announces no tenant by design, is invisible to the guard by construction rather than by a hand-written exception. The concern the earlier wording had — that naming TransactionBehavior alone would reject legitimate transport and audit writes, and the future idempotency store's writes on its own short transaction — is real, and is answered by the guard reading a marker rather than a behavior's name: a setter marks the transaction it opens if and only if it reaches EF. The audit store's writers have landed and reach none; the durable idempotency store will mark its own if it does. It throws TenantContextMissingException when it has not, which Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction asserts in both directions. Packet 7 owns it: Packet 6 ships the setter (IUnitOfWork.SetTenantContextAsync) and the policies it will back up, and the first tenant-owned read on a request path is Packet 7's, which is where the guard belongs. It cannot be a connection-checkout interceptor, for the same reason it cannot be a DbConnectionInterceptor that sets the values: checkout precedes the transaction, so the transaction-local value is not there to be observed (05-database.md § Connection Management).

Corrections this supersedes

Six other places previously described different placements. All are corrected; if a stale copy surfaces, this section wins:

Document Previously said Now
02-backend-coding.md § Pipeline Behaviors Pipeline step 4 (TenantContextBehavior) sets the variables via a DbConnectionInterceptor Step 4 asserts and carries tenant context; step 6 sets the variables inside the transaction, and links here
Phase 02a Packet 3 TenantContextBehavior code TODO Names a DbConnectionInterceptor as the mechanism Corrected in Phase 02a Packet 3b to point here and at Packet 7, which implements it
33-cross-cutting-concerns.md § Pipeline Step 4 sets the RLS GUCs via a DbConnectionInterceptor Step 4 asserts only; step 6 issues SET LOCAL inside the transaction
ADR-0032 § Sub-decision 2 diagram TenantContextBehavior (assert resolved; set RLS GUC) Corrected in that ADR's Amendment 2, item 3
09-tenant-isolation.md § Isolation flow mermaid The accessor issues SET LOCAL from middleware, before any transaction exists The transaction issues it at step 6
Phase 02a § Cross-cutting Concerns TenantContextBehavior (asserts resolved + sets RLS GUCs) Asserts only; TransactionBehavior sets them

Neither error was visible in review because the corpus described the layers correctly while describing the ordering wrongly, and no code exercised the ordering yet. The implementation lands in Phase 02a Packet 7.

Related authorities

A test that connects as the table owner or as a BYPASSRLS role passes even when every policy is inert. Isolation tests connect as learnstack_app; the suite is a Phase 02a Packet 7 deliverable.

Secrets and Configuration

  • Backend secret reads go through ISecretProvider. The registered implementation is ConfigurationSecretProvider until Vault's trigger fires — a production secret must rotate without a redeploy, or more than one operator needs access to production secrets — at which point DaprSecretProvider → Vault takes over per ADR-0038 and ADR-0035. Call sites are identical either way. .env.example is checked in; .env is gitignored.
  • Secret namespace: learnstack/{deployment}/{module}/{key}. The deployment segment is development | saas | dedicated | selfhosted.
  • Production secrets rotated at least every 90 days where rotation is feasible (DB passwords, provider API keys, Hub HMAC shared secret, mTLS client certs).
  • Secret access via ISecretProvider is logged.

ADR-0053's local Node launcher is a bounded exception to the .NET provider interface: the paired private local configuration supplies the ingress/API-hop secret without shell evaluation. It is never a NEXT_PUBLIC_* value, browser input or SDK default. This exception does not authorize a production secret-store adapter; Phase 11 owns the production ingress/secret lifecycle. See Infrastructure Standards.

Private public-rendering boundary

ADR-0053 owns the protocol; ADR-0054 replaces its accounting, native admission and redirect/query rules. These are the ongoing security obligations:

  • Only the native listener captures host/socket provenance. Strip client tenancy, forwarding, internal and framework-override carriers before Next; validate the signed envelope again before bootstrap and before the configured API caller.
  • The envelope, hop secret and visitor address never enter responses, client assets, retained logs or audit data. Framework debug output and test diagnostics are part of that exclusion. A marker or imported server-only module grants no tenant scope.
  • The configured caller uses its private loopback origin and builds exactly five application headers: Accept, X-LearnStack-Host, X-LearnStack-Hop-Secret, X-LearnStack-Visitor-Address and traceparent. The HTTP Host remains the API origin. Rebuild that set from verified context; never forward browser cookies, Authorization, tenant/organization assertions or proxy headers.
  • The complete headers/body operation has one 10-second deadline and an 8 MiB decoded-body bound, with no retries or redirects. Rendering and responses remain dynamic/no-store. The injected SDK owns no host, secret or global fetch default.

P02d-5 delivers this boundary and ADR-0054's accounting, native admission and URL controls. The remediation record owns execution, independent reviews and PR closeout evidence. Every native HTTP path, including matcher exemptions, admits GET/HEAD only; production upgrades close and development retains only validated GET HMR. See Frontend Standards § Native Admission and URL Identity for the native controls and their delivery/review boundary.

File Uploads

  • Validate MIME type with content sniffing, not just the Content-Type header.
  • Validate extension against an allow-list per content type.
  • Enforce per-content-type size limits. The numbers and the tenant-override rule are owned by 16-media-pipeline.md § Validation; three documents used to state them and disagreed three ways about the same image.
  • Strip EXIF where appropriate.
  • Store in tenant-scoped object storage prefix.
  • Never trust the original filename. Generate a server-side key under the canonical tenant prefix: tenants/{tenantId}/{category}/{uuid}.{ext} (with organizations/{orgId}/ segment for org-scoped assets), per 09-tenant-isolation.md § Storage (SeaweedFS) and 16-media-pipeline.md § Key Layout.
  • Virus scan hook (ClamAV or cloud equivalent) before files become accessible.
  • Signed URLs for private files; TTL ≤ 1 hour.

SQL & ORM

  • Use parameterized queries everywhere. No string interpolation into SQL.
  • EF Core LINQ is preferred; raw SQL only with explicit FromSqlInterpolated and parameterized values.
  • No string concatenation with user input.
  • Stored procedures are not used; the application owns the logic.

XSS & Output Encoding

  • React encodes by default — preserve that.
  • dangerouslySetInnerHTML only through a sanitization wrapper (DOMPurify) with a documented policy.
  • CSP nonces enforce inline-script restrictions.
  • Markdown rendered via a library with allowlist sanitization.
  • Email templates use a templating engine with HTML-escape default.

CSRF

  • Cookie sessions use SameSite=Lax.
  • Server Actions verify the Auth.js session implicitly.
  • Mutating fetches from JS include a CSRF token bound to the session.
  • Webhook endpoints verify signatures, not CSRF tokens.

CORS

  • Default deny.
  • Allowed origins explicit per environment.
  • Studio and Portal apps share a single origin with the API (no cross-origin requests needed).
  • Tenant custom domains use server-side rendering; client-side cross-origin to the API uses CORS preflight on a controlled allow-list.

Rate Limiting

Two layers, two jobs, two key spaces — and they are not the same numbers. The edge (APISIX) limits a noisy client to protect the platform, keyed on the remote address; its policy is owned by 30-api-gateway.md § 6 and is deliberately much looser per second. The table below is the application policy: fair use per principal, on whatever names one — a token where the caller is authenticated, the peer where it is not, the mTLS client certificate for the Hub surface, the provider account for a webhook. Only a principal key can express "this tenant's plan allows N", which is why the edge cannot own these rows. A request passes both layers or neither.

Until the gateway fronts the app (ADR-0035 gates it to Phase 11), the application enforces anonymous limits in process. Accepted P02d-5 policy — 2026-10-08. Derives from: ADR-0053. Use one canonical-IP quota for direct socket peers and authenticated-hop visitor addresses, plus a separate physical-peer ceiling. Neither forwarded headers nor a host/cookie creates a visitor partition. Trusted malformed visitor metadata spends the fallback budget, then receives masked 404 before lookup. P5 Step 2 implements both budgets and pre-lookup admission, with real-socket controls. This is local limiting, not distributed DDoS protection. NAT/local loopback callers share an IP quota; budgets count API calls.

ADR-0054 accounting implemented — 2026-10-09; both review rounds passed. Remediation Step 1 owns the actual visitor limiters, with one process-local owner lock across all visitor keys and physical peers. The lock serializes positive acquisition, creation and retirement; no network/database work, await or disposal runs under it. One-minute fixed windows, 60/min per canonical IP, 600/min per peer and no queue remain. No request retains a limiter removed by retirement.

  • Probe an existing visitor with zero permits. Its refusal returns unchanged Retry-After before any peer acquisition. Otherwise acquire the peer permit; peer refusal neither debits the visitor nor creates an unknown visitor limiter. Create an unknown visitor only after peer admission, then acquire one visitor permit. No competing debit/retirement separates the known-visitor probe and debit.
  • First refusal wins, including the visitor when both budgets are exhausted. Do not combine metadata, debit to obtain failure metadata, or use a partition lookup that allocates merely to check membership. Reuse successful and refused outcomes on framework retry of the same HTTP request; returned leases have independent lifetimes. Cancellation, Retry-After and disposal need explicit proofs.
  • Retire only after full-quota idle state lasts a complete window. One non-overlapping sweep visits the whole registry in bounded batches; removal shares the acquisition lock and disposal occurs outside it. Recreated state never restores unexpired quota. DI shutdown closes admission, joins the sweep and disposes every remaining limiter exactly once on both teardown paths.
  • The 600/min peer budget bounds successful peer acquisitions and new visitor allocations. It is not a fixed global cardinality cap or a bound on all attempts/refusal work. Known exhausted visitors can generate arbitrarily many 429 responses without spending peer permits; their path performs no registry sweep, new limiter allocation or host/database lookup. No unbounded parallel identity registry is permitted. Phase 11 owns upstream transport/edge protection and contention/load measurement; no measured throughput is claimed here.
Surface Limit
/api/v1/auth/* (login, password reset, register) 5 req/min per IP
Anonymous API 60 req/min per canonical IP + 600 req/min per physical peer (P5 Step 2)
Authenticated API 600 req/min per token
Write endpoints 60 req/min per token
Webhook endpoints 1000 req/min per provider
Hub internal API (/api/internal/*) 60 req/min per mTLS client cert

429 responses include Retry-After. The edge half runs on APISIX (limit-req / limit-count plugins); the application half runs in ASP.NET, where the plan-level API-rate limit — limits.api_rate_per_minute, in the Hub's key vocabulary, which LearnStack adopts (ADR-0045 Amendment 1, ADR-0021) — can differ per tenant and the edge has no way to know it.

Webhooks (Inbound)

  • HMAC signature verification before any work runs.
  • Reject events older than 5 minutes (replay protection).
  • Idempotency: (provider, event_id) stored; duplicates ignored.
  • Tenant id derived from the stored provider account, never trusted from the payload.

Logging Hygiene

  • Never log secrets, passwords, tokens, full card numbers, full national ids, or full email bodies.
  • Redact at log-builder level via a property filter.
  • PII-sensitive fields (email, phone) are hashed in analytics; logged in plain only when necessary, with explicit allow-list.
  • Tracing tags exclude Authorization headers.

Error Messages

  • Server-side messages explain what happened; client-facing messages avoid disclosure.
  • 404 used to hide cross-tenant existence.
  • Stack traces never reach clients.
  • Internal correlation id surfaced to clients to support back-channel debugging.

Dependency Hygiene

  • Renovate / Dependabot enabled. Not yet: no update bot is configured, and Phase 11 enables one.
  • Critical / high vulnerabilities patched within 7 days. The backend's restore reports an advisory of any severity anywhere in the graph, transitive packages included, and CI fails on it (NuGetAuditMode all, NuGetAuditLevel low, warnings as errors under CI) — so the seven days are a deadline for a fix, never for noticing.
  • Lockfiles committed — frontend/pnpm-lock.yaml today. The backend has no NuGet lock files yet; they arrive in Phase 11.
  • No transitive dependency mismatch — pnpm install --frozen-lockfile in CI today, and dotnet restore --locked-mode once the backend has lock files to lock to (Phase 11).

Container & Infrastructure

  • Base images pinned by digest.
  • Run as non-root.
  • Read-only filesystem where possible.
  • Drop unnecessary Linux capabilities.
  • Image vulnerability scan in CI (Trivy or equivalent).
  • Cluster ingress restricted to documented ports.

Live Classroom Specifics

  • Join tokens scoped per user + room + role; TTL ≤ 1 hour.
  • Identity includes tenant id ({tenantId}:{userId}) to prevent room-name collision across tenants.
  • Recording indicator shown to all participants when recording is active, regardless of who started it.
  • Recording consent enforced; see docs/architecture/16-media-pipeline.md.
  • LiveKit webhook secret rotated quarterly.

Audit Log

Every privileged operation writes an audit log entry, subject to the confined routine dispatcher exception above, per ADR-0033 Audit Durability Model (supersedes ADR-0016), ADR-0044 The Audit Write Path and the 18 Audit Coverage Standard. Coverage is MUST / SHOULD / MAY per module-operation; rows are written only through IAuditStore — modules never write audit_log directly. Entries are append-only and queryable by tenant admins for their own tenant (org-admins for their org).

Security-relevant durability rules:

  • MUST-class audit fails closed. Its rows — one per audited (resource, operation), not one per request — are written on the same transaction as the business write: AuditLogBehavior classifies and parks the intents through AuditExecutionCoordinator, and TransactionExecutionCoordinator calls IAuditStore.WritePendingAsync on the owning unit-of-work frame, immediately before COMMIT, draining every intent in the scope (ADR-0044 §§ 3–4). So a privileged operation cannot commit unaudited. It also means the insert runs while app.tenant_id is set, which is what lets Row Level Security accept it. "The same DbContext.SaveChanges" was the earlier formulation and ADR-0033 withdrew it: the guarantee is the transaction, which is what a reader of audit_log observes and which needs no cross-context machinery. Exactly two failures reject the operation — an operation the catalogue does not classify (audit_unclassified_operation, 500) and a MUST-class row that cannot be written durably (audit_unavailable, 503).
  • A standalone MUST-class write failure follows the operation's own outcome. A standalone row recording an access that is being granted still rejects with 503 audit_unavailable — without it, data leaves the system unaudited. A standalone row recording an operation that is already being refused — a denied authorisation outcome, a rejected tenant assertion — keeps its own 403 / 404, because nothing proceeds unaudited and downgrading the refusal would tell an anonymous caller more, not less. Either way the failure logs at Critical, increments the standalone-write-failure counter and marks the audit health check unhealthy (ADR-0033 Amendment 1). The in-transaction class above is untouched by this.
  • A failure to read a tenant's AuditConfig overrides does not reject. Classification falls back to the in-process catalogue, which carries the same MUST floor, so nothing proceeds unaudited — and denying every request platform-wide because one cached projection is unreachable is the worse compliance outcome. The failure logs at Error; the audit health check does not move, because it answers only whether a MUST-class row can be written (ADR-0033 Amendment 6). A tenant override may narrow SHOULD/MAY coverage; it may never remove baseline MUST coverage (ADR-0033 § Fail-closed, stated precisely; asserted by Audit_Classification_Does_Not_Read_The_Database_On_The_Request_Path in 21-architecture-tests-catalogue.md, which observes that a MUST is answered without the loader ever being asked for a connection, alongside AuditConfigServiceTests and AuditLogBehaviorTests).
  • Snapshots are redacted at capture and bounded there. AuditChangeTrackerInterceptor captures every Added / Modified / Deleted entry minus a named exclusion list, and two gates run before anything is parked: a property marked [PiiSensitive] or matched by the shipped SensitiveTokenCatalog's name tokens has its value replaced with the redaction sentinel — the property is kept, so the diff still records that it changed — and each of before_state, after_state and changes is capped at JsonValue.MaxRowBytes (256 KiB), above which the value becomes an explicit elision record ({"_elided": true, "bytes": …, "sha256": …}) that preserves the column's JSON type. Never a silent truncation and never a dropped field (ADR-0044 §§ 7–8).
  • audit_log is tenant-owned and organization-scoped; audit_config is tenant-owned and tenant-wide — it has no organization_id, so it carries the tenant term only and no restrictive write guards (ADR-0044 Amendment 1). Both take the canonical RLS template for their class from 05-database.md § Table classes unmodified — it is not repeated here, or anywhere else. A tenant admin's read is bounded by the policy, not by the query. audit_log carries no foreign key to tenants: the record of what happened to a tenant has to outlive the tenant.
  • Append-only holds in three layers, and each stops a different actor. learnstack_app holds SELECT, INSERT only and is stopped by the absent privilege; learnstack_platform holds UPDATE on the redaction columns alone — any other column is refused by the column-level grant, before the trigger runs — plus the retention purge DELETE; and the table owner is stopped by neither, because it holds every privilege implicitly and, under FORCE, the policy constrains it by tenant rather than by immutability — measured, an owner UPDATE returns UPDATE 0 with no tenant announced and UPDATE 1 with one, so audit_log_append_only_guard is the one layer binding learnstack_migration. GDPR redaction and retention purge are the only sanctioned mutations, and IAuditStore has no update method (ADR-0044 § 9, 18-audit-coverage.md § Storage; asserted by AuditLog_Update_Is_Column_Restricted).
  • SHOULD/MAY-class audit stays best-effort, and its accepted loss is written down rather than assumed.
  • Monthly partitioning and the retention job land in Phase 11 per ADR-0035 — audit correctness cannot be retrofitted, audit scale can. Phase 02a ships audit_log as a single plain table (ADR-0028 Amendment, 2026-09-07).

Incident Response

  • A security incident playbook lives in docs/runbooks/security-incident.md (Phase 11 deliverable).
  • The team rotates an on-call slot for production.
  • Critical incidents trigger pager + Slack channel; post-mortem within 7 days.

Forbidden

  • Storing passwords or password derivatives in any LearnStack table.
  • Storing third-party tokens in plain text — encrypt at rest if storage is unavoidable.
  • Trusting tenant_id or organization_id from a request body or query param. Both come from the resolved tenant context only (API Standards § Tenant Context), which an anonymous request resolves from its effective host.
  • Implementing custom crypto. Use established libraries.
  • Disabling RLS in production for any reason short of an investigated incident with an ADR.
  • Echoing user-supplied HTML without sanitization.
  • Logging full request/response bodies.
  • Calling Hub endpoints from anywhere except the dedicated IEntitlementProvider / IUsageReporter / IHubTenantSync adapters (see 20-infrastructure-stack.md).
  • Accepting learnstack-hub realm tokens on tenant-facing endpoints, or accepting learnstack realm tokens on /api/internal/* endpoints.
  • Bypassing APISIX with direct backend ingress.