Corteksa

API Platform — Security & Hardening

API Platform — Security & Hardening

Security posture of the external API surface (API keys + OAuth "Connect") and the hardening backlog. Audience: the Corteksa engineering/ops team — this includes a production runbook, not partner-facing content.

What's already solid (best-practice ✅)

  • Secrets hashed at rest — only HMAC-SHA256(pepper, raw) is stored for API keys and OAuth tokens; a DB dump yields nothing usable. Deterministic hashing is correct here because keys/tokens carry 256 bits of entropy (no brute-force risk) — the same choice Stripe/GitHub make. Raw secret shown once.
  • Prefixed, identifiable (crtk_live_, crtk_oauth_, crtk_app_) — enables secret-scanning and safe log references (token_prefix).
  • Least privilege — scopes are a subset of the creator's permissions; isSuperAdmin is forced false, so a credential never bypasses the endpoint gate.
  • Two-layer enforcement — coarse scope gate (PermissionGuard + @RouteName) plus fine per-record RLS/access filter re-checked on every request, so a credential's data reach can't outlive the admin's current rights.
  • Revocable + rate-limited + expiry-enforced on the lookup path; OAuth adds opaque revocable tokens, PKCE, exact redirect match, single-use codes, and refresh rotation.
  • Lifecycle audit — key create/revoke now write a changelog row (api_keys table) via the shared admin-config audit surface (never logging the secret).

🔴 Runbook — confirm RLS is ACTUALLY enforced in prod

On the shared hyper DB, cross-workspace isolation is enforced by Postgres RLS, not app code. It only fires if the app connects as a non-superuser role (crm_app, NOSUPERUSER NOBYPASSRLS). If the app connects as postgres (superuser) or the policies/role aren't installed, RLS is silently bypassed and the whole key/token isolation model is off. Confirm before onboarding external partners.

Verify (run against crm_hyper, as the app's own DB user):

-- 1. The app must NOT be a superuser and must NOT bypass RLS:
SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;
--    want: crm_app | f | f     (postgres | t | * ⇒ RLS BYPASSED)

-- 2. RLS enabled AND forced on workspace-scoped tables:
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relname IN ('api_keys','oauth_access_tokens','oauth_authorizations');
--    want: relrowsecurity = t AND relforcerowsecurity = t for each

-- 3. The isolation policy exists:
SELECT c.relname, p.polname FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid
WHERE c.relname = 'api_keys';
--    want: a ws_isolation policy

Remediate if any check fails:

  1. Run the RLS/role migrations on the hyper DB (as the superuser/postgres, which is exempt): npm run migration:run + npm run migration:run-tenants. These create the crm_app role, grants, and policies — see 1769200000000-CreateAppRoleAndGrants, 1800000000000-CloseRlsGapsAndAdminLoginAwarePolicy, 1800000000002-GrantCreateToAppRoleOnHyper.
  2. Point the running app at crm_app: set DB_USERNAME=crm_app (default is postgres) + its DB_PASSWORD in the prod env. Migrations keep running as the superuser; only the request-serving app must be crm_app for the policies to fire.
  3. Restart the app and re-run the three checks.

Do not "fix" the login-aware carve-out. admin, api_keys, and the OAuth token tables use a permissive-when-unpinned policy so the pre-workspace by-hash lookup works; it becomes strict once the connection is pinned. Any NEW pre-auth lookup on a hyper table must use this same login-aware policy, never strict, or auth breaks. See ISOLATION.md §4.

Paste-key vs OAuth — the accepted tradeoff

The primary integration flow has the customer paste an API key (ClickUp model). It's simple and self-serve, but the customer handles a long-lived secret (which can leak — via clipboard, a wrong paste box, etc.). OAuth "Connect" avoids that (no secret in the user's hands, per-grant revoke, scoped consent) at the cost of more moving parts. Decision: ship paste-key now; OAuth stays available for a published, broad multi-customer integration. Revisit if key leakage becomes a real problem at scale.

Hardening backlog

ItemStatus
Audit key create/revoke✅ done (this change)
Encourage an expiry when minting a key (presets + recommendation)✅ done (this change)
API_KEY_HASH_SECRET documented as prod-required, dedicated value✅ done (.env.example)
Confirm RLS active in prod (crm_app + policies)⚠️ verify/remediate (runbook above)
Secret-scanning / auto-revoke on leaked keys⬜ roadmap
Optional: default a shorter TTL / rotation reminders⬜ roadmap

Secret-scanning roadmap: because keys are prefixed (crtk_live_…), a scanning pattern can later be registered (GitHub secret-scanning partner program, or an internal scanner) plus a leaked-key auto-revoke webhook. Not built yet; the revoke + rotation path already exists to act on a detection manually.

On this page