From 97d0b32ed5911bdba1d4ec1f04f37a43fb086630 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 19:34:40 +0000 Subject: [PATCH] test(scopes): add comprehensive API scope enforcement tests (P2-008) - Implement wildcard scope matching in ApiKey::hasScope(): - Resource wildcards (posts:*) grant all actions on resource - Action wildcards (*:read) grant action on all resources - Full wildcard (*) grants universal access - Add hasAnyScope() method for OR-style scope checking - Add extensive tests for: - EnforceApiScope middleware validation - CheckApiScope middleware with explicit requirements - Wildcard scope matching (posts:*, *:read, *) - Scope inheritance and hierarchy - Error response formatting with required/provided scopes - Edge cases (null scopes, case sensitivity, nested colons) Co-Authored-By: Claude Opus 4.5 --- TODO.md | 30 +- src/Api/Models/ApiKey.php | 51 +- .../Tests/Feature/ApiScopeEnforcementTest.php | 510 ++++++++++++++++++ 3 files changed, 577 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index aee1831..4caab3d 100644 --- a/TODO.md +++ b/TODO.md @@ -23,20 +23,22 @@ - **Completed:** 29 January 2026 - **File:** `src/Api/Tests/Feature/WebhookDeliveryTest.php` -- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits - - [ ] Test per-tier rate limits - - [ ] Test rate limit headers - - [ ] Test quota exceeded responses - - [ ] Test workspace-scoped limits - - [ ] Test burst allowance - - **Estimated effort:** 3-4 hours +- [x] **Test Coverage: Rate Limiting** - Test tier-based limits + - [x] Test per-tier rate limits + - [x] Test rate limit headers + - [x] Test quota exceeded responses + - [x] Test workspace-scoped limits + - [x] Test burst allowance + - **Completed:** 29 January 2026 + - **File:** `src/Api/Tests/Feature/RateLimitingTest.php` -- [ ] **Test Coverage: Scope Enforcement** - Test permission system - - [ ] Test EnforceApiScope middleware - - [ ] Test wildcard scopes (posts:*, *:read) - - [ ] Test scope inheritance - - [ ] Test scope validation errors - - **Estimated effort:** 3-4 hours +- [x] **Test Coverage: Scope Enforcement** - Test permission system + - [x] Test EnforceApiScope middleware + - [x] Test wildcard scopes (posts:*, *:read) + - [x] Test scope inheritance + - [x] Test scope validation errors + - **Completed:** 29 January 2026 + - **File:** `src/Api/Tests/Feature/ApiScopeEnforcementTest.php` ### Medium Priority @@ -251,5 +253,7 @@ - [x] **API Key Security Tests** - Comprehensive bcrypt hashing and rotation tests (P1-002) - [x] **Webhook System Signature Tests** - HMAC-SHA256 signature verification tests (P1-003) - [x] **API Key IP Whitelisting** - allowed_ips column with IPv4/IPv6 and CIDR support (P1-004) +- [x] **Scope Enforcement Tests** - Wildcard scopes, inheritance, and error responses (P2-008) +- [x] **Rate Limiting Tests** - Tier-based limits with headers and burst allowance *See `changelog/2026/jan/` for completed features.* diff --git a/src/Api/Models/ApiKey.php b/src/Api/Models/ApiKey.php index 0ee34e3..3c229ee 100644 --- a/src/Api/Models/ApiKey.php +++ b/src/Api/Models/ApiKey.php @@ -266,10 +266,45 @@ class ApiKey extends Model /** * Check if key has a specific scope. + * + * Supports wildcard matching: + * - `posts:*` grants all actions on posts resource + * - `*:read` grants read action on all resources + * - `*` grants full access to everything */ public function hasScope(string $scope): bool { - return in_array($scope, $this->scopes ?? [], true); + $scopes = $this->scopes ?? []; + + // Exact match + if (in_array($scope, $scopes, true)) { + return true; + } + + // Full wildcard (grants everything) + if (in_array('*', $scopes, true)) { + return true; + } + + // Check for resource:action pattern + if (! str_contains($scope, ':')) { + // Simple scope (read, write, delete) - no wildcard matching + return false; + } + + [$resource, $action] = explode(':', $scope, 2); + + // Resource wildcard (e.g., posts:* grants posts:read, posts:write, etc.) + if (in_array("{$resource}:*", $scopes, true)) { + return true; + } + + // Action wildcard (e.g., *:read grants posts:read, users:read, etc.) + if (in_array("*:{$action}", $scopes, true)) { + return true; + } + + return false; } /** @@ -286,6 +321,20 @@ class ApiKey extends Model return true; } + /** + * Check if key has any of the specified scopes. + */ + public function hasAnyScope(array $scopes): bool + { + foreach ($scopes as $scope) { + if ($this->hasScope($scope)) { + return true; + } + } + + return false; + } + /** * Check if key is expired. */ diff --git a/src/Api/Tests/Feature/ApiScopeEnforcementTest.php b/src/Api/Tests/Feature/ApiScopeEnforcementTest.php index ec6f630..6da35e8 100644 --- a/src/Api/Tests/Feature/ApiScopeEnforcementTest.php +++ b/src/Api/Tests/Feature/ApiScopeEnforcementTest.php @@ -230,3 +230,513 @@ describe('Non-API Key Auth', function () { // In practice, routes use either 'auth' OR 'api.auth', not both }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard Scopes - Resource Wildcards (posts:*) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Resource Wildcard Scopes', function () { + it('grants access with resource wildcard scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Admin Key', + ['posts:*'] + ); + + $apiKey = $result['api_key']; + + // Resource wildcard should grant all actions on that resource + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + expect($apiKey->hasScope('posts:publish'))->toBeTrue(); + }); + + it('resource wildcard does not grant access to other resources', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Only Key', + ['posts:*'] + ); + + $apiKey = $result['api_key']; + + // Should not grant access to other resources + expect($apiKey->hasScope('users:read'))->toBeFalse(); + expect($apiKey->hasScope('analytics:read'))->toBeFalse(); + expect($apiKey->hasScope('webhooks:write'))->toBeFalse(); + }); + + it('multiple resource wildcards work independently', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Content Admin Key', + ['posts:*', 'pages:*'] + ); + + $apiKey = $result['api_key']; + + // Both resource wildcards should work + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + expect($apiKey->hasScope('pages:write'))->toBeTrue(); + expect($apiKey->hasScope('pages:publish'))->toBeTrue(); + + // Others should not + expect($apiKey->hasScope('users:read'))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard Scopes - Action Wildcards (*:read) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Action Wildcard Scopes', function () { + it('grants read access to all resources with action wildcard', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read Only All Key', + ['*:read'] + ); + + $apiKey = $result['api_key']; + + // Action wildcard should grant read on all resources + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('analytics:read'))->toBeTrue(); + expect($apiKey->hasScope('webhooks:read'))->toBeTrue(); + }); + + it('action wildcard does not grant other actions', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read Only All Key', + ['*:read'] + ); + + $apiKey = $result['api_key']; + + // Should not grant write or delete + expect($apiKey->hasScope('posts:write'))->toBeFalse(); + expect($apiKey->hasScope('users:delete'))->toBeFalse(); + expect($apiKey->hasScope('webhooks:manage'))->toBeFalse(); + }); + + it('multiple action wildcards work independently', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read/Write All Key', + ['*:read', '*:write'] + ); + + $apiKey = $result['api_key']; + + // Both action wildcards should work + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('users:write'))->toBeTrue(); + + // Delete should not be granted + expect($apiKey->hasScope('posts:delete'))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard Scopes - Full Wildcard (*) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Full Wildcard Scope', function () { + it('full wildcard grants access to everything', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'God Mode Key', + ['*'] + ); + + $apiKey = $result['api_key']; + + // Full wildcard should grant everything + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('admin:system'))->toBeTrue(); + expect($apiKey->hasScope('any:thing'))->toBeTrue(); + }); + + it('full wildcard grants simple scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'God Mode Key', + ['*'] + ); + + $apiKey = $result['api_key']; + + // Simple scopes should also be granted + expect($apiKey->hasScope('read'))->toBeTrue(); + expect($apiKey->hasScope('write'))->toBeTrue(); + expect($apiKey->hasScope('delete'))->toBeTrue(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scope Inheritance and Hierarchy +// ───────────────────────────────────────────────────────────────────────────── + +describe('Scope Inheritance', function () { + it('exact scopes take precedence over wildcards', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Mixed Key', + ['posts:read', 'posts:*'] + ); + + $apiKey = $result['api_key']; + + // Both exact and wildcard should work + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + }); + + it('combined resource and action wildcards cover different scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Mixed Wildcards Key', + ['posts:*', '*:read'] + ); + + $apiKey = $result['api_key']; + + // posts:* should grant all post actions + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + + // *:read should grant read on all resources + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('analytics:read'))->toBeTrue(); + + // But not write on other resources + expect($apiKey->hasScope('users:write'))->toBeFalse(); + }); + + it('empty scopes array denies all access', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Scopes Key', + [] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScope('read'))->toBeFalse(); + expect($apiKey->hasScope('posts:read'))->toBeFalse(); + expect($apiKey->hasScope('*'))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// hasScopes and hasAnyScope Methods +// ───────────────────────────────────────────────────────────────────────────── + +describe('Multiple Scope Checking', function () { + it('hasScopes requires all scopes to be present', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Limited Key', + ['posts:read', 'posts:write'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScopes(['posts:read']))->toBeTrue(); + expect($apiKey->hasScopes(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasScopes(['posts:read', 'posts:delete']))->toBeFalse(); + }); + + it('hasAnyScope requires at least one scope to be present', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Limited Key', + ['posts:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasAnyScope(['posts:read']))->toBeTrue(); + expect($apiKey->hasAnyScope(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasAnyScope(['posts:delete', 'users:read']))->toBeFalse(); + }); + + it('hasScopes works with wildcards', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Admin Key', + ['posts:*'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScopes(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasScopes(['posts:read', 'users:read']))->toBeFalse(); + }); + + it('hasAnyScope works with wildcards', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read All Key', + ['*:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasAnyScope(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasAnyScope(['posts:write', 'users:write']))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CheckApiScope Middleware (Explicit Scope Requirements) +// ───────────────────────────────────────────────────────────────────────────── + +describe('CheckApiScope Middleware', function () { + beforeEach(function () { + // Register routes with explicit scope requirements + Route::middleware(['api', 'api.auth', 'api.scope:posts:read']) + ->get('/test-explicit/posts', fn () => response()->json(['status' => 'ok'])); + + Route::middleware(['api', 'api.auth', 'api.scope:posts:write']) + ->post('/test-explicit/posts', fn () => response()->json(['status' => 'ok'])); + + Route::middleware(['api', 'api.auth', 'api.scope:posts:read,posts:write']) + ->put('/test-explicit/posts', fn () => response()->json(['status' => 'ok'])); + }); + + it('allows request with exact required scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Reader Key', + ['posts:read'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(200); + }); + + it('allows request with wildcard matching required scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Admin Key', + ['posts:*'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(200); + }); + + it('denies request without required scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Users Only Key', + ['users:read'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json('error'))->toBe('forbidden'); + expect($response->json('message'))->toContain('posts:read'); + }); + + it('requires all scopes when multiple specified', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Reader Only Key', + ['posts:read'] + ); + + // Route requires both posts:read AND posts:write + $response = $this->putJson('/api/test-explicit/posts', [], [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json('message'))->toContain('posts:write'); + }); + + it('allows all scopes with full wildcard', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Admin Key', + ['*'] + ); + + $headers = ['Authorization' => "Bearer {$result['plain_key']}"]; + + expect($this->getJson('/api/test-explicit/posts', $headers)->status())->toBe(200); + expect($this->postJson('/api/test-explicit/posts', [], $headers)->status())->toBe(200); + expect($this->putJson('/api/test-explicit/posts', [], $headers)->status())->toBe(200); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scope Denial Error Responses +// ───────────────────────────────────────────────────────────────────────────── + +describe('Scope Denial Error Responses', function () { + it('EnforceApiScope returns 403 with detailed error', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read Only Key', + [ApiKey::SCOPE_READ] + ); + + $response = $this->postJson('/api/test-scope/write', [], [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json())->toHaveKeys(['error', 'message', 'detail', 'key_scopes']); + expect($response->json('error'))->toBe('forbidden'); + expect($response->json('detail'))->toContain('POST'); + expect($response->json('detail'))->toContain('write'); + }); + + it('CheckApiScope returns 403 with required scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Wrong Scopes Key', + ['users:read'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json())->toHaveKeys(['error', 'message', 'required_scopes', 'key_scopes']); + expect($response->json('required_scopes'))->toContain('posts:read'); + expect($response->json('key_scopes'))->toBe(['users:read']); + }); + + it('error response contains accurate key scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Multi Scope Key', + ['posts:read', 'users:read', 'analytics:read'] + ); + + $response = $this->deleteJson('/api/test-scope/delete', [], [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json('key_scopes'))->toBe(['posts:read', 'users:read', 'analytics:read']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Edge Cases +// ───────────────────────────────────────────────────────────────────────────── + +describe('Edge Cases', function () { + it('handles null scopes array gracefully', function () { + // Directly create a key with null scopes (bypassing generate()) + $apiKey = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->create(['scopes' => null]); + + expect($apiKey->hasScope('read'))->toBeFalse(); + expect($apiKey->hasScope('posts:read'))->toBeFalse(); + expect($apiKey->hasAnyScope(['read']))->toBeFalse(); + }); + + it('handles scope with multiple colons', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Nested Scope Key', + ['api:v2:posts:read'] + ); + + $apiKey = $result['api_key']; + + // Exact match should work + expect($apiKey->hasScope('api:v2:posts:read'))->toBeTrue(); + + // The first segment before colon is treated as resource + // Resource wildcard for 'api' should match + $result2 = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'API Wildcard Key', + ['api:*'] + ); + + // api:* should match api:v2:posts:read (treats v2:posts:read as action) + expect($result2['api_key']->hasScope('api:v2:posts:read'))->toBeTrue(); + }); + + it('handles empty string scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Valid Key', + ['posts:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScope(''))->toBeFalse(); + }); + + it('scope matching is case-sensitive', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Lowercase Key', + ['posts:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('Posts:Read'))->toBeFalse(); + expect($apiKey->hasScope('POSTS:READ'))->toBeFalse(); + }); +});