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-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.
Security is layered. No single control is sufficient. The standards here apply to every PR.
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).
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. |
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 testEvery_OrgScoped_Entity_HasOrgIdAndFilterchecks the marker. - No
IgnoreQueryFilters()outside platform-admin code paths (Roslyn-allowlisted + audit-logged). - Every background job payload carries
TenantId(andOrganizationId?when relevant); the worker sets ambient tenant + org before any work. - Every integration event payload carries
tenant_id(andorganization_id?when relevant); consumers restore tenant + org context before handling. - Raw SQL queries (if any) include
tenant_id(andorganization_idwhen 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.
- 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.
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 |
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 acceptlearnstack-hubtokens; the internal API (/api/internal/*) only acceptslearnstack-hubtokens plus the mTLS client certificate.
General rules:
- OIDC. No handwritten password code; no handwritten token rotation.
- Refresh tokens stored as
HttpOnly,Secure,SameSite=Laxcookies; 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.
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-authplugin verifies the access token against thelearnstackrealm's public key set; the API also verifies. Both must pass.corsplugin handles preflight; authenticated cross-origin traffic is allow-listed per environment.limit-req/limit-countplugins enforce the rate-limit policy below.- The
/api/internal/*route set is gated by mTLS configured on the APISIX SSL object (client.ca+client.depthper APISIX 3.x SSL-config; mTLS is not a route plugin) plus anip-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 ininfra/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.
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-Signatureheader (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 return401, with no detail. Do not write a handler that returns401for 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, orIHubTenantSync. 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-mappingsby path, never by value (ADR-0034). - Host resolution never calls the Hub.
IHostToTenantResolverreadsplatform_host_to_tenantand nothing else, so a Hub outage cannot take anonymous public pages down.
Every write use case checks, in order:
- Authentication (valid token).
- Tenant membership (user has a Membership in the resolved tenant).
- Role / permission (user's roles include the required permission).
- 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).
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 anOrganizationIdcolumn and an org-aware EF filter + RLS policy (ADR-0017). NullableOrganizationIdmeans the row may be tenant-wide. IgnoreQueryFilters()is allowed only in platform-admin code paths with a Roslyn-allowlist attribute and an audit-log call.- Background jobs must receive
TenantId(andOrganizationId?) in their payload; jobs without it fail at registration. - The
app.tenant_idandapp.organization_idsession variables are set withSET LOCALinside the ambient transaction — see § Tenant Context immediately below, which is the single authority for that placement.
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 are set. 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.
app.tenant_id, app.organization_id and app.scope are set with SET LOCAL,
inside the ambient transaction, as the first statement after it opens — in practice by
TransactionBehavior (step 6 of the MediatR pipeline), from the ITenantContext that
TenantContextBehavior asserted at step 4.
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 unsetapp.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.
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 a
DbCommandInterceptor additionally asserts that TransactionBehavior has already issued
the SET LOCAL pair before any command against a [TenantOwned] table runs, and throws
TenantContextMissingException when it has not. 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).
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.
- ADR-0003 Amendment 3 — the
corrected RLS policy template, the four-role database model, and the rule that
isolation tests connect as
learnstack_app. - 05-database.md § Tenant-Owned and Organization-Scoped Tables — the single canonical SQL template. It is not repeated here, or anywhere else.
- 05-database.md § Connection Management — PgBouncer transaction-mode pooling, which this rule depends on.
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.
- Every secret read goes through
ISecretProvider. The registered implementation isConfigurationSecretProvideruntil Vault's trigger fires — secrets must rotate without a redeploy, or a non-development deployment exists — at which pointDaprSecretProvider→ Vault takes over per ADR-0014 and ADR-0035. Call sites are identical either way..env.exampleis checked in;.envis gitignored. - Secret namespace:
learnstack/{deployment}/{module}/{key}. The deployment segment isdevelopment | 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
ISecretProvideris logged.
- Validate MIME type with content sniffing, not just the
Content-Typeheader. - Validate extension against an allow-list per content type.
- Enforce per-content-type size limits (image: 10 MB, document: 50 MB, video: 5 GB).
- 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}(withorganizations/{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.
- Use parameterized queries everywhere. No string interpolation into SQL.
- EF Core LINQ is preferred; raw SQL only with explicit
FromSqlInterpolatedand parameterized values. - No string concatenation with user input.
- Stored procedures are not used; the application owns the logic.
- React encodes by default — preserve that.
dangerouslySetInnerHTMLonly 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.
- 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.
- 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.
| Surface | Limit |
|---|---|
/api/v1/auth/* (login, password reset, register) |
5 req/min per IP |
| Anonymous API | 60 req/min per IP |
| 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. Rate-limit policy lives at APISIX
(limit-req / limit-count plugins) plus a per-handler ASP.NET layer for finer grain
where plan-level LimitKeys.MaxApiRequestsPerHour differs per tenant.
- 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.
- 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
Authorizationheaders.
- 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.
- Renovate / Dependabot enabled.
- Critical / high vulnerabilities patched within 7 days.
- Lockfiles committed.
- No transitive dependency mismatch —
npm ci/dotnet restore --locked-modein CI.
- 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.
- 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.
Every privileged operation writes an audit log entry per
ADR-0033 Audit Durability Model
(supersedes ADR-0016) and the 18 Audit Coverage Standard.
Coverage is MUST / SHOULD / MAY per module-operation; the MediatR
AuditLogBehavior writes 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. The row is enrolled in the same
DbContext.SaveChangesas the business write, so a privileged operation cannot commit unaudited. It also means the insert runs whileapp.tenant_idis set, which is what lets Row Level Security accept it. - A failure to read
AuditConfigfails closed too. A tenant override may narrow SHOULD/MAY coverage; it may never remove baseline MUST coverage. - 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.
- 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.
- 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_idororganization_idfrom a request body or query param. Both come from authenticated context only. - 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/IHubTenantSyncadapters (see 20-infrastructure-stack.md). - Accepting
learnstack-hubrealm tokens on tenant-facing endpoints, or acceptinglearnstackrealm tokens on/api/internal/*endpoints. - Bypassing APISIX with direct backend ingress.