# Controls

Rendered by `kx controls` from `iac/backbone/controls.ts`; edit that file, not this one.

What the backbone promises a regulated customer, the clause each promise answers, what implements
it, and the command that proves it on a live customer (`kx verify` runs the infra ones).
Infrastructure supplies controls and evidence, not certification: HIPAA also needs Microsoft's BAA
(covers us) and our BAA with each customer; SOC 2 is an audit of the company.

Status: **built** = in the IaC; **designed** = written down, not built; **gap** = known missing.

## Infrastructure (the backbone)

| Control | Status | HIPAA | GLBA | PCI DSS 4 | NYDFS 500 | SOC 2 |
|---|---|---|---|---|---|---|
| **ISOLATION** One customer's data never shares a store with another's | built | 164.308(a)(4) |  |  |  | CC6.1 |
| **ENC-REST** Encryption at rest | built | 164.312(a)(2)(iv) | 314.4(c)(3) | 3.5.1 | 500.15 | CC6.1 |
| **ENC-TRANSIT** Encryption in transit, TLS 1.2 or later | built | 164.312(e)(1) | 314.4(c)(3) | 4.2.1 | 500.15 | CC6.7 |
| **NET-PRIVATE** Data stores are not reachable from the internet | built | 164.312(e)(1) |  | 1.3.1 |  | CC6.6 |
| **IAM-WORKLOAD** The app holds no cloud keys; its identity can reach only its own stores | built | 164.312(a)(1) | 314.4(c)(1) | 7.2.1 | 500.07 | CC6.3 |
| **IAM-DB-ROLE** The app connects to its database with a least-privilege role | gap |  |  | 7.2.2 |  | CC6.3 |
| **SECRETS** Secrets are generated, held in a vault, and never in code or images | built | 164.312(a)(1) |  | 8.3.2 |  | CC6.1 |
| **AUDIT-LOG** Every access and change is logged | built | 164.312(b) | 314.4(c)(8) | 10.2.1 | 500.06 | CC7.2 |
| **AUDIT-RETAIN** Audit records are kept and cannot be altered | built | 164.316(b)(2)(i) |  | 10.5.1 | 500.06 | CC7.2 |
| **BACKUP** Data is backed up | built | 164.308(a)(7)(ii)(A) |  |  |  | A1.2 |
| **RESTORE-TEST** The backup is restored and checked, not assumed | designed | 164.308(a)(7)(ii)(D) |  |  |  | A1.3 |
| **GUARDRAILS** Insecure configurations are refused, not just reported | built |  |  |  |  | CC5.2 |
| **POSTURE** Compliance posture is assessed continuously, per customer | built |  | 314.4(d)(2) |  |  | CC4.1 |
| **CHANGE-MGMT** Every change is code, reviewed, and attributable | built |  | 314.4(c)(7) | 6.5.1 |  | CC8.1 |
| **FILES** App files survive restarts, stay private, are backed up, and each app sees only its own | built | 164.308(a)(7)(ii)(A) |  | 3.5.1 |  | CC6.1 |
| **MAIL** Outbound email goes through a managed service with no tracking | built |  | 314.4(c)(3) |  |  | CC6.7 |
| **WAF** Public web app protected by a web application firewall | gap |  |  | 6.4.2 |  |  |
| **PW-IN-URL** A password sent in a sign-in URL is refused before it reaches the app | built |  |  |  |  |  |

### ISOLATION: One customer's data never shares a store with another's

Resource group per customer with its own VNet, Postgres server, storage account, Key Vault, managed identity and Log Analytics workspace. The isolation unit is a parameter (`resourceGroup` today, `subscription` later). Trials share a tier; their files are one directory per trial on a shared share (see FILES). Trial blobs (tier deployed with blob: true) are one container per trial in one shared account; the trial's identity holds Storage Blob Data Contributor on its own containers only, so isolation is per container, as it is per secret in the shared vault.

*Stricter by data class:* phi/pci: a shared subscription means one quota and billing boundary and a shared Activity Log; move such customers to `subscription` isolation before production.

Evidence: `az resource list -g <rg> --query '[].type' -o tsv | sort | uniq -c`

### ENC-REST: Encryption at rest

Postgres and MySQL Flexible storage encryption (always on); Storage service encryption (always on), including the trial tier's Azure Files NFS account; Key Vault for the secrets the app holds.

