## Summary
API key foundation for the Truck Wash API: data model, key generation,
and scope system.
Three atomic commits, one PR:
- **TRU-143** — `api_keys` table + repository wrapper
- **TRU-144** — Key generation + argon2id hashing
- **TRU-145** — Scope registry with role bindings
## Linear
- TRU-143 — [Backend] API key data model + storage schema
- TRU-144 — [Backend] Key generation + argon2id hashing
- TRU-145 — [Backend] Scope system + role bindings
## Files
**Production code:**
- `services/nginx/app/classes/api_key_schema_bootstrap.php` — idempotent
`CREATE TABLE IF NOT EXISTS` for `api_keys`
- `services/nginx/app/classes/api_key_repository.php` — `create`,
`findActiveByKeyId`, `findById`, `revoke`, `delete`, `listForCustomer`,
`touchLastUsed`
- `services/nginx/app/classes/api_key_generator.php` — `generateKeyId`,
`generateSecret`, `formatKey`, `hash` (argon2id), `verify`, `parseKey`
- `services/nginx/app/classes/auth/scope_registry.php` — canonical scope
constants + role defaults + `hasScope` / `expand` / `matches` /
`isValid`
-
`services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php`
— human-readable migration record
**Tests (all new, all passing):**
- `tests/Unit/Auth/ApiKeyGeneratorTest.php` — 15 tests
- `tests/Unit/Auth/ApiKeyRepositoryTest.php` — 11 tests
- `tests/Unit/Auth/ScopeRegistryTest.php` — 24 tests
**Totals:** 50 new tests, 140 assertions. Full unit suite: 1329 passed
(up from 1279 baseline), 0 new failures (10 pre-existing unrelated
failures remain).
## Schema
```sql
CREATE TABLE api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```
## Key format
```
<prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
```
- `key_id` is stored in plain text (the lookup key).
- The secret is **never** stored in plain text — only the argon2id hash.
- The full key is shown to the user exactly once at creation.
- Prefix is configurable via `config('api_key.prefix', 'truck')`.
## Role → scope defaults
| Role | Scopes |
|---|---|
| superuser | `*` |
| admin | `customer:*`, `booking:*`, `subuser:*`, `invoice:*` |
| customer | `customer:read`, `booking:read`, `invoice:read` |
| subuser | `booking:read`, `booking:write` |
Wildcard support: `*` matches everything; `resource:*` matches every
action on a resource.
## Design notes
- **No migration framework** — this codebase uses
`*_schema_bootstrap.php` files for idempotent table creation. The
`database/migrations/` file is kept as a human-readable change record.
- **No Eloquent** — the repository is a thin wrapper over the existing
mysqli `$db` global, matching the pattern in `classes/orders_o.php`,
`classes/invoice_store.php`, etc.
- **Coexistence with TRU-149** — `classes/auth/scope_registry.php`
(TRU-145) is the canonical implementation; the local `app\auth\Scope`
stub from `feat/TRU-149-route-scopes` is documented in the class header
as the thing this will replace once that branch merges. The two can
coexist in the meantime.
- **"Self" / "assigned" qualifiers** (e.g. "customer can only read their
own bookings") are intentionally **out of scope** here — they live in
the resolver layer that maps an authenticated principal to a
customer/subuser record. Scopes only encode "can the caller read
bookings at all".
- **No secrets in code** — the generator uses `random_bytes()` with
rejection sampling (no modulo bias). No test fixtures contain real keys.
## Checklist
- [x] All new tests pass (50/50)
- [x] No new test failures in the full unit suite
- [x] No PHP syntax errors
- [x] No committed secrets
- [x] No modifications to existing test files
- [x] No modifications to `openclaw.json` or any config files
---------
Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
250 lines
7.9 KiB
PHP
250 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Repository for the `api_keys` table.
|
|
*
|
|
* This is a thin procedural wrapper that uses the project's existing
|
|
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
|
|
* other repositories in this codebase (see `classes/orders_o.php`,
|
|
* `classes/invoice_store.php`, etc.).
|
|
*
|
|
* Records are returned as associative arrays. The caller is expected
|
|
* to interact with them as plain dicts; there is no dedicated model
|
|
* class for api keys.
|
|
*/
|
|
class api_key_repository
|
|
{
|
|
public const TABLE = 'api_keys';
|
|
|
|
private static function db()
|
|
{
|
|
global $db;
|
|
if (!isset($db) || !is_object($db)) {
|
|
throw new Exception('Database connection ($db) is not available');
|
|
}
|
|
// Lazy-create the table on first use so callers don't have to
|
|
// remember to call ensureTables().
|
|
if (class_exists(api_key_schema_bootstrap::class)) {
|
|
api_key_schema_bootstrap::ensureTables();
|
|
}
|
|
return $db;
|
|
}
|
|
|
|
/**
|
|
* Validate the input data for create(). Exposed so test doubles
|
|
* can exercise the same validation without touching a real DB.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function validate(array $data): void
|
|
{
|
|
$required = ['key_id', 'key_hash', 'name', 'role'];
|
|
foreach ($required as $field) {
|
|
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
|
|
throw new \InvalidArgumentException("Missing required field: {$field}");
|
|
}
|
|
}
|
|
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
|
|
if (!in_array($data['role'], $allowedRoles, true)) {
|
|
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return int inserted id
|
|
*/
|
|
public static function create(array $data): int
|
|
{
|
|
self::validate($data);
|
|
|
|
$db = self::db();
|
|
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
|
|
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
|
|
: null;
|
|
|
|
$stmt = $db->conn()->prepare(
|
|
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
if ($stmt === false) {
|
|
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
|
|
}
|
|
|
|
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
|
|
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
|
|
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
|
|
? (string)$data['expires_at']
|
|
: null;
|
|
|
|
$stmt->bind_param(
|
|
'sssssiss',
|
|
$data['key_id'],
|
|
$data['key_hash'],
|
|
$data['name'],
|
|
$data['role'],
|
|
$scopesJson,
|
|
$customerId,
|
|
$createdBy,
|
|
$expiresAt
|
|
);
|
|
|
|
if (!$stmt->execute()) {
|
|
$err = $stmt->error;
|
|
$stmt->close();
|
|
throw new Exception('Failed to insert api_key: ' . $err);
|
|
}
|
|
$id = $stmt->insert_id;
|
|
$stmt->close();
|
|
return (int)$id;
|
|
}
|
|
|
|
/**
|
|
* Find a non-revoked key by its public key_id.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function findActiveByKeyId(string $keyId): ?array
|
|
{
|
|
if ($keyId === '') {
|
|
return null;
|
|
}
|
|
$db = self::db();
|
|
$stmt = $db->conn()->prepare(
|
|
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
|
|
);
|
|
if ($stmt === false) {
|
|
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
|
}
|
|
$stmt->bind_param('s', $keyId);
|
|
if (!$stmt->execute()) {
|
|
$err = $stmt->error;
|
|
$stmt->close();
|
|
throw new Exception('Failed to execute select: ' . $err);
|
|
}
|
|
$result = $stmt->get_result();
|
|
$row = $result ? $result->fetch_assoc() : null;
|
|
$stmt->close();
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* Find any key by id (including revoked).
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function findById(int $id): ?array
|
|
{
|
|
$db = self::db();
|
|
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
|
|
if ($stmt === false) {
|
|
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
|
}
|
|
$stmt->bind_param('i', $id);
|
|
if (!$stmt->execute()) {
|
|
$err = $stmt->error;
|
|
$stmt->close();
|
|
throw new Exception('Failed to execute select: ' . $err);
|
|
}
|
|
$result = $stmt->get_result();
|
|
$row = $result ? $result->fetch_assoc() : null;
|
|
$stmt->close();
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
|
|
*/
|
|
public static function revoke(int $id): bool
|
|
{
|
|
$db = self::db();
|
|
$stmt = $db->conn()->prepare(
|
|
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
|
|
);
|
|
if ($stmt === false) {
|
|
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
|
|
}
|
|
$stmt->bind_param('i', $id);
|
|
$ok = $stmt->execute();
|
|
$affected = $stmt->affected_rows;
|
|
$stmt->close();
|
|
return $ok && $affected > 0;
|
|
}
|
|
|
|
/**
|
|
* Bump last_used_at for a key. Best-effort: failures are swallowed
|
|
* because this is a hot-path observability hook and must not
|
|
* break the request.
|
|
*/
|
|
public static function touchLastUsed(int $id): void
|
|
{
|
|
try {
|
|
$db = self::db();
|
|
$stmt = $db->conn()->prepare(
|
|
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
|
|
);
|
|
if ($stmt === false) {
|
|
return;
|
|
}
|
|
$stmt->bind_param('i', $id);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
} catch (Throwable) {
|
|
// intentionally ignored
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List keys for a customer, newest first.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
|
{
|
|
$db = self::db();
|
|
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
|
|
if (!$includeRevoked) {
|
|
$sql .= ' AND revoked_at IS NULL';
|
|
}
|
|
$sql .= ' ORDER BY id DESC';
|
|
$stmt = $db->conn()->prepare($sql);
|
|
if ($stmt === false) {
|
|
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
|
|
}
|
|
$stmt->bind_param('i', $customerId);
|
|
if (!$stmt->execute()) {
|
|
$err = $stmt->error;
|
|
$stmt->close();
|
|
throw new Exception('Failed to execute list: ' . $err);
|
|
}
|
|
$result = $stmt->get_result();
|
|
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
|
$stmt->close();
|
|
return is_array($rows) ? $rows : [];
|
|
}
|
|
|
|
/**
|
|
* Delete a key by id. Returns true if a row was removed.
|
|
* Generally prefer `revoke()` over `delete()` so audit trails
|
|
* stay intact.
|
|
*/
|
|
public static function delete(int $id): bool
|
|
{
|
|
$db = self::db();
|
|
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
|
|
if ($stmt === false) {
|
|
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
|
|
}
|
|
$stmt->bind_param('i', $id);
|
|
$ok = $stmt->execute();
|
|
$affected = $stmt->affected_rows;
|
|
$stmt->close();
|
|
return $ok && $affected > 0;
|
|
}
|
|
}
|