Architecture
Integrations — Architecture
Phase 1 of the Corteksa integration platform. It lets an external app (the timer app wieeo, Zapier/Make, or any partner) authenticate into the CRM's REST API with a per-workspace key — the foundation the later OAuth "Connect" server reuses.
The model in one paragraph
A key acts as the admin who created it. On each request the guard hashes
the presented key, finds its row, resolves { workspace_id, admin_id }, pins
the RLS connection to that workspace, and attaches an AdminUser whose id is
the acting admin. From there the existing PermissionGuard, row-level access
filters, and audit all behave exactly as they would for that admin logging in —
narrowed by the key's scopes and with the super-admin bypass forced off.
Authenticating a request
Send the key one of two ways:
X-Api-Key: crtk_live_<hex>
# or
Authorization: Bearer crtk_live_<hex>The base URL is the workspace's own subdomain (same host the frontend uses) — the subdomain resolves the tenant DB; the key resolves the workspace within it.
Scopes = route names
A key's scopes are a subset of the same route_name strings the permission
system already uses (read.contacts, create.deals, update.contacts, …).
PermissionGuard checks the requested route's @RouteName against the key's
scopes exactly like a human's permission list. A key can only be granted scopes
its creator holds (super-admins may grant any).
Phase-1 surface: the records API — GET/POST/PUT/DELETE /api/v1/object/data/:objectSlug…
(and its bulk variants). Admin-only endpoints (roles, billing, user management)
are never reachable by a key.
Managing keys
Human-guarded admin endpoints (you cannot mint or revoke keys with a key):
| Method | Path | Route name | Notes |
|---|---|---|---|
POST | /api/v1/api-keys | apiKeys.create | Returns the raw key once as data.api_key — copy it now. |
GET | /api/v1/api-keys | apiKeys.read | Lists the workspace's keys (never the secret). |
DELETE | /api/v1/api-keys/:slug | apiKeys.update | Revokes (kept as a row for audit). |
POST body: { "name": "wieeo", "scopes": ["read.contacts","create.contacts"], "expires_at"?: "<ISO-8601>" }.
Security properties
- Storage: only
HMAC-SHA256(pepper, rawKey)is stored (deterministic → O(1) lookup; the key's 256-bit entropy makes a fast hash safe). Pepper isAPI_KEY_HASH_SECRET(falls back toJWT_SECRET). A DB dump yields nothing usable. - Isolation (hyper): the
api_keystable carriesworkspace_idunder a login-awarews_isolationRLS policy — permissive only for the by-hash auth lookup (before a workspace is known), strict once the connection is pinned. Same carve-out theadmintable uses. Dedicated tenants isolate by DB boundary. - No privilege escalation:
isSuperAdminis forcedfalse, so a key never bypasses the endpoint gate. And because the record services always re-check the admin's current A/G/M/D level per record, a key's data access can never outlive the admin's rights — the scope list is only the coarse endpoint gate. - Rate limiting: a per-key fixed-window limiter (
API_KEY_RATE_LIMIT_PER_MINUTE, default 120; 0 disables) applies to API-key traffic only, never human JWT requests.
Config
| Env | Default | Purpose |
|---|---|---|
API_KEY_HASH_SECRET | falls back to JWT_SECRET | HMAC pepper; rotate to invalidate all keys |
API_KEY_RATE_LIMIT_PER_MINUTE | 120 | Per-key request budget; 0 disables |
Phase 2 — OAuth 2.0 "Connect" server
Corteksa is also an OAuth 2.0 provider so a partner app can offer a
"Connect with Corteksa" button. It reuses everything above: the issued access
token is opaque (crtk_oauth_…, HMAC-hashed, login-aware-RLS tenant table)
and flows through the SAME ApiAuthGuard → acting-as-admin → scopes path — the
guard just routes by prefix (crtk_live_ = API key, crtk_oauth_ = OAuth). The
shared TokenPrincipalService builds the acting-as-admin AdminUser for both.
The flow (authorization code + PKCE, RFC 6749/7636)
app → user browser → GET /oauth/authorize?client_id&redirect_uri&scope&state&code_challenge
(SPA renders the consent screen from this JSON)
user approves → POST /oauth/authorize {decision:"approve", …} [Bearer: the user]
→ records the grant + mints a single-use code (Redis, 60s)
→ { redirect_to: "{redirect_uri}?code=…&state=…" }
app backend → POST /oauth/token {grant_type:"authorization_code", code, redirect_uri,
client_id, client_secret | code_verifier}
→ validates single-use code + exact redirect + PKCE/secret
→ { access_token: "crtk_oauth_…", refresh_token, expires_in, scope }
app → GET /api/v1/object/data/contacts [Authorization: Bearer crtk_oauth_…]Endpoints
| Method / Path | Auth | Purpose |
|---|---|---|
POST/GET/PATCH/DELETE /api/v1/oauth/apps | super-admin | Register/manage the global app (client) registry. Register returns client_id + client_secret once. |
GET /api/v1/oauth/authorize | user (Bearer) | Consent details for the SPA (app + grantable scopes). |
POST /api/v1/oauth/authorize | user (Bearer) | Approve/deny → returns redirect_to. |
POST /api/v1/oauth/token | client (secret/PKCE) | authorization_code + refresh_token grants. Raw RFC JSON. |
POST /api/v1/oauth/revoke | client | RFC 7009 token revocation. |
GET/DELETE /api/v1/oauth/authorizations[/:slug] | user (Bearer) | "Connected apps": list / revoke consent (cascades to tokens). |
Client types
confidential(server apps like wieeo's backend) — authenticate withclient_secret.public(SPAs/mobile) — no secret; PKCE (S256) required.
Security properties
- Access tokens are revocable (opaque + hashed) — revoking a grant or app kills live tokens immediately, not just at expiry (~1h). Refresh tokens (~30d) are rotated on every use (old revoked).
- Exact
redirect_urimatch against the registered allowlist (open-redirect defense); auth codes are single-use (atomic Redis GET-and-delete), 60s, bound to client + redirect + PKCE challenge. - Scope monotonic downgrade: granted ⊆ requested ⊆
app.allowed_scopes⊆ the user's live permissions. - Wildcard object scopes (
{verb}.*, ClickUp-style): a scope likeread.*grants a verb across every object the connecting user can reach, so a partner can sync to any object the user later picks — without being re-registered per object. It stays bounded by the owner: at token-auth it expands (object-scope.util.tsexpandObjectScopes) into only the owner's live{verb}.{slug}gates, and the record services still re-check A/G/M/D per row. It is grantable when the grantor holds any object gate for that verb (isScopeHeld). - The apps registry is a global main-DB catalog; grants + tokens are workspace-scoped with RLS (login-aware for tokens, strict for grants), the same isolation model as API keys. Token writes from the public token endpoint run inside
TenantScope.runInScope(pins + releases the RLS connection).
Partner integration (how wieeo connects)
- A Corteksa super-admin registers the app (
POST /oauth/appswithredirect_uris,allowed_scopes,client_type) → getsclient_id(+client_secretfor confidential). - wieeo sends the user to
…/oauth/authorize?client_id&redirect_uri&scope&state&code_challengeon the workspace's subdomain. - On approval, wieeo receives
?code=…at itsredirect_uri, exchanges it at/oauth/token, and calls the records API with thecrtk_oauth_access token — refreshing via therefresh_tokengrant.
Push (webhooks)
Authentication above is the pull half (the app calls Corteksa). The push half — Corteksa sending HMAC-signed webhooks to the app on record changes — is its own doc: Webhooks. A public webhook subscription is simply a webhook owned by an API key; deliveries are signed, and an event caused by the app's own key is never echoed back to it. For the whole platform at a glance, see the Overview.
Still out of scope (later phases)
Self-serve developer portal (dynamic client registration), OIDC id_tokens,
webhook auto-disable/health ledger, connector registry, and full OpenAPI generation.