11 KiB
11 KiB
API Scopes
Fine-grained permission control for API keys using OAuth-style scopes.
Scope Format
Scopes follow the format: resource:action
Examples:
posts:read- Read blog postsposts:write- Create and update postsposts:delete- Delete postsusers:*- All user operations*:read- Read access to all resources*- Full access (use sparingly!)
Available Scopes
Content Management
| Scope | Description |
|---|---|
posts:read |
View published posts |
posts:write |
Create and update posts |
posts:delete |
Delete posts |
posts:publish |
Publish posts |
pages:read |
View static pages |
pages:write |
Create and update pages |
pages:delete |
Delete pages |
categories:read |
View categories |
categories:write |
Manage categories |
tags:read |
View tags |
tags:write |
Manage tags |
User Management
| Scope | Description |
|---|---|
users:read |
View user profiles |
users:write |
Update user profiles |
users:delete |
Delete users |
users:roles |
Manage user roles |
users:permissions |
Manage user permissions |
Analytics
| Scope | Description |
|---|---|
analytics:read |
View analytics data |
analytics:export |
Export analytics |
metrics:read |
View system metrics |
Webhooks
| Scope | Description |
|---|---|
webhooks:read |
View webhook endpoints |
webhooks:write |
Create and update webhooks |
webhooks:delete |
Delete webhooks |
webhooks:manage |
Full webhook management |
API Keys
| Scope | Description |
|---|---|
keys:read |
View API keys |
keys:write |
Create API keys |
keys:delete |
Delete API keys |
keys:manage |
Full key management |
Workspace Management
| Scope | Description |
|---|---|
workspace:read |
View workspace details |
workspace:write |
Update workspace settings |
workspace:members |
Manage workspace members |
workspace:billing |
Access billing information |
Admin Operations
| Scope | Description |
|---|---|
admin:users |
Admin user management |
admin:workspaces |
Admin workspace management |
admin:system |
System administration |
admin:* |
Full admin access |
Assigning Scopes
API Key Creation
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::create([
'name' => 'Mobile App',
'workspace_id' => $workspace->id,
'scopes' => [
'posts:read',
'posts:write',
'categories:read',
],
]);
Sanctum Tokens
$user = User::find(1);
$token = $user->createToken('mobile-app', [
'posts:read',
'posts:write',
'analytics:read',
])->plainTextToken;
Scope Enforcement
Route Protection
use Mod\Api\Middleware\EnforceApiScope;
// Single scope
Route::middleware(['auth:sanctum', 'scope:posts:write'])
->post('/posts', [PostController::class, 'store']);
// Multiple scopes (all required)
Route::middleware(['auth:sanctum', 'scopes:posts:write,categories:read'])
->post('/posts', [PostController::class, 'store']);
// Any scope (at least one required)
Route::middleware(['auth:sanctum', 'scope-any:posts:write,pages:write'])
->post('/content', [ContentController::class, 'store']);
Controller Checks
<?php
namespace Mod\Blog\Controllers\Api;
class PostController
{
public function store(Request $request)
{
// Check single scope
if (!$request->user()->tokenCan('posts:write')) {
abort(403, 'Insufficient permissions');
}
// Check multiple scopes
if (!$request->user()->tokenCan('posts:write') ||
!$request->user()->tokenCan('categories:read')) {
abort(403);
}
// Proceed with creation
$post = Post::create($request->validated());
return new PostResource($post);
}
public function publish(Post $post)
{
// Require specific scope for sensitive action
if (!request()->user()->tokenCan('posts:publish')) {
abort(403, 'Publishing requires posts:publish scope');
}
$post->publish();
return new PostResource($post);
}
}
Wildcard Scopes
Resource Wildcards
Grant all permissions for a resource:
$apiKey->scopes = [
'posts:*', // All post operations
'categories:*', // All category operations
];
Equivalent to:
$apiKey->scopes = [
'posts:read',
'posts:write',
'posts:delete',
'posts:publish',
'categories:read',
'categories:write',
'categories:delete',
];
Action Wildcards
Grant read-only access to everything:
$apiKey->scopes = [
'*:read', // Read access to all resources
];
Full Access
$apiKey->scopes = ['*']; // Full access (dangerous!)
::: warning
Only use * scope for admin integrations. Always prefer specific scopes.
:::
Scope Validation
Custom Scopes
Define custom scopes for your modules:
<?php
namespace Mod\Shop\Api;
use Mod\Api\Contracts\ScopeProvider;
class ShopScopeProvider implements ScopeProvider
{
public function scopes(): array
{
return [
'products:read' => 'View products',
'products:write' => 'Create and update products',
'products:delete' => 'Delete products',
'orders:read' => 'View orders',
'orders:write' => 'Process orders',
'orders:refund' => 'Issue refunds',
];
}
}
Register Provider:
use Core\Events\ApiRoutesRegistering;
use Mod\Shop\Api\ShopScopeProvider;
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->scopes(new ShopScopeProvider());
}
Scope Groups
Group related scopes:
// config/api.php
return [
'scope_groups' => [
'content_admin' => [
'posts:*',
'pages:*',
'categories:*',
'tags:*',
],
'analytics_viewer' => [
'analytics:read',
'metrics:read',
],
'webhook_manager' => [
'webhooks:*',
],
],
];
Usage:
// Assign group instead of individual scopes
$apiKey->scopes = config('api.scope_groups.content_admin');
Checking Scopes
Token Abilities
// Check if token has scope
if ($request->user()->tokenCan('posts:write')) {
// Has permission
}
// Check multiple scopes (all required)
if ($request->user()->tokenCan('posts:write') &&
$request->user()->tokenCan('posts:publish')) {
// Has both permissions
}
// Get all token abilities
$abilities = $request->user()->currentAccessToken()->abilities;
Scope Middleware
// Require single scope
Route::middleware('scope:posts:write')->post('/posts', ...);
// Require all scopes
Route::middleware('scopes:posts:write,categories:read')->post('/posts', ...);
// Require any scope (OR logic)
Route::middleware('scope-any:posts:write,pages:write')->post('/content', ...);
API Key Scopes
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::findByKey($providedKey);
// Check scope
if ($apiKey->hasScope('posts:write')) {
// Has permission
}
// Check multiple scopes
if ($apiKey->hasAllScopes(['posts:write', 'categories:read'])) {
// Has all permissions
}
// Check any scope
if ($apiKey->hasAnyScope(['posts:write', 'pages:write'])) {
// Has at least one permission
}
Scope Inheritance
Hierarchical Scopes
Higher-level scopes include lower-level scopes:
admin:* includes:
├─ admin:users
├─ admin:workspaces
└─ admin:system
workspace:* includes:
├─ workspace:read
├─ workspace:write
├─ workspace:members
└─ workspace:billing
Implementation:
public function hasScope(string $scope): bool
{
// Exact match
if (in_array($scope, $this->scopes)) {
return true;
}
// Check wildcards
[$resource, $action] = explode(':', $scope);
// Resource wildcard (e.g., posts:*)
if (in_array("{$resource}:*", $this->scopes)) {
return true;
}
// Action wildcard (e.g., *:read)
if (in_array("*:{$action}", $this->scopes)) {
return true;
}
// Full wildcard
return in_array('*', $this->scopes);
}
Error Responses
Insufficient Scope
{
"message": "Insufficient scope",
"required_scope": "posts:write",
"provided_scopes": ["posts:read"],
"error_code": "insufficient_scope"
}
HTTP Status: 403 Forbidden
Missing Scope
{
"message": "This action requires the 'posts:publish' scope",
"required_scope": "posts:publish",
"error_code": "scope_required"
}
Best Practices
1. Principle of Least Privilege
// ✅ Good - minimal scopes
$apiKey->scopes = [
'posts:read',
'categories:read',
];
// ❌ Bad - excessive permissions
$apiKey->scopes = ['*'];
2. Use Specific Scopes
// ✅ Good - specific actions
$apiKey->scopes = [
'posts:read',
'posts:write',
];
// ❌ Bad - overly broad
$apiKey->scopes = ['posts:*'];
3. Document Required Scopes
/**
* Publish a blog post.
*
* Required scopes:
* - posts:write (to modify post)
* - posts:publish (to change status)
*
* @requires posts:write
* @requires posts:publish
*/
public function publish(Post $post)
{
// ...
}
4. Validate Early
// ✅ Good - check at route level
Route::middleware('scope:posts:write')
->post('/posts', [PostController::class, 'store']);
// ❌ Bad - check late in controller
public function store(Request $request)
{
$validated = $request->validate([...]); // Wasted work
if (!$request->user()->tokenCan('posts:write')) {
abort(403);
}
}
Testing Scopes
use Tests\TestCase;
use Laravel\Sanctum\Sanctum;
class ScopeTest extends TestCase
{
public function test_requires_write_scope(): void
{
$user = User::factory()->create();
// Token without write scope
Sanctum::actingAs($user, ['posts:read']);
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test Post',
]);
$response->assertStatus(403);
}
public function test_allows_with_correct_scope(): void
{
$user = User::factory()->create();
// Token with write scope
Sanctum::actingAs($user, ['posts:write']);
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$response->assertStatus(201);
}
public function test_wildcard_scope_grants_access(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:*']);
$this->postJson('/api/v1/posts', [...])->assertStatus(201);
$this->putJson('/api/v1/posts/1', [...])->assertStatus(200);
$this->deleteJson('/api/v1/posts/1')->assertStatus(204);
}
}