## 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>
222 lines
6.9 KiB
PHP
222 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace classes\auth;
|
|
|
|
/**
|
|
* Scope registry: the source of truth for API key scopes and
|
|
* role → scope defaults.
|
|
*
|
|
* This class is the canonical implementation that the parallel
|
|
* `app\auth\Scope` stub (introduced by TRU-149 / branch
|
|
* feat/TRU-149-route-scopes) will be replaced with once
|
|
* `feat/api-key-foundation` is merged. Until then the two can
|
|
* coexist; the middleware in `scope_middleware.php` continues
|
|
* to use the legacy stub.
|
|
*
|
|
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
|
|
* Two wildcard forms are recognised:
|
|
* - `*` — matches every scope.
|
|
* - `resource:*` — matches every action on a resource.
|
|
*
|
|
* Role defaults:
|
|
* - superuser: every scope (via "*" wildcard).
|
|
* - admin: customer:*, booking:*, subuser:*, invoice:*
|
|
* - customer: customer:read, booking:read, invoice:read
|
|
* - subuser: booking:read, booking:write
|
|
*
|
|
* The "self" / "assigned" qualifiers from the spec are *enforcement
|
|
* layer* concerns, not scope concerns — they live in the resolver
|
|
* that maps an authenticated principal to a customer/subuser record.
|
|
* Scopes only encode "can the caller read bookings at all", not
|
|
* "which bookings".
|
|
*/
|
|
final class scope_registry
|
|
{
|
|
// --- Customer resource ---
|
|
public const CUSTOMER_READ = 'customer:read';
|
|
public const CUSTOMER_WRITE = 'customer:write';
|
|
|
|
// --- Booking resource ---
|
|
public const BOOKING_READ = 'booking:read';
|
|
public const BOOKING_WRITE = 'booking:write';
|
|
|
|
// --- Subuser resource ---
|
|
public const SUBUSER_READ = 'subuser:read';
|
|
public const SUBUSER_WRITE = 'subuser:write';
|
|
|
|
// --- Invoice resource ---
|
|
public const INVOICE_READ = 'invoice:read';
|
|
public const INVOICE_WRITE = 'invoice:write';
|
|
|
|
// --- Superuser / admin resource ---
|
|
public const SUPERUSER_READ = 'superuser:read';
|
|
public const SUPERUSER_WRITE = 'superuser:write';
|
|
|
|
/**
|
|
* Canonical list of every concrete scope (no wildcards).
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function all(): array
|
|
{
|
|
return [
|
|
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
|
self::BOOKING_READ, self::BOOKING_WRITE,
|
|
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
|
self::INVOICE_READ, self::INVOICE_WRITE,
|
|
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Return the default scope set carried by a role. Wildcards are
|
|
* returned as-is; resolve them with `expand()` before checking
|
|
* membership if you need a flat list.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function scopesForRole(string $role): array
|
|
{
|
|
switch (strtolower(trim($role))) {
|
|
case 'superuser':
|
|
return ['*'];
|
|
case 'admin':
|
|
return [
|
|
'customer:*',
|
|
'booking:*',
|
|
'subuser:*',
|
|
'invoice:*',
|
|
];
|
|
case 'customer':
|
|
return [
|
|
self::CUSTOMER_READ,
|
|
self::BOOKING_READ,
|
|
self::INVOICE_READ,
|
|
];
|
|
case 'subuser':
|
|
return [
|
|
self::BOOKING_READ,
|
|
self::BOOKING_WRITE,
|
|
];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Does the granted scope (or wildcard) match the required scope?
|
|
*
|
|
* - "*" matches anything.
|
|
* - "customer:*" matches "customer:read" and "customer:write".
|
|
* - "customer:read" matches itself exactly.
|
|
*
|
|
* @param array<int, string> $granted
|
|
*/
|
|
public static function hasScope(array $granted, string $required): bool
|
|
{
|
|
$required = trim($required);
|
|
if ($required === '') {
|
|
return false;
|
|
}
|
|
foreach ($granted as $candidate) {
|
|
if (!is_string($candidate)) {
|
|
continue;
|
|
}
|
|
if (self::matches($candidate, $required)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Expand a list of scopes (which may include wildcards) into the
|
|
* full set of concrete scopes they grant. Useful for showing a
|
|
* user what their key can do, or for caching decisions.
|
|
*
|
|
* The wildcard "*" expands to the full `all()` set. A wildcard
|
|
* like "customer:*" expands to every concrete scope starting with
|
|
* "customer:". Duplicate entries are removed.
|
|
*
|
|
* @param array<int, string> $scopes
|
|
* @return array<int, string>
|
|
*/
|
|
public static function expand(array $scopes): array
|
|
{
|
|
$concrete = self::all();
|
|
$expanded = [];
|
|
|
|
foreach ($scopes as $scope) {
|
|
if (!is_string($scope)) {
|
|
continue;
|
|
}
|
|
$scope = trim($scope);
|
|
if ($scope === '') {
|
|
continue;
|
|
}
|
|
if ($scope === '*') {
|
|
$expanded = array_merge($expanded, $concrete);
|
|
continue;
|
|
}
|
|
if (str_ends_with($scope, ':*')) {
|
|
$prefix = substr($scope, 0, -2) . ':';
|
|
foreach ($concrete as $candidate) {
|
|
if (str_starts_with($candidate, $prefix)) {
|
|
$expanded[] = $candidate;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// Already concrete — pass through if it looks canonical.
|
|
if (in_array($scope, $concrete, true)) {
|
|
$expanded[] = $scope;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($expanded));
|
|
}
|
|
|
|
/**
|
|
* Internal wildcard matcher — public for testing.
|
|
*/
|
|
public static function matches(string $granted, string $required): bool
|
|
{
|
|
$granted = trim($granted);
|
|
$required = trim($required);
|
|
if ($granted === '' || $required === '') {
|
|
return false;
|
|
}
|
|
if ($granted === '*') {
|
|
return true;
|
|
}
|
|
if (str_ends_with($granted, ':*')) {
|
|
$prefix = substr($granted, 0, -2);
|
|
return str_starts_with($required, $prefix . ':');
|
|
}
|
|
return $granted === $required;
|
|
}
|
|
|
|
/**
|
|
* Validate a scope string. Returns true iff the value is either
|
|
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
|
|
* for a known resource.
|
|
*/
|
|
public static function isValid(string $scope): bool
|
|
{
|
|
$scope = trim($scope);
|
|
if ($scope === '' || $scope === '*') {
|
|
return $scope !== '';
|
|
}
|
|
if (str_ends_with($scope, ':*')) {
|
|
$prefix = substr($scope, 0, -2);
|
|
foreach (self::all() as $concrete) {
|
|
if (str_starts_with($concrete, $prefix . ':')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return in_array($scope, self::all(), true);
|
|
}
|
|
}
|