Workspaces + API keys
Multi-tenancy in Leatsmap is built on three primitives:
- A workspace is the customer / org boundary. Every analytics row is attributed to a site, and every site lives inside exactly one workspace.
- A site is a property under a workspace. The
site_idis what the SDK ships in the bearer header (legacy auth) or inX-Site-Id(api_key auth). - An api_key is a workspace-scoped credential with one of three
scopes:
ingest:write,query:read,admin.
Create a workspace
Section titled “Create a workspace”curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme", "slug": "acme" }' \ http://localhost:8081/workspacesResponse (201):
{ "id": "<uuid>", "name": "Acme", "slug": "acme", "created_at": "<iso>" }Validation:
slugmatches^[a-z][a-z0-9-]{0,63}$and is unique. Conflict returns409 slug_taken.nameis 1–128 chars after trimming.- The slug is not renameable in v1.0 — it is the URL-stable handle.
Create a site
Section titled “Create a site”curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "id": "site_marketing", "name": "Marketing site" }' \ http://localhost:8081/workspaces/acme/sitesValidation:
idmatches^[A-Za-z0-9_-]{1,64}$. The alphabet is narrow because the site_id is the bearer in the legacy collector contract — it must fit cleanly in an HTTP header.nameis 1–128 chars after trimming.- Conflict returns
409 site_id_taken.
Create an api_key
Section titled “Create an api_key”curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "label": "CI bot", "scopes": ["ingest:write"] }' \ http://localhost:8081/workspaces/acme/keysResponse (201) — note plaintext is returned once:
{ "id": "<uuid>", "workspace_id": "<uuid>", "label": "CI bot", "scopes": ["ingest:write"], "created_at": "<iso>", "plaintext": "sk_acme_<32-char-base64>", "warning": "Save this key now. It will not be shown again. The server only stores its hash and cannot recover the plaintext."}The server stores only a hash. There is no recovery path — losing the plaintext means rotating to a new key.
Scopes
Section titled “Scopes”| Scope | Allows |
|---|---|
ingest:write | Calling POST /events and POST /s2s/events (when REQUIRE_API_KEY=tag|require). |
query:read | Analytics endpoints under /sites/:siteId/. |
admin | Wildcard — satisfies any scope check, plus workspace-management ops (audit, dlq, gdpr, key management). |
Multiple scopes per key are allowed — ["ingest:write", "query:read"] is
a common pairing for an integration that both sends and reads.
Revoke an api_key
Section titled “Revoke an api_key”curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ http://localhost:8081/workspaces/acme/keys/<key-uuid>Soft-delete: the row’s revoked_at is set to now(). Subsequent uses of
the plaintext fail auth. Idempotent: a second DELETE returns 404.
List api_keys
Section titled “List api_keys”curl -H "Authorization: Bearer $ADMIN_TOKEN" \ http://localhost:8081/workspaces/acme/keysReturns metadata only — the plaintext is never re-derivable from the
stored hash. Includes last_used_at so operators can spot stale keys.
Cross-workspace isolation
Section titled “Cross-workspace isolation”Non-admin actors who probe a foreign workspace receive 404 with the
same envelope as “not found”. The platform never confirms the existence
of a foreign workspace through status-code differential — that would be a
gentle enumeration vector for an attacker.
Auth modes summary
Section titled “Auth modes summary”| Endpoint set | Accepts | Requires |
|---|---|---|
POST /events (legacy) | Bearer <site_id> | REQUIRE_API_KEY=none (default) |
POST /events (api_key) | Bearer <api_key> + X-Site-Id: <site_id> | REQUIRE_API_KEY=tag|require, scope ingest:write |
| Analytics reads | Bearer $ADMIN_TOKEN OR Bearer <api_key> | query:read scope (or admin) |
| Workspace / key / GDPR / audit / DLQ | Bearer $ADMIN_TOKEN OR admin-scoped api_key | — |
Migration path: new deployments should set REQUIRE_API_KEY=tag to
accept both bearer formats while customer SDKs are still pinned to the
legacy site_id form, then flip to require after a deprecation window.