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;
isSuperAdminis forcedfalse, 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_keystable) 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 policyRemediate if any check fails:
- 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 thecrm_approle, grants, and policies — see1769200000000-CreateAppRoleAndGrants,1800000000000-CloseRlsGapsAndAdminLoginAwarePolicy,1800000000002-GrantCreateToAppRoleOnHyper. - Point the running app at
crm_app: setDB_USERNAME=crm_app(default ispostgres) + itsDB_PASSWORDin the prod env. Migrations keep running as the superuser; only the request-serving app must becrm_appfor the policies to fire. - 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
| Item | Status |
|---|---|
| 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.