feat: API keys, per-tenant quotas, and per-key rate limits #137

Merged
manlycucumber merged 1 commit from feature/21-api-keys-quotas into develop 2026-07-03 06:10:41 +00:00
manlycucumber commented 2026-07-03 06:09:48 +00:00 (Migrated from github.com)

Adds the metering layer (epic #21) behind a storage seam, disabled until a key store is configured — so the open service (and every existing test) is unchanged, and live MySQL wiring is a deploy step.

What's here

  • Auth\KeyStore (#22) — the one persistence seam, with InMemoryKeyStore (the tested double) and PdoKeyStore (MySQL, sql/schema.sql). Counters are aggregate — one upserted row per (tenant, month) and per (key, minute), never a row per request.
  • Auth\KeyIssuer (#23) — issue / rotate / revoke. Only sha256(secret) is stored; the plaintext is shown once. bin/api-key.php is the maintainer CLI.
  • Auth\AccessControl (#24/#25/#26) — authenticate via Authorization: Bearer or X-API-Key, scope to the tenant, enforce the key's per-minute rate limit and the tenant's monthly quota, echo X-RateLimit-*. 401 unauthenticated; 429 rate_limited (with Retry-After) / 429 quota_exceeded. GuardedHandler wraps the calendar handlers; health + meta stay public.
  • Kernel gains an optional AccessControl (open by default via fromEnvironment()) and carries an ApiException's headers onto the error response.

Verification

  • composer check green: phpcs, PHPStan [OK] (52 source files), PHPUnit 88 tests / 303 assertions — covering the store, issue/rotate/revoke, 401 paths, valid-key grant + rate headers, and 429 rate/quota at both unit and kernel level.

Scope notes

CI has no database, so the auth/quota/rate logic is proven against the in-memory store; PdoKeyStore is static-checked and integration-tested against a live MySQL at deploy (a maintainer step, alongside provisioning INTROIBO_DB_DSN). Admin rebuild/purge + AUP/terms endpoints are the next PR (epic #27).

Refs #21 #22 #23 #24 #25 #26

Adds the metering layer (epic #21) behind a storage seam, **disabled until a key store is configured** — so the open service (and every existing test) is unchanged, and live MySQL wiring is a deploy step. ## What's here - **`Auth\KeyStore`** (#22) — the one persistence seam, with **`InMemoryKeyStore`** (the tested double) and **`PdoKeyStore`** (MySQL, [`sql/schema.sql`](../blob/develop/sql/schema.sql)). Counters are **aggregate** — one upserted row per (tenant, month) and per (key, minute), never a row per request. - **`Auth\KeyIssuer`** (#23) — issue / rotate / revoke. Only `sha256(secret)` is stored; the plaintext is shown once. `bin/api-key.php` is the maintainer CLI. - **`Auth\AccessControl`** (#24/#25/#26) — authenticate via `Authorization: Bearer` or `X-API-Key`, scope to the tenant, enforce the key's **per-minute rate limit** and the tenant's **monthly quota**, echo `X-RateLimit-*`. `401 unauthenticated`; `429 rate_limited` (with `Retry-After`) / `429 quota_exceeded`. `GuardedHandler` wraps the calendar handlers; **health + meta stay public**. - **Kernel** gains an optional `AccessControl` (open by default via `fromEnvironment()`) and carries an `ApiException`'s headers onto the error response. ## Verification - `composer check` green: phpcs, PHPStan `[OK]` (52 source files), **PHPUnit 88 tests / 303 assertions** — covering the store, issue/rotate/revoke, 401 paths, valid-key grant + rate headers, and 429 rate/quota at both unit and kernel level. ## Scope notes CI has no database, so the auth/quota/rate **logic** is proven against the in-memory store; `PdoKeyStore` is static-checked and integration-tested against a live MySQL at deploy (a maintainer step, alongside provisioning `INTROIBO_DB_DSN`). Admin rebuild/purge + AUP/terms endpoints are the next PR (epic #27). Refs #21 #22 #23 #24 #25 #26
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Directorium/Api!137
No description provided.