Back to docs

Security & data architecture

How Mnueron isolates each org's data, encrypts vendor credentials at rest, and stays auditable for enterprise customers. Pair this with the architecture overview — this page is for security reviewers.

Encryption at rest

All vendor credentials use pgp_sym_encrypt with a server-side key (ARCHIVE_ENC_KEY env var). Postgres-level pgcrypto means even a stolen DB snapshot is useless without the key.

Encryption in transit

Every connection — browser-to-app, app-to-Supabase, app-to-LLM-provider — uses TLS 1.2+. We don't carry secrets over plaintext channels.

Row-level security

Postgres RLS scopes every tenant table by org_id. The app sets app.current_org_id GUC at request start. A buggy query that forgets WHERE org_id = … still can't leak rows.

No plaintext logging

Decrypted values never enter console.log, structured logs, or error messages. The API only sees last4 (the masked preview) and audit timestamps.

Defense in depth

App-layer authorization, RLS, encryption, and admin-only routes for cross-org reads all work together. Removing any single layer doesn't expose data.

Operator key rotation

ARCHIVE_ENC_KEY can be rotated by writing a re-encrypt migration. Org members can rotate their own API keys from /account-settings/keys at any time — last_verified_at + verify_status help track validity.

How org data is organized

Every customer is one row in the orgs table. Everything that belongs to that customer carries org_idas a foreign key and is scoped by RLS at the database level. There is no shared table where multiple orgs' data sits unfiltered.

Tenant tables (the "everything is scoped" list)

  • meetings · meeting_attendees · meeting_sources · meeting_links
  • decisions · action_items
  • memories · namespaces · memory_visibility
  • projects · project_assignment_rules
  • org_secrets · org_archive_config · archive_runs
  • recall_events · memory_recalls
  • entities · relations · consolidations

User ↔ org membership

Mnueron does not have a separate org_memberships table. Membership is implicit through api_tokens: each row carries both user_id and org_id. A user belongs to an org if and only if at least one of their api_tokens points to that org. This keeps the schema small and makes deleting access (revoking a token) an atomic operation.

The RLS template

-- Every tenant-scoped table follows this template.
-- Mnueron's app code sets app.current_org_id at the start of every
-- request via withTenantScope() before any DB statement runs.

CREATE POLICY <table>_tenant_isolation ON <table>
  FOR ALL
  USING (org_id::text = current_setting('app.current_org_id', true))
  WITH CHECK (org_id::text = current_setting('app.current_org_id', true));

withTenantScope(ctx, fn) in src/lib/db.ts is the ONLY public-facing way to talk to the DB. It calls SELECT set_config('app.current_org_id', $1, false) before fn runs and resets after.

What we encrypt at rest

Anything that grants third-party access is encrypted. The DB stores only the encrypted blob plus a masked preview of the last 4 characters. The encryption key (ARCHIVE_ENC_KEY) lives only in the server's environment variables — it never enters the DB, never reaches a browser, never appears in commits.

ColumnContainsEncryption
org_secrets.value_encryptedOpenAI / Anthropic / Granola / Fathom / Zoom / Teams keyspgp_sym_encrypt(value, ARCHIVE_ENC_KEY)
org_archive_config.secret_encryptedS3 / R2 / B2 secret access keyspgp_sym_encrypt(value, ARCHIVE_ENC_KEY)
api_tokens.token_hashUser session tokensscrypt-derived hash; never reversible
users.password_hashUser passwordsbcryptjs

Secret lifecycle — write, read, never expose

