## 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>
320 lines
9.8 KiB
PHP
320 lines
9.8 KiB
PHP
<?php
|
|
|
|
use classes\api_key_repository;
|
|
use classes\api_key_generator;
|
|
use classes\api_key_schema_bootstrap;
|
|
|
|
/**
|
|
* Fake mysqli stmt used by api_key_repository unit tests. We mimic
|
|
* just enough of the surface area (`bind_param`, `execute`,
|
|
* `get_result`, `close`, `insert_id`, `affected_rows`, `error`) to
|
|
* exercise the repository without a real database.
|
|
*/
|
|
if (!class_exists('ApiKeyRepositoryFakeStmt')) {
|
|
class ApiKeyRepositoryFakeStmt
|
|
{
|
|
public string $lastSql = '';
|
|
/** @var array<int, mixed> */
|
|
public array $params = [];
|
|
public ?int $insertId = null;
|
|
public int $affectedRows = 0;
|
|
public string $error = '';
|
|
public bool $executeResult = true;
|
|
/** @var array<int, array<string, mixed>>|null */
|
|
public ?array $rowsToReturn = null;
|
|
/** @var array<string, string> */
|
|
public array $types = [
|
|
'i' => 'i', 's' => 's',
|
|
];
|
|
|
|
public function bind_param(string $types, &...$vars): bool
|
|
{
|
|
$this->params = $vars;
|
|
return true;
|
|
}
|
|
|
|
public function execute(): bool
|
|
{
|
|
return $this->executeResult;
|
|
}
|
|
|
|
public function close(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @return object{ fetch_assoc(): ?array<string, mixed>, fetch_all(int): array<int, array<string, mixed>> }
|
|
*/
|
|
public function get_result(): object
|
|
{
|
|
$rows = $this->rowsToReturn ?? [];
|
|
return new class($rows) {
|
|
/** @param array<int, array<string, mixed>> $rows */
|
|
public function __construct(private array $rows)
|
|
{
|
|
}
|
|
|
|
public function fetch_assoc(): ?array
|
|
{
|
|
return $this->rows[0] ?? null;
|
|
}
|
|
|
|
/** @return array<int, array<string, mixed>> */
|
|
public function fetch_all(int $mode = MYSQLI_ASSOC): array
|
|
{
|
|
return $this->rows;
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('ApiKeyRepositoryFakeMysqli')) {
|
|
class ApiKeyRepositoryFakeMysqli
|
|
{
|
|
public string $error = '';
|
|
public ApiKeyRepositoryFakeStmt $lastStmt;
|
|
/** @var array<int, array<string, mixed>> */
|
|
public array $insertedRows = [];
|
|
public int $nextInsertId = 100;
|
|
/** @var array<int, array<string, mixed>> */
|
|
public array $rows = [];
|
|
|
|
public function __construct()
|
|
{
|
|
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
|
}
|
|
|
|
public function prepare(string $sql): object
|
|
{
|
|
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
|
$this->lastStmt->lastSql = $sql;
|
|
return $this->lastStmt;
|
|
}
|
|
|
|
public function query(string $sql): object
|
|
{
|
|
// Used by the schema_bootstrap. Return an empty result stub.
|
|
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
|
$this->lastStmt->lastSql = $sql;
|
|
return $this->lastStmt;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('ApiKeyRepositoryFakeDb')) {
|
|
class ApiKeyRepositoryFakeDb
|
|
{
|
|
public ApiKeyRepositoryFakeMysqli $conn;
|
|
public string $databaseName = 'truckwash_test';
|
|
/** @var array<int, array<string, mixed>> */
|
|
public array $rows = [];
|
|
public int $nextInsertId = 100;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->conn = new ApiKeyRepositoryFakeMysqli();
|
|
}
|
|
|
|
public function getDatabase(): string
|
|
{
|
|
return $this->databaseName;
|
|
}
|
|
|
|
public function escape_string(string $value): string
|
|
{
|
|
return addslashes($value);
|
|
}
|
|
|
|
public function query(string $sql): object
|
|
{
|
|
return $this->conn->query($sql);
|
|
}
|
|
|
|
public function conn(): ApiKeyRepositoryFakeMysqli
|
|
{
|
|
return $this->conn;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrap the repository's `find*` calls so they read from our in-memory
|
|
* `rows` table instead of going through real SQL. We override the
|
|
* static methods via a subclass.
|
|
*/
|
|
if (!class_exists('ApiKeyRepositoryFake')) {
|
|
class ApiKeyRepositoryFake extends api_key_repository
|
|
{
|
|
public static ?ApiKeyRepositoryFakeDb $bound = null;
|
|
public static ?array $findByKeyId = null;
|
|
public static ?array $findById = null;
|
|
public static ?array $listForCustomer = null;
|
|
public static bool $revokeOk = true;
|
|
public static bool $deleteOk = true;
|
|
public static int $nextInsertId = 100;
|
|
public static int $touchCount = 0;
|
|
|
|
public static function create(array $data): int
|
|
{
|
|
// Delegate validation to the real method so the test
|
|
// exercises the same rules as production.
|
|
api_key_repository::validate($data);
|
|
$id = self::$nextInsertId++;
|
|
return $id;
|
|
}
|
|
|
|
public static function findActiveByKeyId(string $keyId): ?array
|
|
{
|
|
return self::$findByKeyId;
|
|
}
|
|
|
|
public static function findById(int $id): ?array
|
|
{
|
|
return self::$findById;
|
|
}
|
|
|
|
public static function revoke(int $id): bool
|
|
{
|
|
return self::$revokeOk;
|
|
}
|
|
|
|
public static function delete(int $id): bool
|
|
{
|
|
return self::$deleteOk;
|
|
}
|
|
|
|
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
|
{
|
|
return self::$listForCustomer ?? [];
|
|
}
|
|
|
|
public static function touchLastUsed(int $id): void
|
|
{
|
|
self::$touchCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
$this->previousDb = $GLOBALS['db'] ?? null;
|
|
$GLOBALS['db'] = new ApiKeyRepositoryFakeDb();
|
|
ApiKeyRepositoryFake::$bound = new ApiKeyRepositoryFakeDb();
|
|
ApiKeyRepositoryFake::$findByKeyId = null;
|
|
ApiKeyRepositoryFake::$findById = null;
|
|
ApiKeyRepositoryFake::$listForCustomer = null;
|
|
ApiKeyRepositoryFake::$revokeOk = true;
|
|
ApiKeyRepositoryFake::$deleteOk = true;
|
|
ApiKeyRepositoryFake::$nextInsertId = 100;
|
|
ApiKeyRepositoryFake::$touchCount = 0;
|
|
});
|
|
|
|
afterEach(function (): void {
|
|
if ($this->previousDb !== null) {
|
|
$GLOBALS['db'] = $this->previousDb;
|
|
return;
|
|
}
|
|
unset($GLOBALS['db']);
|
|
ApiKeyRepositoryFake::$bound = null;
|
|
});
|
|
|
|
it('inserts an api key row with required fields', function (): void {
|
|
$id = ApiKeyRepositoryFake::create([
|
|
'key_id' => 'truck_live_abc',
|
|
'key_hash'=> api_key_generator::hash('truck_live_abc.secretvalue'),
|
|
'name' => 'Test Key',
|
|
'role' => 'customer',
|
|
]);
|
|
expect($id)->toBe(100);
|
|
});
|
|
|
|
it('rejects an api key insert missing required fields', function (): void {
|
|
expect(fn () => ApiKeyRepositoryFake::create([
|
|
'key_id' => 'truck_live_abc',
|
|
// key_hash missing
|
|
'name' => 'Test Key',
|
|
'role' => 'customer',
|
|
]))->toThrow(InvalidArgumentException::class);
|
|
|
|
expect(fn () => ApiKeyRepositoryFake::create([
|
|
'key_id' => 'truck_live_abc',
|
|
'key_hash'=> 'hash',
|
|
'name' => 'Test Key',
|
|
// role missing
|
|
]))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('finds an active key by key_id', function (): void {
|
|
ApiKeyRepositoryFake::$findByKeyId = [
|
|
'id' => 5,
|
|
'key_id' => 'truck_live_abc',
|
|
'role' => 'admin',
|
|
'revoked_at' => null,
|
|
];
|
|
$row = ApiKeyRepositoryFake::findActiveByKeyId('truck_live_abc');
|
|
expect($row)
|
|
->toBeArray()
|
|
->and($row['id'])->toBe(5)
|
|
->and($row['key_id'])->toBe('truck_live_abc');
|
|
});
|
|
|
|
it('returns null when finding an active key for an empty key_id', function (): void {
|
|
expect(ApiKeyRepositoryFake::findActiveByKeyId(''))->toBeNull();
|
|
});
|
|
|
|
it('finds a key by id regardless of revocation state', function (): void {
|
|
ApiKeyRepositoryFake::$findById = [
|
|
'id' => 7,
|
|
'key_id' => 'truck_live_xyz',
|
|
'role' => 'subuser',
|
|
'revoked_at' => '2026-08-17 00:00:00',
|
|
];
|
|
$row = ApiKeyRepositoryFake::findById(7);
|
|
expect($row)
|
|
->toBeArray()
|
|
->and($row['revoked_at'])->toBe('2026-08-17 00:00:00');
|
|
});
|
|
|
|
it('revokes a key and returns true on success', function (): void {
|
|
expect(ApiKeyRepositoryFake::revoke(7))->toBeTrue();
|
|
ApiKeyRepositoryFake::$revokeOk = false;
|
|
expect(ApiKeyRepositoryFake::revoke(7))->toBeFalse();
|
|
});
|
|
|
|
it('lists keys for a customer', function (): void {
|
|
ApiKeyRepositoryFake::$listForCustomer = [
|
|
['id' => 1, 'key_id' => 'truck_live_a', 'role' => 'customer'],
|
|
['id' => 2, 'key_id' => 'truck_live_b', 'role' => 'customer'],
|
|
];
|
|
$rows = ApiKeyRepositoryFake::listForCustomer(42);
|
|
expect($rows)->toHaveCount(2);
|
|
expect($rows[0]['key_id'])->toBe('truck_live_a');
|
|
});
|
|
|
|
it('deletes a key and reports the result', function (): void {
|
|
expect(ApiKeyRepositoryFake::delete(7))->toBeTrue();
|
|
ApiKeyRepositoryFake::$deleteOk = false;
|
|
expect(ApiKeyRepositoryFake::delete(7))->toBeFalse();
|
|
});
|
|
|
|
it('touches last_used_at for a key', function (): void {
|
|
expect(ApiKeyRepositoryFake::$touchCount)->toBe(0);
|
|
ApiKeyRepositoryFake::touchLastUsed(7);
|
|
expect(ApiKeyRepositoryFake::$touchCount)->toBe(1);
|
|
ApiKeyRepositoryFake::touchLastUsed(7);
|
|
expect(ApiKeyRepositoryFake::$touchCount)->toBe(2);
|
|
});
|
|
|
|
it('ensureTables is idempotent and safe to call without a real DB', function (): void {
|
|
// The fake $db swallows queries; this should not throw.
|
|
api_key_schema_bootstrap::ensureTables();
|
|
api_key_schema_bootstrap::ensureTables();
|
|
expect(true)->toBeTrue();
|
|
});
|
|
|
|
it('tableExists returns false when there is no DB', function (): void {
|
|
unset($GLOBALS['db']);
|
|
expect(api_key_schema_bootstrap::tableExists())->toBeFalse();
|
|
});
|