> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Machine Credentials — API Keys

> After this ships, a tenant's point of sale can call our API with a key that does exactly what it needs and nothing more, and rotating that key does not take the till offline.

## Context

An API key is the first thing an integrator touches, and its validation sits on the Runtime hot
path — every `track` call pays for it. It has to be fast and it has to be safe, and those pull
against each other.

DEC-D5 removes the usual complication: a key is **a subject with role assignments**, not a thing
with its own scope language. The same resolution and the same check serve a person and a machine,
so there is no second authorization path that can drift.

DEC-D6 removes another: **third parties are never required to hand over IP ranges**. A key plus
HMAC plus rate limits is the security model; an IP allowlist exists as opt-in hardening, off by
default, because demanding IPs from an integrator running on someone else's cloud is asking for
something they cannot give.

## Scope *(normative)*

* `identity.api_keys` with hashed secret and a clear-text prefix for identification.
* Role assignment to a key, through the same mechanism as a user.
* Rotation with an overlap window so no request fails during a rollover.
* `last_used_at` and last-used IP for detection.
* Optional expiry.
* Public write keys for browser track ingestion (F1b, with the widget).

## Non-scope *(normative)*

* OAuth2 clients — FS-IDN-0007. A key is issued by the tenant to themselves; an OAuth client is
  granted by the tenant to somebody else.
* The permission model itself — FS-IDN-0002.
* Per-tenant IP allowlisting, which is a later security-policy feature (opt-in, default off).

## Behaviour *(normative)*

1. A key is shown **exactly once, at creation**. It is stored hashed and cannot be retrieved
   afterwards — losing it means rotating it, and that is the correct trade.
2. Every key carries a **clear-text prefix** (`sk_live_a1b2…`). A key found in a public repository
   can be identified and revoked from the prefix alone, without anyone ever holding the secret.
3. A key is a subject with role assignments. There is no key-specific scope language, and the
   permission check is the same code path as for a user.
4. Rotation issues a new secret and keeps the old one valid for a **configurable overlap window,
   default 24 hours**. A rotation that breaks production is a rotation nobody performs, and a key
   nobody rotates is the real risk.
5. Validation is cached and performs **zero database queries** on the hot path once warm.
   Revocation invalidates the cache immediately, not on expiry.
6. `last_used_at` and the last-used IP are recorded asynchronously, never in the request path.
7. Keys may carry an optional expiry. An expiring key notifies its creator ahead of time.
8. Creating or rotating a key requires re-authentication (FS-IDN-0004) and writes an audit row.
9. A key belongs to **one organization** and can never act on another, whatever roles it carries.
10. **Public write keys** (F1b) are a distinct kind: track-ingestion only, safe to ship in a browser
    bundle, rate-limited harder and bound to configured origins. They are never granted any other
    permission.
11. FORBIDDEN: a key in a log, an event payload, an error message or a URL query parameter.

## Data *(normative)*

| Table               | Key invariants                                                                                                                                                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity.api_keys` | `organization_id`; `prefix` clear-text unique; `secret_hash`; `label`; `kind` secret\|public\_write; `expires_at` nullable; `rotated_from` nullable with `overlap_until`; `last_used_at`, `last_used_ip`; `revoked_at`; never deleted |

## API *(normative)*

| Endpoint                                 | Class      | Permission                          | Budget    |
| ---------------------------------------- | ---------- | ----------------------------------- | --------- |
| `GET /v1/identity/api-keys`              | Management | `identity.api_keys.read`            | p95 \<1 s |
| `POST /v1/identity/api-keys`             | Management | `identity.api_keys.create`, re-auth | p95 \<1 s |
| `POST /v1/identity/api-keys/{id}/rotate` | Management | `identity.api_keys.rotate`, re-auth | p95 \<1 s |
| `DELETE /v1/identity/api-keys/{id}`      | Management | `identity.api_keys.revoke`          | p95 \<1 s |

Listing never returns a secret, only prefixes and metadata.

## Events *(normative)*

`identity.api_key.created`, `.rotated` and `.revoked`, carrying the prefix and the actor — **never
the secret**.

## Acceptance criteria *(normative)*

1. A key is returned exactly once; every subsequent read returns only its prefix.
2. During the overlap window both the old and the new secret authenticate; after it, only the new.
3. Validation performs zero database queries once the cache is warm, verified by a query counter.
4. Revocation is effective on the very next request.
5. A key from organization A cannot act on organization B under any role.
6. A public write key can only reach track ingestion — every other endpoint rejects it.
7. `last_used_at` updates without adding measurable latency to the request.
8. Creating a key without re-authentication is rejected.
9. **Negative:** no secret appears in any log, event, error or URL — verified by a scan over a seeded
   request set.

## Execution

Single slice, synchronous command. Validation lives in the API's auth middleware with an Upstash
cache; the roles resolution is shared with FS-IDN-0002.

## Open questions

| # | Question                                                                  | Decides | By              |
| - | ------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Do keys expire by default, and after how long?                            | Daniel  | before approval |
| 2 | Rotation overlap window — 24 hours, or configurable per key up to 7 days? | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