The flow below is enforced in code in src/lib/secrets/db.ts and the /api/account/secrets/* routes:

// On WRITE:
//   user pastes value → /api/account/secrets PUT
//   → cleaned + last4 computed
//   → INSERT ... VALUES (pgp_sym_encrypt($value, $encKey))
//   → DB stores only the encrypted bytea + last4 ("…wxyz")
//   → response returns { kind, last4 }; never the plaintext.

// On READ (pipeline / cron / extractors only):
//   await resolveSecretAdmin(orgId, "openai_key")
//   → SELECT pgp_sym_decrypt(value_encrypted, $encKey) WHERE org_id = $1
//   → returns plaintext string in process memory ONLY for the duration
//     of the LLM call. Never logged, never persisted, never returned
//     through any API response.

// On VIEW (dashboard listing):
//   /api/account/secrets GET
//   → projection EXCLUDES value_encrypted entirely
//   → returns kind + last4 + audit fields only

Three explicit rules that the codebase enforces:

  1. Listing endpoints never include the encrypted column. listOrgSecrets() projects only kind, last4, audit fields— the SQL doesn't select the ciphertext at all.
  2. Decryption only happens admin-side. resolveSecretAdmin() runs in admin context (bypasses RLS), takes an explicit org_idparameter, and never returns another org's secret because WHERE org_id = $1 is hard-coded into the query.
  3. Decrypted values never log. No console.log, no error message, no structured event carries the plaintext. The only place it exists is in the local variable used to make the HTTP call to the upstream provider.

Audit trail

Every secret-bearing table has the same audit shape so SOC2-style reviews are straightforward:

  • created_by + created_at — who introduced it
  • updated_at — last write (rotation, edit)
  • last_used_at — last decrypt by the pipeline / cron
  • last_verified_at + verify_status — most recent test result

Mutating routes record actor + timestamp on every change. Audit-only tables that complement this:

  • archive_runs — one row per archive worker invocation (manual or cron), including ok flag and error_message.
  • billing_events — every Stripe webhook landed, idempotent by stripe_event_id.
  • recall_events — every memory recall captured for the savings analytics. Includes org, client, model, query length.
  • account_deletion_audit — records account deletion requests for compliance follow-up.

What we store vs what we don't

We store

  • Meeting metadata (title, time, duration, source)
  • Transcript text (chunked into memories rows)
  • Decisions + action items extracted by the LLM pipeline
  • Attendee names / emails (matched to org users when possible)
  • Linked references (REQ-*, JIRA-*, ADR-*, doc filenames)
  • AI-generated synopsis (per meeting, optional)
  • Org-scoped configuration (projects, rules, archive config)

We don't store

  • Audio or video recordings
  • LLM API keys in plaintext (encrypted only)
  • Decrypted secrets in logs, errors, or API responses
  • Cross-org joinable data (everything is org-scoped)
  • Granola/Fathom/Zoom raw payloads beyond what we extract
  • Browsing history, session replays, or telemetry beyond the recall events table

For orgs that want even tighter control: the archive feature offloads aged meeting bodies to a bucket your org owns as self-describing .md files. After the offload, our DB keeps only a stub row (id + title + url) and you can delete the bucket files yourself any time.

Local-first option

For the most security-sensitive orgs, the mnueron CLI supports local-only mode: meeting memory lives in SQLite on the user's laptop and never leaves the machine. No cloud sync, no third-party APIs unless the user adds their own LLM key. Configure via ~/.mnueron/config.json and choose "Local only" at /account-settings/storage.

Migration timeline

Every schema change ships as a numbered SQL file in supabase/migrations/. Notable ones for the security story:

  • 016_org_plan_tier.sql — orgs + plan tiers (free/basic/pro/team/enterprise).
  • 017_stripe_billing.sql — billing + audit event tables.
  • 024_storage_quota.sql — per-org storage quota enforcement.
  • 027_account_deletion_audit.sql — compliance audit for account deletion.
  • 048_memory_visibility.sql — per-memory visibility (private / shared-link).
  • 050_meetings.sql — Meet pipeline schema with RLS on every table.
  • 052_storage_modes_and_archive.sql — first pgcrypto-encrypted column (archive secret).
  • 053_meeting_sources_auth_jsonb.sql — convert vendor auth from bytea to jsonb idempotently.
  • 054_projects.sql — projects layer + classifier rules.
  • 055_meeting_synopsis.sql — adds AI synopsis columns.
  • 056_org_secrets.sql — unified encrypted store for all per-org API keys.
  • 057_secret_access_log.sql — append-only audit trail of every secret decryption.
  • 058_org_secrets_envelope.sql — envelope encryption columns (encryption_mode, encrypted_dek, iv, kms_key_id). Enables AWS KMS path alongside legacy pgcrypto.

Operator runbook

Rotate ARCHIVE_ENC_KEY

Generate a new 32-byte base64 key, deploy it as a SECOND env var (e.g. ARCHIVE_ENC_KEY_NEXT), run a one-off migration that re-encrypts every org_secrets.value_encrypted and org_archive_config.secret_encrypted with the new key, promote the new env var to be the primary, then drop the old one. Plan for read downtime ≤ 1 minute on the swap or use blue/green.

Rotate CRON_SECRET

Update CRON_SECRET in Vercel, redeploy, then update local .env values on every machine running npm run sync:granola or archive:run. There's no DB migration needed — the value is only ever compared in-memory against the request header.

Revoke an org's API access

Delete from api_tokens where user_id = ? ororg_id = ?. Because membership is implicit through tokens, this immediately removes that user from the org's data. RLS prevents any prior session cookies from resolving.

Envelope encryption with AWS KMS (Option C — operator opt-in)

Mnueron supports two encryption modes for stored credentials. Operators choose at deploy time by setting two env vars:

  • MNUERON_KMS_ENABLED=true
  • AWS_KMS_KEY_ID=<key ARN>

When both are set, every NEW secret saved through the UI is encrypted under envelope_v1:

  1. App requests a per-row AES-256 Data Encryption Key (DEK) from AWS KMS via GenerateDataKey.
  2. The plaintext DEK encrypts the secret locally with AES-256-GCM (authenticated; ciphertext carries a tag).
  3. The DEK is then immediately wiped from app memory. Only its encrypted form is stored in org_secrets.encrypted_dek.
  4. Reads: app pulls the encrypted DEK, calls KMS Decrypt, gets back the plaintext DEK, AES-decrypts the secret locally, wipes the DEK. One KMS call per secret read.

What this unlocks: FIPS 140-2 Level 3 validated crypto (KMS is FIPS-certified), per-row blast radius (compromise of one DEK exposes one secret, not all), and CloudTrail audit of every KMS call by IAM principal. Pairs with Mnueron's own secret_access_log for in-app activity.

Per-row migration from the legacy pgcrypto path runs one org at a time:

ORG_SLUG=acme-insurance npm run migrate:org-to-kms

Existing rows stay in encryption_mode = 'pgcrypto' until migrated; the dispatch logic in resolveSecretAdmin reads either mode transparently. Mid-rollout is safe.

BYOK (customer-managed keys): the schema includes a kms_key_id column per row so a future per-org BYOK UI can let enterprise customers point each org at their own KMS key ARN. Their KMS key policy grants Mnueron's IAM principal kms:GenerateDataKey + kms:Decrypt; we never see the underlying master.

What we don't have yet — honesty over overpromise

Mnueron's current security bar is appropriate for solo developers, startups, mid-market SaaS, and most public-company departments. It's honest to tell you what we don't yet offer for stricter use cases so you don't fail a vendor security review halfway through procurement:

GapWhat strict reviewers wantOn our roadmap?
HSM / KMSMaster key in FIPS 140-2 Level 2+ HSM (AWS KMS, GCP KMS, Vault) — never in an env var✓ Available (AWS KMS envelope encryption — operator enables per deploy)
Customer-managed keys (BYOK)Provide your own KMS key, retain control of decryptionYes — depends on KMS first
FIPS 140-2 validated cryptoFederal contracts and some healthcare require this✓ Available via AWS KMS (Level 3 certified)
Per-row DEKs (envelope encryption)Compromise of one DEK exposes one secret, not all✓ Available when envelope_v1 enabled
FedRAMP / SOC2 Type 2Audited controls, attestation reportNot pursued yet — happy to discuss
Dedicated tenancyIsolated DB cluster, isolated network, no shared computeAvailable on Enterprise contract

Where we'd sit on a typical procurement review

Customer typeLikely outcome today
Solo developer / startup / SMBPass
Mid-market SaaS (50–500 employees)Pass with this doc
Public company InfoSec reviewConditional — likely wants secret access logs (we have) + documented rotation (we have)
Healthcare with BAA / HIPAA strictConditional — need BAA + KMS
Bank / financial servicesNeeds KMS + dedicated tenancy
Federal / FedRAMPMajor rebuild required

If you're evaluating Mnueron and need controls beyond what's documented above, reach out — most of these gaps are paid-tier features we can light up per-contract rather than globally.

Related reading