1. Multi-tenant model
Azora is a row-level multi-tenant system. There is one application, one PostgreSQL database, and one process per node — and every customer-data table carries an indexed account_id column.
-- Every customer-facing table looks like this:
CREATE TABLE tasks (
id text PRIMARY KEY,
account_id text NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
title text NOT NULL,
...
);
CREATE INDEX tasks_account_id_idx ON tasks (account_id);
Every query that reads tenant data is routed through a helper that adds WHERE account_id = $tenant before sending SQL to Postgres:
// Webbased/src/server/tenantDb.ts
const rows = await tenantDb(session)
.select().from(tasks);
// → SELECT * FROM tasks WHERE account_id = $1
What we don't do: separate databases per tenant, "workspaces" inside one big shared table, folder-based permissions, or row-level Postgres policies (RLS). Application-layer scoping is simpler to reason about in code review. Every generic CRUD path goes through tenantDb(); bespoke routes (auth, CP, app-settings) carry their own per-route checks documented in-file.
Cross-tenant access requires CP escalation
Application code paths add WHERE account_id = $tenant via tenantDb(). The Control Plane (§2) is the one surface that can fan out across tenants — for support and provisioning — and every such read is logged to cp_audit_log. CP-only metadata (provisioning records, billing events, admin audit) lives in a separate schema (cp_accounts, cp_audit_log) and never co-mingles with tenant business rows.
2. The Control Plane
The Control Plane (CP) is the vendor-only admin surface — used by Azora staff to provision tenants, handle billing, and respond to support requests. It is a separate route tree (/dashboard/control-plane), a separate role hierarchy, and a separate audit log (admin_audit_log). Tenants cannot reach it; vendor staff cannot reach tenant data through it.
Identity model
A CP admin is a user row with account_id IS NULL. That single column is the landlord marker. There is exactly one CP admin per environment by default, seeded by migration 0004_seed_cp_admin_and_cleanup.sql.
Two layered defenses gate CP access:
- The user row must have
account_id IS NULL (DB marker)
- If
CP_ADMIN_NAMES env var is set, the username must also be on the allowlist (env-side second factor; optional)
Both are evaluated server-side in deriveIsCpAdmin. The flag is stamped onto the session cookie and the JWT at login; subsequent requests check it via middleware.
What the CP can and can't do
| Capability | CP admin | Tenant admin |
| Provision a new tenant | Yes | No |
| Suspend or delete a tenant | Yes (logged) | No |
| Read tenant business data (tasks, CAPAs, etc.) | Support-only; logged to cp_audit_log | Yes (own tenant only) |
| Reset a tenant user's password | Yes — via the same login flow the user runs (no read access to hash) | Yes (own tenant) |
| View billing events | Yes | Own tenant only |
3. Authentication & sessions
Password hashing
Passwords are hashed with bcrypt (cost factor 12), never stored or logged in plaintext. Hash columns are never returned by API endpoints; the hash is consulted only inside the password-check helper.
Session cookies
Sessions are sealed with iron-session. The cookie is HttpOnly, Secure, SameSite=Strict in production, and includes session.isCpAdmin + session.accountId stamped at login. Cookies are sealed with SESSION_SECRET — rotating that secret invalidates every active session.
JWT API tokens
API access uses JWTs signed with HS256 against the same SESSION_SECRET. JWTs include a per-user revocation watermark (users.jwt_issued_at): logout, password change, suspension, or admin force-logout all bump the watermark, and any JWT issued before that timestamp is rejected.
// Effect: a stolen JWT is invalidated server-side
// without needing a per-token blocklist.
if (jwt.iat < user.jwt_issued_at) reject();
TOTP 2FA
Per-user TOTP secret with bcrypt-hashed backup codes. Two-state secret model (pending vs. active) prevents TOCTOU on enrollment — the secret only becomes "active" after the user verifies a code from it. TOTP secrets at rest are AES-256-GCM encrypted; see §4.
Username enumeration
Login responses are normalized: identical timing and identical error message regardless of whether the username exists. Failed-login rate-limit is per-username AND per-IP.
4. Encryption-at-rest
Sensitive columns are encrypted at the application layer with AES-256-GCM, keyed by HKDF-SHA256 derivation from SESSION_SECRET. The DB never sees plaintext.
// Envelope format stored in DB:
// "enc:v1:<iv-hex>:<tag-hex>:<ciphertext-hex>"
//
// 12-byte IV, 16-byte GCM auth tag, ciphertext — all hex-encoded.
const dek = hkdfSync("sha256", SESSION_SECRET, salt=Buffer.alloc(0),
info="azora-totp-at-rest-v1", 32);
const iv = randomBytes(12);
const { ciphertext, authTag } = aesGcmEncrypt(plaintext, dek, iv);
db_value = `enc:v1:${iv.toString('hex')}:${authTag.toString('hex')}:${ciphertext.toString('hex')}`;
Versioned envelope prefix (enc:v1:) gives forward compatibility for future cipher migrations — the helper picks the decryption path off the prefix.
What's encrypted today
- TOTP secrets (active and pending)
Roadmap (Q3 2026)
- License keys (
cp_licenses.license_key)
- R2 / S3 storage credentials (in
app_settings)
- CI/CD provider tokens (e.g. GitHub PATs in
cicd_config)
What isn't
- Business data — tasks, CAPAs, vendors, etc. (Postgres-level encryption is at the disk layer, provided by the host: AWS RDS / DigitalOcean managed Postgres / your own LUKS volume on self-host.)
- Email addresses (used for login, indexing, search)
Why not encrypt everything: Application-layer column encryption breaks indexes, full-text search, joins, and any meaningful query. We encrypt the things a database breach would otherwise expose as credentials usable elsewhere. For business data, we rely on Postgres-layer encryption, tenant isolation, and audit logging.
5. Audit logging
Every CRUD operation on tenant business data is logged to the audit_log table with:
- Actor:
user_id (or 'system' for automation/cron)
- Tenant:
account_id
- Action:
create / update / delete
- Entity: table + row id
- Diff: before/after JSON, redacted on sensitive fields (passwords, secrets, encrypted columns)
- Timestamp, IP, user-agent
The CP keeps a separate admin_audit_log for its own actions (provisioning, suspension, billing changes). The two never mix.
Read-audit logging
Tenant-scoped reads on sensitive entities (auth tokens, audit log itself, secret-bearing settings) are logged separately. This is for the auditor question "did anyone view the change-control history before signing it?" — yes, you can answer it.
Append-only enforcement
Audit log rows are append-only at the database level. A BEFORE UPDATE OR DELETE trigger on each of the four audit tables (entity_audit_log, quality_audit_log, admin_audit_log, cp_audit_log) raises a insufficient_privilege exception — even a DBA with direct PG access cannot modify rows without disabling the trigger first, which itself leaves a record. The only legitimate change is INSERT. To repair a malformed row, the operator must INSERT a correction row pointing at the original, never modify it in place. See migration 0030_audit_log_security_bundle.sql.
Tamper-evident hash chain
Each audit row stores a chain_hash column computed at INSERT time by a BEFORE INSERT trigger as SHA-256(id || actor || action || created_at || prev_hash), where prev_hash is the chain_hash of the previous row. Any tampering anywhere in the chain — even by a DBA with direct PG access who first disables the append-only trigger — invalidates every hash from that point forward. The verifier endpoint at GET /api/v1/audit/verify-chain?table=entity walks the chain newest-to-oldest and reports the first broken row. Pre-migration rows (those that predate 0030) have chain_hash = '' and are correctly identified by the verifier as "outside the chain" rather than counted as failures — the chain effectively starts as a genesis block at the first post-migration INSERT.
Retention
Audit logs are retained for the life of the tenant. On tenant deletion (§10), audit log rows are dropped via ON DELETE CASCADE in the same transaction as the rest of the data.
6. Compliance posture
ISO 13485
Azora's QMS workflows are ISO 13485-aligned: the solution ships with CAPA, NCR, change control, audit management, risk register (ISO 14971), traceability matrix, document control, and training records. Internal QMS uses Azora itself.
What we are not: Azora is not ISO 13485 certified. Customer organizations may use Azora as part of their own quality system, but certification depends on the organization’s processes, implementation, and audit.
21 CFR Part 11
Electronic signatures capture signer identity, signature statement (intent), timestamp, IP address, and user-agent on CAPA close-out, NCR disposition, and test execution. The underlying audit log is append-only at the database level and tamper-evident via SHA-256 hash chain — see §5. Signed change control records cannot be modified without breaking the chain; corrections require a new version superseding the original.
SOC 2
SOC 2 Type 2 audit on the Q4 2026 roadmap. We do not currently have SOC 2 attestation. See roadmap →
HIPAA
Azora is not currently sold as a HIPAA-compliant system, and we do not currently sign BAAs. Customers who need HIPAA workflows should evaluate carefully — the architecture includes audit logging, encryption, and access controls, but those controls alone do not establish HIPAA compliance.
SSO / SAML / SCIM
Planned for enterprise service engagements; not currently shipped. See roadmap →
7. May 2026 third-party audit findings
An external security audit was performed in May 2026. Ten findings, all closed before this brief was published. Each finding has a documented fix in BUG_TRACKER.md (internal, ID WEB-140 through WEB-151) and a corresponding commit + test in the codebase. The table below is reconciled directly against the tracker entries.
| ID | Severity | Area | Status |
| C-1 / C-2 | Critical | app_settings cross-tenant secret leak + tenant-admin clobber (R2/S3 + license-key plaintext exposed to any logged-in user) | Closed (server-side scrub-on-read + preserve-on-write; encryption-at-rest deferred to Q3 2026) |
| H-1 | High | sync_queue exposed cross-tenant via /api/v1/sync-queue (no account_id column, broken filter mapping) | Closed (removed from CRUD allowlist) |
| H-2 | High | Stored XSS via DocumentsPanel <iframe src> + <img src> with no scheme allowlist | Closed (safeUrl() + scheme allowlist at render time) |
| H-3 | High | /api/auth/2fa/setup + enable accepted session-only auth — account takeover via stolen session | Closed (password required at setup) |
| H-4 | High | /api/v1/auth/validate-token skipped JWT revocation watermark check | Closed (now calls verifyJwtForUser) |
| H-5 | High | JWT revocation silently no-ops in production when DATABASE_URL is unset | Closed (fail-closed in prod) |
| M-1 | Medium | /api/v1/auth/register response shape leaked username existence | Closed (response normalized to { ok: true }) |
| M-2 | Medium | /api/admin/seed-timesheet missing admin/president role check | Closed (explicit role gate) |
| M-3 | Medium | Tenant /api/audit GET had no read-audit logging | Closed (mirrors the CP pattern) |
| M-5 | Medium | TOTP seeds stored as plaintext base32 at rest | Closed (AES-256-GCM envelope, see §4) |
The bcrypt cost factor (currently 12, see §3) was raised as part of broader hardening, not as a numbered audit finding. This page publishes the finding summary; supporting review material can be discussed with qualified buyers through sales@azorasolutions.com.
8. Threat model
What we protect against
- Cross-tenant data leak. Mitigation: row-level
account_id scoping, tenantDb() helper as the only data path, code review enforces this.
- Credential theft from DB dump. Mitigation: bcrypt for passwords, AES-256-GCM for secrets-at-rest. A dump alone yields no usable credentials.
- Stolen JWT replay. Mitigation:
jwt_issued_at watermark — logout/password-change invalidates server-side without a blocklist.
- Unauthorized CP escalation. Mitigation:
account_id IS NULL as the marker, with optional CP_ADMIN_NAMES env allowlist. Tenant admins cannot escalate by editing their own row.
- Inserted-by-attacker audit-log entries. Mitigation: PG triggers raise
insufficient_privilege on any UPDATE/DELETE against the four audit tables (migration 0030). Tamper-evident SHA-256 hash chain detects mid-chain forgery, verified at /api/v1/audit/verify-chain. A dedicated DB role with restricted privileges on audit tables is a future hardening step.
- Username enumeration. Mitigation: identical response shape and timing for "user not found" vs "wrong password."
What we don't protect against (out of scope)
- Compromised customer endpoint. If a tenant admin's laptop is keylogged, Azora cannot help. Use 2FA to limit blast radius; SSO is not currently shipped.
- Insider threat at Azora. Vendor staff with database superuser access can read tenant data despite CP/tenant separation. Defense: limited number of staff with prod access, all access logged in
admin_audit_log, hardware MFA required. Not zero-knowledge.
- Physical hardware compromise. Disk-layer encryption depends on the deployment. Hosted and self-host infrastructure controls are documented during the security review; the application-level secret encryption described in §4 is separate.
- Cipher breakthrough on AES-256. If AES-256 falls, the column-encryption envelope versioning lets us migrate ciphers, but we don't promise post-quantum readiness today.
- Supply-chain compromise of a dependency. Mitigation is partial:
pnpm lockfile, dependency review on every PR, no auto-merging dependabot. A targeted attack on a critical dep is still possible.
9. Self-host model
Self-hosting is offered through an enterprise services agreement. It uses the same codebase, database schema, and deploy script. You hold the Postgres database; Azora does not receive the application data in that deployment model.
What you get
- The full Azora codebase as a deployable artifact (Next.js app +
deploy.sh + Drizzle migrations)
- Deploy targets a single VPS or any container host. Reference deployment is Ubuntu 24.04 + PostgreSQL 16 + Nginx + PM2.
- Schema migrations versioned in
drizzle/; we provide migration scripts for each release.
- Updates:
git pull && bash deploy.sh for minor versions; major versions ship a migration plan.
What you give up
- The Control Plane is hosted-only by default. On self-host, billing/provisioning is manual or through your own tooling.
- Auto-scaling: a self-host deploy is single-node by default. Multi-node Postgres + app-server fan-out is possible but on-you-to-configure.
- Outage recovery: Azora owns the hosted-product runbook; on self-host, your operations team owns the deployment runbook under the agreed support model.
Why self-host
Common reasons: data residency requirements, internal IT policy, regulated industries with explicit "data must stay in our network" clauses, or simply preference. We don't gate features on self-host vs hosted — same code, same modules.
10. Data lifecycle & deletion
On tenant provisioning
During open beta, Azora provisions the tenant and inserts seed data under its tenant identifier before issuing credentials. The marketing site does not create tenant accounts automatically.
While active
All application operations stay within the tenant's logical boundary. Backup encryption, location, and retention are deployment-specific controls documented for the hosted environment or in the self-host services agreement.
On suspension
Tenant data remains intact; access is denied at the auth layer. CP admin can re-activate. Default suspension period is 30 days before data is purged.
On deletion / cancellation
The accounts row is deleted. Every other table cascades:
account_id text NOT NULL REFERENCES cp_accounts(id) ON DELETE CASCADE
This is a single transaction. After commit, no production row carrying that account_id remains in the tenant tables. Residual backup copies age out under the retention schedule documented for that deployment.
Data export
Export capability is not gated by a separate pricing tier. Many panels export their current view, and the Admin audit log exports CSV. Contact support for a tenant-wide data handoff or cancellation export.
11. How to engage us
This brief covers the questions security teams ask in the first 90% of their evaluation. For the last 10%:
- Send your security questionnaire. Email sales@azorasolutions.com; scope, supporting material, and response timing are confirmed for the evaluation.
- Schema walkthrough. We'll open a screen-share and step through the Postgres schema, the
tenantDb() helper, the audit log structure, and any specific table you want to see. 30–45 minutes.
- Audit supporting material. This page contains the published finding summary. Ask sales which underlying material can be shared for your evaluation.
- Self-host demo. If you're evaluating self-host, we'll spin up a reference deployment and walk you through the deploy script, the migration story, and the operations model.
For everything else: security@azorasolutions.com. We answer.