Files
api/services/nginx/app/tests/Unit/Auth/ScopeRegistryTest.php
T
Jeppe BandTRU-198 Subagent 18dc8e6e9c feat(auth): API key foundation (TRU-143+144+145) (#396)
## 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>
2026-08-17 13:54:04 +02:00

191 lines
8.4 KiB
PHP

<?php
use classes\auth\scope_registry;
it('exposes the canonical scope constants', function (): void {
expect(scope_registry::CUSTOMER_READ)->toBe('customer:read');
expect(scope_registry::CUSTOMER_WRITE)->toBe('customer:write');
expect(scope_registry::BOOKING_READ)->toBe('booking:read');
expect(scope_registry::BOOKING_WRITE)->toBe('booking:write');
expect(scope_registry::SUBUSER_READ)->toBe('subuser:read');
expect(scope_registry::SUBUSER_WRITE)->toBe('subuser:write');
expect(scope_registry::INVOICE_READ)->toBe('invoice:read');
expect(scope_registry::INVOICE_WRITE)->toBe('invoice:write');
expect(scope_registry::SUPERUSER_READ)->toBe('superuser:read');
expect(scope_registry::SUPERUSER_WRITE)->toBe('superuser:write');
});
it('returns every concrete scope from all()', function (): void {
$all = scope_registry::all();
expect($all)->toContain(scope_registry::CUSTOMER_READ);
expect($all)->toContain(scope_registry::CUSTOMER_WRITE);
expect($all)->toContain(scope_registry::BOOKING_READ);
expect($all)->toContain(scope_registry::BOOKING_WRITE);
expect($all)->toContain(scope_registry::SUBUSER_READ);
expect(scope_registry::SUBUSER_WRITE);
expect($all)->toContain(scope_registry::INVOICE_READ);
expect($all)->toContain(scope_registry::INVOICE_WRITE);
expect($all)->toContain(scope_registry::SUPERUSER_READ);
expect($all)->toContain(scope_registry::SUPERUSER_WRITE);
expect(count($all))->toBe(10);
expect(count(array_unique($all)))->toBe(10);
});
it('superuser defaults to the global wildcard', function (): void {
expect(scope_registry::scopesForRole('superuser'))->toBe(['*']);
});
it('admin defaults to all resource wildcards', function (): void {
$scopes = scope_registry::scopesForRole('admin');
expect($scopes)->toContain('customer:*');
expect($scopes)->toContain('booking:*');
expect($scopes)->toContain('subuser:*');
expect($scopes)->toContain('invoice:*');
expect($scopes)->not->toContain('superuser:*');
});
it('customer defaults to read-only on self resources', function (): void {
$scopes = scope_registry::scopesForRole('customer');
expect($scopes)->toBe([
scope_registry::CUSTOMER_READ,
scope_registry::BOOKING_READ,
scope_registry::INVOICE_READ,
]);
});
it('subuser defaults to booking read+write on assigned bookings', function (): void {
$scopes = scope_registry::scopesForRole('subuser');
expect($scopes)->toBe([
scope_registry::BOOKING_READ,
scope_registry::BOOKING_WRITE,
]);
});
it('unknown roles default to no scopes', function (): void {
expect(scope_registry::scopesForRole('nope'))->toBe([]);
expect(scope_registry::scopesForRole(''))->toBe([]);
expect(scope_registry::scopesForRole('SuperUser'))->toBe(['*']); // case-insensitive
});
it('hasScope matches an exact scope against itself', function (): void {
expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_READ))->toBeTrue();
});
it('hasScope rejects an exact scope against a different scope', function (): void {
expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_WRITE))->toBeFalse();
});
it('hasScope lets a wildcard match any concrete scope', function (): void {
expect(scope_registry::hasScope(['*'], scope_registry::INVOICE_READ))->toBeTrue();
expect(scope_registry::hasScope(['*'], scope_registry::SUPERUSER_WRITE))->toBeTrue();
});
it('hasScope resolves a resource wildcard to that resource only', function (): void {
expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_READ))->toBeTrue();
expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_WRITE))->toBeTrue();
expect(scope_registry::hasScope(['customer:*'], scope_registry::BOOKING_READ))->toBeFalse();
});
it('hasScope returns false on empty input', function (): void {
expect(scope_registry::hasScope([], 'booking:read'))->toBeFalse();
expect(scope_registry::hasScope(['booking:read'], ''))->toBeFalse();
});
it('hasScope ignores non-string granted entries', function (): void {
expect(scope_registry::hasScope([null, 123, 'booking:read'], 'booking:read'))->toBeTrue();
expect(scope_registry::hasScope([null, 123], 'booking:read'))->toBeFalse();
});
it('expand flattens a single wildcard to all concrete scopes', function (): void {
$expanded = scope_registry::expand(['*']);
expect(count($expanded))->toBe(10);
expect($expanded)->toContain(scope_registry::BOOKING_READ);
expect($expanded)->toContain(scope_registry::SUPERUSER_WRITE);
});
it('expand flattens resource wildcards', function (): void {
$expanded = scope_registry::expand(['booking:*']);
expect($expanded)->toBe([
scope_registry::BOOKING_READ,
scope_registry::BOOKING_WRITE,
]);
});
it('expand deduplicates results', function (): void {
$expanded = scope_registry::expand([
'booking:*',
scope_registry::BOOKING_READ,
'booking:write',
]);
expect($expanded)->toBe([
scope_registry::BOOKING_READ,
scope_registry::BOOKING_WRITE,
]);
});
it('expand drops unknown concrete scopes (no silent grant)', function (): void {
$expanded = scope_registry::expand(['booking:read', 'totally:made-up']);
expect($expanded)->toBe([scope_registry::BOOKING_READ]);
});
it('expand combines multiple wildcards and concrete scopes', function (): void {
$expanded = scope_registry::expand([
scope_registry::BOOKING_READ,
'customer:*',
]);
expect($expanded)->toContain(scope_registry::BOOKING_READ);
expect($expanded)->toContain(scope_registry::CUSTOMER_READ);
expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE);
expect(count($expanded))->toBe(3);
});
it('expand ignores empty and non-string entries', function (): void {
$expanded = scope_registry::expand([null, '', ' ', scope_registry::BOOKING_READ]);
expect($expanded)->toBe([scope_registry::BOOKING_READ]);
});
it('superuser role resolves to all scopes via expand', function (): void {
$expanded = scope_registry::expand(scope_registry::scopesForRole('superuser'));
expect(count($expanded))->toBe(10);
});
it('admin role expands to all non-superuser scopes', function (): void {
$expanded = scope_registry::expand(scope_registry::scopesForRole('admin'));
expect($expanded)->toContain(scope_registry::CUSTOMER_READ);
expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE);
expect($expanded)->toContain(scope_registry::BOOKING_READ);
expect($expanded)->toContain(scope_registry::BOOKING_WRITE);
expect($expanded)->toContain(scope_registry::SUBUSER_READ);
expect($expanded)->toContain(scope_registry::SUBUSER_WRITE);
expect($expanded)->toContain(scope_registry::INVOICE_READ);
expect($expanded)->toContain(scope_registry::INVOICE_WRITE);
expect($expanded)->not->toContain(scope_registry::SUPERUSER_READ);
expect($expanded)->not->toContain(scope_registry::SUPERUSER_WRITE);
expect(count($expanded))->toBe(8);
});
it('customer role does not gain write or subuser scopes', function (): void {
$expanded = scope_registry::expand(scope_registry::scopesForRole('customer'));
expect($expanded)->not->toContain(scope_registry::CUSTOMER_WRITE);
expect($expanded)->not->toContain(scope_registry::BOOKING_WRITE);
expect($expanded)->not->toContain(scope_registry::SUBUSER_READ);
expect($expanded)->not->toContain(scope_registry::INVOICE_WRITE);
});
it('isValid accepts canonical scopes, wildcards, and resource wildcards', function (): void {
expect(scope_registry::isValid('*'))->toBeTrue();
expect(scope_registry::isValid('customer:*'))->toBeTrue();
expect(scope_registry::isValid(scope_registry::BOOKING_READ))->toBeTrue();
expect(scope_registry::isValid('totally:made-up'))->toBeFalse();
expect(scope_registry::isValid(''))->toBeFalse();
expect(scope_registry::isValid(' '))->toBeFalse();
expect(scope_registry::isValid('unknown:*'))->toBeFalse();
});
it('role default + hasScope composes correctly for customer:read on customer role', function (): void {
$granted = scope_registry::scopesForRole('customer');
expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_READ))->toBeTrue();
expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_WRITE))->toBeFalse();
expect(scope_registry::hasScope($granted, scope_registry::SUBUSER_READ))->toBeFalse();
});