*Stricter by data class:* phi/pci: storage infrastructure (double) encryption, which can only be set at creation.

Evidence: `az storage account show -n <sa> --query encryption`

### ENC-TRANSIT: Encryption in transit, TLS 1.2 or later

Container App ingress with allowInsecure=false and a managed certificate; Postgres require_secure_transport=on and ssl_min_protocol_version=TLSv1.2; MySQL require_secure_transport=ON and tls_version=TLSv1.2,TLSv1.3; storage HTTPS-only, min TLS 1.2. Deny policies refuse a change that weakens any of these. Exception, recorded on FILES: trial app files travel over NFS 4.1 unencrypted inside the VNet (Container Apps cannot encrypt NFS).

Evidence: `az postgres|mysql flexible-server parameter show -g <rg> -s <db> -n require_secure_transport --query value; curl -sI http://<domain> (no plaintext listener)`

### NET-PRIVATE: Data stores are not reachable from the internet

Postgres or MySQL in its own delegated subnet with public network access disabled; blob storage (including the trial tier's shared blob account) and the trial tier's files account behind private endpoints with public network access disabled; private DNS zones linked to the VNet.

Evidence: `az postgres|mysql flexible-server show -g <rg> -n <db> --query network.publicNetworkAccess; az storage account show -n <sa> --query publicNetworkAccess`

### IAM-WORKLOAD: The app holds no cloud keys; its identity can reach only its own stores

One user-assigned managed identity per app with AcrPull on the registry, Storage Blob Data Contributor on its one storage account and Key Vault Secrets User on its one vault. Storage shared keys disabled; registry admin user disabled.

Evidence: `az role assignment list --assignee <identity-principal-id> --all -o table`

### IAM-DB-ROLE: The app connects to its database with a least-privilege role

Not yet: the app uses the server admin login. Next: a Container Apps job inside the VNet that creates an app role owning only the app database, or Entra ID auth with the app's managed identity.

Evidence: `az postgres|mysql flexible-server show -g <rg> -n <db> --query administratorLogin`

### SECRETS: Secrets are generated, held in a vault, and never in code or images

Database passwords, connection strings and the app's own generated keys (manifest generatedSecrets) are generated by Pulumi, stored encrypted in Pulumi state (Key Vault key) and in the customer's Key Vault (RBAC, purge protection); the Container App reads them by Key Vault reference. Secrets a person stores in that vault by hand (manifest vaultSecrets, e.g. an OAuth client) are passed by the same reference when present at deploy time and left out when not; Pulumi never writes or reads their values.

*Stricter by data class:* phi/pci: Key Vault behind a private endpoint (gap, the vault is public-endpoint plus RBAC today).

Evidence: `az containerapp show -g <rg> -n <app> --query properties.configuration.secrets`

### AUDIT-LOG: Every access and change is logged

Diagnostic settings (allLogs) on Postgres or MySQL, blob storage, the files account's file service, Key Vault and the Container Apps environment to the customer's Log Analytics workspace and to the platform audit archive; pgaudit (write, ddl, role); MySQL audit_log (connection, DDL, DCL, admin); the subscription Activity Log exported to the archive.

Evidence: `az monitor diagnostic-settings list --resource <resource-id> -o table`

### AUDIT-RETAIN: Audit records are kept and cannot be altered

Platform audit archive: storage with version-level immutability (WORM, append writes allowed) for `auditRetentionDays` (default 2190, six years) and a CanNotDelete lock. The immutability policy is left Unlocked: locking it is irreversible and is a decision for John.

*Stricter by data class:* phi/pci: Log Analytics retention 90 days (30 otherwise); the archive holds the long tail.

Evidence: `az storage account show -n <audit-sa> --query immutableStorageWithVersioning`

### BACKUP: Data is backed up

Postgres or MySQL point-in-time restore, 35 days; blob versioning plus 30-day blob and container soft delete; trial tier blob account: versioning plus 7-day blob and container soft delete (a deleted trial's documents are gone after 7 days); app files (trial tier): a daily share snapshot kept 35 days, and 35-day share soft delete.

*Stricter by data class:* phi/pci: geo-redundant database backup.

Evidence: `az postgres|mysql flexible-server show -g <rg> -n <db> --query backup`

### RESTORE-TEST: The backup is restored and checked, not assumed

`kx restore-drill <customer>`: point-in-time restore to a temporary server in the same subnet, wait for Ready, compare, delete. The row-count comparison needs a job inside the VNet (not built).

Evidence: `kx restore-drill <customer> (writes a dated receipt under runs/)`

### GUARDRAILS: Insecure configurations are refused, not just reported

Azure Policy deny assignments on every kx resource group: its own region(s) only, storage public access disallowed, storage shared key disallowed, secure transfer required, storage min TLS 1.2, Postgres and MySQL public network access disabled, Container Apps HTTPS only. Scoped to kx groups so other workloads in the subscription are unaffected.

Evidence: `az policy assignment list -g <rg> --query "[?enforcementMode=='Default'].displayName"`

### POSTURE: Compliance posture is assessed continuously, per customer

Built-in regulatory initiatives assigned in audit mode (DoNotEnforce) at the customer's resource group, chosen by data class: NIST SP 800-53 R5 and SOC 2 Type 2 for all; plus HITRUST/HIPAA for phi; plus PCI DSS v4.0.1 for pci. The compliance view is per customer.

Evidence: `az policy state summarize -g <rg>`

### CHANGE-MGMT: Every change is code, reviewed, and attributable

All resources from Pulumi through `kx publish`; images pinned by digest; Pulumi stack history plus the Activity Log say who changed what.

Evidence: `pulumi stack history -s <customer>`

### FILES: App files survive restarts, stay private, are backed up, and each app sees only its own

Trial tier only. Manifest `needs.files` names the directories an app keeps state in; the backbone decides the store. One Azure Files NFS share (premium SSD, provisioned v2, 32 GiB) on a FileStorage account with shared key access off and public network access off, reached through a private endpoint; encrypted at rest. Each trial gets the directory t-<id> on the share: a job with a fixed command creates it, the app's template mounts only its subdirectories (subPath), an init container seeds them once from the image, and a second fixed job empties it when the trial is deleted. A daily job takes an ARM share snapshot (no key) and expires snapshots after 35 days; deleted shares are kept 35 days. File service logs go to the workspace and the audit archive. Gaps, recorded rather than hidden: (1) NFS traffic is not encrypted in transit; Container Apps does not support NFS encryption in transit, so the account must not require it. It stays on the tier's private network (VNet to private endpoint). (2) NFS has no client authentication: isolation between trials is the mount configuration the backbone writes, plus unprivileged containers, not a credential; a trial app that ran a userspace NFS client could reach other trials' directories through the endpoint. (3) The share's 32 GiB is shared; no per-trial quota. Trials hold no PHI or PCI data. Paid customers: not built (a keyless SMB mount with a managed identity failed in testing, runs/backbone-files-2026-10-22).

*Stricter by data class:* phi/pci: not offered; such apps need the paid path, which has no file store yet.

Evidence: `az storage account show -g <rg> -n <fa> --query "{pna:publicNetworkAccess,key:allowSharedKeyAccess,enc:encryption.services.file.enabled}"; az storage share-rm list -g <rg> --storage-account <fa> --include-snapshot -o table; kx verify --trial <id>`

### MAIL: Outbound email goes through a managed service with no tracking

Apps that declare needs.email get Azure Communication Services Email in their own resource group: an Azure-managed sender domain (no DNS), user engagement tracking disabled (no tracking pixels or rewritten links), data location United States, the access key generated by Azure and held only in the customer's Key Vault (read by Key Vault reference), diagnostic logs to the workspace and the audit archive (AUDIT-LOG). A company domain (iac/backbone/publicDns.ts) is added to the same email service as a customer-managed domain with the same tracking setting; its SPF, DKIM (two selectors), DMARC and verification records are published by the kx-dns stack, and it is linked as the sender (SecureApp mailFrom) only once ACS reports it verified.

Evidence: `az communication email domain show -g <rg> --email-service-name kx-<customer>-email -n AzureManagedDomain --query userEngagementTracking`

### WAF: Public web app protected by a web application firewall

Not built: Container Apps ingress has no WAF. Required for pci; Front Door Premium with a WAF policy in front of the app is the planned shape.

*Stricter by data class:* pci: required before production.

Evidence: `-`

### PW-IN-URL: A password sent in a sign-in URL is refused before it reaches the app

Narrow stand-in for one WAF rule, not a WAF. Some upstream apps (Cal.com, Documenso) render a sign-in form that submits as GET if it is used before the page's JavaScript loads, which puts the password in the query string (runs/app-gates-2026-10-22 finding 1). A manifest that names `signInPaths` gets a sidecar (iac/backbone/guard.ts, nginx pinned by digest, 0.25 vCPU / 0.5 GiB more per replica): ingress goes to the sidecar, which proxies to the app on the replica's loopback and answers a GET or HEAD on a sign-in path whose query has a parameter named like pass/pwd with 303 to the bare path. The app never receives it; the sidecar logs the blocked event without the query string. Gaps, recorded: the browser has already sent the URL once (over TLS to the ingress) and may keep it in its history; other paths and POST are passed through unchanged.

Evidence: `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://<app>/<signin>?email=a&password=b' (expect 303 to the bare path); az containerapp show -g <rg> -n <app> --query "properties.template.containers[].name"`

## Application layer (runtime kit, designed)

| Control | Status | HIPAA | GLBA | PCI DSS 4 | NYDFS 500 | SOC 2 |
|---|---|---|---|---|---|---|
| **AUTH-UNIQUE** Unique per-person login | designed | 164.312(a)(2)(i) |  | 8.2.1 |  | CC6.2 |
| **MFA** Multi-factor authentication | designed |  | 314.4(c)(5) | 8.4.2 | 500.12 | CC6.1 |
| **SESSION** Sessions expire and log off automatically | designed | 164.312(a)(2)(iii) |  | 8.2.8 |  | CC6.1 |
| **AUTHZ-SERVER** Authorization enforced on the server and in the database | designed | 164.312(a)(1) | 314.4(c)(1) | 7.2.1 |  | CC6.1 |
| **APP-AUDIT** User activity trail inside the app | designed | 164.312(b) | 314.4(c)(8) |  | 500.06 | CC7.2 |
| **DISPOSAL** Customer data can be exported and securely disposed of | designed |  | 314.4(c)(6) |  |  | C1.2 |

### AUTH-UNIQUE: Unique per-person login

Runtime kit: platform-enforced login in front of the app (Container Apps built-in auth / OIDC), so a generated app cannot ship an unauthenticated route by mistake.

Evidence: `curl -s -o /dev/null -w '%{http_code}' https://<domain>/api/<any> (401 without a session)`

### MFA: Multi-factor authentication

Runtime kit: TOTP enrolment required (the CRM's REQUIRE_MFA=1 is the reference).

Evidence: `app setting REQUIRE_MFA=1 on the Container App`

### SESSION: Sessions expire and log off automatically

Runtime kit: server-minted session id, `__Host-` cookie, HttpOnly, Secure, SameSite=Lax, idle timeout.

Evidence: `curl -sI https://<domain>/api/login (Set-Cookie attributes)`

### AUTHZ-SERVER: Authorization enforced on the server and in the database

Runtime kit: a Postgres row-level-security convention (tenant/owner column plus policies created by the platform, not written by the model). This is the Lovable CVE-2025-48757 class of failure.

Evidence: `SELECT relname, relrowsecurity FROM pg_class WHERE relkind='r' (via the in-VNet job)`

### APP-AUDIT: User activity trail inside the app

Runtime kit: an append-only audit table written by the kit on sign-in, record change and download.

Evidence: `the app's activity screen; the audit table`

### DISPOSAL: Customer data can be exported and securely disposed of

Runtime kit: export and delete for a record and for the whole tenant; `kx destroy` for the stack.

Evidence: `-`

## Audit initiatives by data class

| Initiative | Assigned for |
|---|---|
| NIST SP 800-53 Rev. 5 (`179d1daa-458f-4e47-8086-2a68d0d6c38f`) | none, pii, pci, phi |
| SOC 2 Type 2 (`4054785f-702b-4a98-9215-009cbd58b141`) | none, pii, pci, phi |
| HITRUST/HIPAA (`a169a624-5599-4385-a696-c8d643089fab`) | phi |
| PCI DSS v4.0.1 (`a06d5deb-24aa-4991-9d58-fa7563154e31`) | pci |
