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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 19:34:40 +00:00
parent 49c862b6c1
commit 97d0b32ed5
3 changed files with 577 additions and 14 deletions

30
TODO.md
View file

@ -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.*

View file

@ -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.
*/

View file

@ -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();
});
});