Skip to content

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_id is what the SDK ships in the bearer header (legacy auth) or in X-Site-Id (api_key auth).
  • An api_key is a workspace-scoped credential with one of three scopes: ingest:write, query:read, admin.
Terminal window
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme", "slug": "acme" }' \
http://localhost:8081/workspaces

Response (201):

{ "id": "<uuid>", "name": "Acme", "slug": "acme", "created_at": "<iso>" }

Validation:

  • slug matches ^[a-z][a-z0-9-]{0,63}$ and is unique. Conflict returns 409 slug_taken.
  • name is 1–128 chars after trimming.
  • The slug is not renameable in v1.0 — it is the URL-stable handle.
Terminal window
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/sites

Validation:

  • id matches ^[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.
  • name is 1–128 chars after trimming.
  • Conflict returns 409 site_id_taken.
Terminal window
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/keys

Response (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.

ScopeAllows
ingest:writeCalling POST /events and POST /s2s/events (when REQUIRE_API_KEY=tag|require).
query:readAnalytics endpoints under /sites/:siteId/.
adminWildcard — 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.

Terminal window
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.

Terminal window
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:8081/workspaces/acme/keys

Returns metadata only — the plaintext is never re-derivable from the stored hash. Includes last_used_at so operators can spot stale keys.

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.

Endpoint setAcceptsRequires
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 readsBearer $ADMIN_TOKEN OR Bearer <api_key>query:read scope (or admin)
Workspace / key / GDPR / audit / DLQBearer $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.