Authorization & Access Control
Last updated: 2026-03-22
Table of Contents
- Overview
- Security Warning
- Quick Start
- Default Behavior
- Enabling Authorization
- Configuration Options
- Adding Ownership Tracking
- Custom Policy
- Spatie Permission Integration
- Common Scenarios
- Best Practices
- Troubleshooting
Overview
The Filament Address Pro package includes a comprehensive authorization system that controls who can:
- View addresses (list and individual records)
- Create new addresses
- Edit existing addresses
- Delete addresses
- Perform bulk operations (verify, import, export)
The system uses Laravel's native Policy system, making it flexible, extensible, and compatible with your existing authorization infrastructure.
Security Warning
⚠️ CRITICAL: Default Configuration is NOT Secure for Production
By default, authorization is DISABLED for backward compatibility. This means:
ALL authenticated users have FULL ACCESS to:
- View all addresses (globally)
- Create/edit/delete any address
- Run bulk operations (import, export, verify)
- Trigger API calls (incurring costs)
- Access cache and audit logs
When is this acceptable?
✅ Safe for:
- Demo/development environments
- Single-user applications
- Solo entrepreneur/freelancer use cases
- Prototyping and testing
❌ NOT SAFE for:
- Multi-user applications (teams, organizations)
- Multi-tenant SaaS platforms
- Production applications with sensitive data
- Applications requiring compliance (GDPR, SOC2, HIPAA)
Risks of Unrestricted Access
Data Privacy Risk 🔴 CRITICAL
- User A can view/edit User B's addresses
- In multi-tenant apps: Customer A sees Customer B's data
- No GDPR compliance possible without access controls
- Example: SaaS with 10 companies → all can see each other's addresses
Cost Control Risk 🔴 HIGH
- Any user can trigger unlimited API calls
- Bulk verify without approval → unexpected charges
- No spending limits or admin approval required
- Example: Junior staff runs "Verify All" on 10K addresses = $50-100 API cost
Data Integrity Risk 🟡 MEDIUM
- Any user can delete any address
- Bulk operations without safeguards
- Import can overwrite data
- No audit trail of who did what
Compliance Risk 🔴 HIGH
- Cannot meet GDPR requirements (no data access restrictions)
- No SOC2 audit trail capabilities
- No HIPAA field-level restrictions
- No segregation of duties
Quick Start
For Production Multi-User Applications
Minimum required configuration for production:
# .env file
ADDRESS_AUTHORIZATION_ENABLED=trueThis enables authorization with sensible defaults. Then configure specific permissions:
// config/addresses.php
'authorization' => [
'enabled' => true,
// Restrict bulk operations to admins
'bulk_verify' => fn($user) => $user->hasRole('admin'),
'import' => fn($user) => $user->hasRole('admin'),
'export' => fn($user) => $user->hasRole('admin'),
],Default Behavior
When authorization is disabled (default):
// config/addresses.php
'authorization' => [
'enabled' => false, // ← Default
],Result: All authenticated users can perform ALL operations.
The AddressPolicy checks return true for all authenticated users:
- ✅ View all addresses
- ✅ Create addresses
- ✅ Edit any address
- ✅ Delete any address
- ✅ Bulk verify, import, export
UI Behavior:
- "New Address" button: Visible to all
- "Edit" action: Visible for all records
- "Delete" action: Visible for all records
- Bulk actions: All visible
Enabling Authorization
Method 1: Environment Variable (Recommended)
# .env
ADDRESS_AUTHORIZATION_ENABLED=trueThis enables authorization checks while using the default permissions from config.
Method 2: Config File
// config/addresses.php
'authorization' => [
'enabled' => true,
],Method 3: Dynamic (Runtime)
// In a service provider or middleware
config(['addresses.authorization.enabled' => true]);Configuration Options
All authorization options are in config/addresses.php under the authorization section.
Available Permissions
Each permission can be:
- Boolean:
true(allow all) orfalse(deny all) - Closure:
fn($user) => $user->hasRole('admin') - Null: Inherit from parent permission
View Permissions
'authorization' => [
// Can user access the address list?
'view_any' => true, // Default: allow all
// Can user view a specific address?
// null = inherit from view_any
'view' => null,
// Example: Only view own addresses
// 'view' => fn($user, $address) => $address->created_by === $user->id,
],Note: The
created_byfield is not included in the default migration. Row-level ownership tracking requires custom schema modifications. See Adding Ownership Tracking below for implementation details.
Modify Permissions
'authorization' => [
// Can user create new addresses?
'create' => true,
// Can user edit addresses?
'update' => true,
// Example: Only edit own addresses
// 'update' => fn($user, $address) => $address->created_by === $user->id,
// Can user delete addresses?
'delete' => true,
// Example: Admin only
// 'delete' => fn($user) => $user->hasRole('admin'),
],Bulk Operation Permissions
⚠️ RECOMMENDATION: Restrict these to admin roles for production.
'authorization' => [
// Bulk verification (triggers API calls, incurs costs)
'bulk_verify' => true, // ⚠️ Default allows all
// Import addresses (can overwrite data)
'import' => true, // ⚠️ Default allows all
// Export addresses (contains sensitive data)
'export' => true, // ⚠️ Default allows all
],Production Example:
'authorization' => [
'bulk_verify' => fn($user) => $user->hasRole('admin'),
'import' => fn($user) => $user->hasAnyRole(['admin', 'data-manager']),
'export' => fn($user) => $user->hasAnyRole(['admin', 'analyst']),
],Environment Variables
All permissions support environment variable overrides:
# View permissions
ADDRESS_AUTH_VIEW_ANY=true
ADDRESS_AUTH_VIEW=true
# Modify permissions
ADDRESS_AUTH_CREATE=true
ADDRESS_AUTH_UPDATE=true
ADDRESS_AUTH_DELETE=false # Disable delete for all users
# Bulk operations
ADDRESS_AUTH_BULK_VERIFY=false # Disable bulk verify
ADDRESS_AUTH_IMPORT=false # Disable import
ADDRESS_AUTH_EXPORT=false # Disable exportNote: Environment variables only support boolean values. For role-based or complex logic, use closures in the config file.
Adding Ownership Tracking
The addresses table does not include created_by or updated_by columns by default. This keeps the package flexible and avoids assumptions about your authentication system (integer IDs, UUIDs, ULIDs, etc.).
When Do You Need This?
Add ownership tracking if you need:
- Row-level authorization (users can only edit their own addresses)
- Audit trails (who created/modified each address)
- Multi-tenant data segregation
- Compliance requirements (GDPR, SOC2, HIPAA)
Implementation Steps
1. Publish the Migration
php artisan vendor:publish --tag=filament-address-migrations2. Customize the Migration
Edit database/migrations/*_create_addresses_table.php and add ownership fields:
Schema::create('addresses', function (Blueprint $table) {
// ... existing fields ...
// Add ownership tracking
// Choose the appropriate type for your auth system:
$table->unsignedBigInteger('created_by')->nullable(); // Standard Laravel
// $table->uuid('created_by')->nullable(); // UUID users
// $table->ulid('created_by')->nullable(); // ULID users
$table->unsignedBigInteger('updated_by')->nullable(); // Optional: track updates
// Add foreign keys (adjust table name if needed)
$table->foreign('created_by')
->references('id')
->on('users')
->nullOnDelete(); // Set to null if user is deleted
$table->foreign('updated_by')
->references('id')
->on('users')
->nullOnDelete();
// ... rest of migration ...
});3. Update the Address Model
Add fillable fields and relationships:
// app/Models/Address.php (if using custom model)
// or extend the package model
protected $fillable = [
// ... existing fields ...
'created_by',
'updated_by',
];
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}4. Auto-Populate on Create/Update
Add an observer or model boot method:
// In AddressServiceProvider or AppServiceProvider
Address::creating(function ($address) {
if (auth()->check() && !$address->created_by) {
$address->created_by = auth()->id();
}
});
Address::updating(function ($address) {
if (auth()->check()) {
$address->updated_by = auth()->id();
}
});5. Use in Authorization
Now you can use ownership checks in your config:
'authorization' => [
'enabled' => true,
// Users can only view their own addresses
'view' => fn($user, $address) => $address->created_by === $user->id,
// Admins can edit all, users can edit only their own
'update' => fn($user, $address) =>
$user->hasRole('admin') || $address->created_by === $user->id,
// Only admins can delete
'delete' => fn($user) => $user->hasRole('admin'),
],Alternative: Full Audit Trail
For comprehensive auditing (what changed, when, why), consider dedicated audit packages:
- spatie/laravel-activitylog - Most popular, flexible
- owen-it/laravel-auditing - Detailed change tracking
- venturecraft/revisionable - Simple revision history
These packages provide full audit trails including:
- Old and new values for each field
- User agent, IP address, timestamps
- Restoration capabilities
- Query interfaces for audit logs
Custom Policy
For advanced authorization requirements, copy and customize the AddressPolicy:
Step 1: Copy the Policy
php artisan vendor:publish --tag=filament-address-policiesThis creates: app/Policies/AddressPolicy.php
Step 2: Customize the Policy
<?php
namespace App\Policies;
use App\Models\User;
use Viewflex\FilamentAddress\Models\Address;
use Viewflex\FilamentAddress\Policies\AddressPolicy as BasePolicy;
class AddressPolicy extends BasePolicy
{
/**
* Only allow users to view addresses they created.
*/
public function view(User $user, Address $address): bool
{
return $address->created_by === $user->id;
}
/**
* Only admins can delete addresses.
*/
public function delete(User $user, Address $address): bool
{
return $user->hasRole('admin');
}
/**
* Only allow bulk verify for users with remaining API quota.
*/
public function bulkVerify(User $user): bool
{
if (!$user->hasRole('admin')) {
return false;
}
// Check if user/tenant has remaining quota
return $user->tenant->api_quota_remaining > 0;
}
}Note: The example above uses
$address->created_by, which requires adding ownership tracking to your schema. See Adding Ownership Tracking for implementation steps.
Step 3: Register Your Custom Policy
// config/addresses.php
'authorization' => [
'enabled' => true,
'policy' => \App\Policies\AddressPolicy::class, // ← Your custom policy
],Spatie Permission Integration
The package automatically detects and uses Spatie Laravel Permission if installed. No additional configuration needed!
Installation
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migratePermission Names
The policy automatically checks for these permissions:
view-addresses- Can view address listcreate-addresses- Can create addresses; also controls the "Recalculate Quality Scores" header and bulk actionsupdate-addresses- Can edit addressesdelete-addresses- Can delete addresses; also controls the "Delete Selection" bulk actionverify-addresses- Can bulk verifyimport-addresses- Can import addressesexport-addresses- Can export addresses
Note on Recalculate Quality Scores: Both the header button and the bulk action are gated behind
create-addresses(notupdate-addresses). Theupdatepolicy requires a specific model instance and cannot be used for class-level permission checks. Users with onlyview-addresseswill not see these actions.
Creating Roles and Permissions
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
// Create permissions
Permission::create(['name' => 'view-addresses']);
Permission::create(['name' => 'create-addresses']);
Permission::create(['name' => 'update-addresses']);
Permission::create(['name' => 'delete-addresses']);
Permission::create(['name' => 'verify-addresses']);
Permission::create(['name' => 'import-addresses']);
Permission::create(['name' => 'export-addresses']);
// Create roles
$admin = Role::create(['name' => 'admin']);
$staff = Role::create(['name' => 'staff']);
$viewer = Role::create(['name' => 'viewer']);
// Assign permissions to roles
$admin->givePermissionTo([
'view-addresses',
'create-addresses',
'update-addresses',
'delete-addresses',
'verify-addresses',
'import-addresses',
'export-addresses',
]);
$staff->givePermissionTo([
'view-addresses',
'create-addresses',
'update-addresses',
]);
$viewer->givePermissionTo([
'view-addresses',
]);
// Assign role to user
$user->assignRole('staff');How It Works
The AddressPolicy automatically detects if your User model has the hasPermissionTo() method (from Spatie):
public function viewAny(User $user): bool
{
// If Spatie is installed and user has the method
if (method_exists($user, 'hasPermissionTo')) {
return $user->hasPermissionTo('view-addresses');
}
// Fallback to config
return config('addresses.authorization.view_any', true);
}No configuration needed - it just works! 🎉
Common Scenarios
Scenario 1: Small Team (Admin + Staff)
// config/addresses.php
'authorization' => [
'enabled' => true,
// Everyone can view and create
'view_any' => true,
'create' => true,
// Only admins can edit/delete
'update' => fn($user) => $user->hasRole('admin'),
'delete' => fn($user) => $user->hasRole('admin'),
// Only admins can do bulk operations
'bulk_verify' => fn($user) => $user->hasRole('admin'),
'import' => fn($user) => $user->hasRole('admin'),
'export' => fn($user) => $user->hasRole('admin'),
],Scenario 2: Users Can Only Edit Their Own
'authorization' => [
'enabled' => true,
// Everyone can view list (but might filter in query)
'view_any' => true,
// Can only view own addresses
'view' => fn($user, $address) => $address->created_by === $user->id,
// Can create for themselves
'create' => true,
// Can only edit own
'update' => fn($user, $address) => $address->created_by === $user->id,
// Can only delete own
'delete' => fn($user, $address) => $address->created_by === $user->id,
// No bulk operations for regular users
'bulk_verify' => false,
'import' => false,
'export' => false,
],Scenario 3: Department-Based Access
'authorization' => [
'enabled' => true,
// Sales: View and create
// Finance: Export for billing
// Admin: Full access
'view_any' => fn($user) => $user->hasAnyRole(['sales', 'finance', 'admin']),
'create' => fn($user) => $user->hasAnyRole(['sales', 'admin']),
'update' => fn($user) => $user->hasRole('admin'),
'delete' => fn($user) => $user->hasRole('admin'),
'bulk_verify' => fn($user) => $user->hasRole('admin'),
'import' => fn($user) => $user->hasRole('admin'),
'export' => fn($user) => $user->hasAnyRole(['finance', 'admin']),
],Scenario 4: Quota-Based Restrictions
// Custom policy
public function bulkVerify(User $user): bool
{
if (!$user->hasRole('admin')) {
return false;
}
// Check daily quota
$todayUsage = Address::where('verified_at', '>=', today())
->where('verification_triggered_by', $user->id)
->count();
return $todayUsage < 100; // Max 100 verifications per day
}Best Practices
1. Enable Authorization Before Launch
Don't wait until after launch. Enable authorization during development to catch issues early.
ADDRESS_AUTHORIZATION_ENABLED=true2. Restrict Bulk Operations
Bulk operations can incur significant costs. Always restrict to trusted users:
'bulk_verify' => fn($user) => $user->hasRole('admin'),
'import' => fn($user) => $user->hasRole('admin'),
'export' => fn($user) => $user->hasRole('admin'),3. Implement Row-Level Security Early
Even if all users can view the list, implement per-record checks:
'view' => fn($user, $address) => $address->tenant_id === $user->tenant_id,4. Use Spatie Permission for Complex Roles
For 3+ roles with varying permissions, use Spatie Permission instead of closures:
composer require spatie/laravel-permissionThen assign permissions via database instead of hard-coded logic.
5. Log Authorization Failures
For compliance, log when users are denied access:
// In custom policy
public function delete(User $user, Address $address): bool
{
$canDelete = $user->hasRole('admin');
if (!$canDelete) {
\Log::warning('Authorization denied', [
'user' => $user->id,
'action' => 'delete',
'address' => $address->id,
]);
}
return $canDelete;
}6. Test Authorization Logic
Include authorization tests in your test suite:
test('staff cannot delete addresses', function () {
$staff = User::factory()->create();
$staff->assignRole('staff');
$address = Address::factory()->create();
expect(Gate::allows('delete', $address))->toBeFalse();
});7. Document Your Authorization Rules
Create a PERMISSIONS.md file documenting which roles can do what:
# Address Permissions
## Roles
### Admin
- Full access to all addresses
- Can verify, import, export
### Staff
- View all addresses
- Create new addresses
- Cannot edit/delete
### Viewer
- View addresses only
- No modifications allowedTroubleshooting
Problem: "New Address" Button Not Appearing
Cause: User doesn't have create permission.
Solution:
// Check in tinker
Gate::allows('create', \Viewflex\FilamentAddress\Models\Address::class);
// Enable create permission
config(['addresses.authorization.create' => true]);Problem: All Actions Hidden After Enabling Authorization
Cause: Config not loaded or policy not registered.
Solution:
# Clear config cache
php artisan config:clear
# Check if policy is registered
php artisan tinker
>>> Gate::getPolicyFor(\Viewflex\FilamentAddress\Models\Address::class)Problem: Spatie Permissions Not Working
Cause: User model doesn't have the HasRoles trait.
Solution:
// app/Models/User.php
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}Problem: Authorization Works in Tinker But Not in UI
Cause: Different user context or cache issue.
Solution:
# Clear all caches
php artisan cache:clear
php artisan config:clear
php artisan view:clear
# Check which user is authenticated in the panel
dd(auth()->user(), auth()->id());Problem: Custom Policy Not Being Used
Cause: Config still points to default policy.
Solution:
// config/addresses.php
'authorization' => [
'policy' => \App\Policies\AddressPolicy::class, // ← Make sure this is correct
],
// Then clear config
php artisan config:clearMulti-Tenancy Support
Status: Deferred to a future release.
Multi-tenancy is not built in, but you can implement tenant scoping today with a custom policy:
// Custom policy
public function view(User $user, Address $address): bool
{
// Check if address belongs to user's tenant
return $address->tenant_id === $user->tenant_id;
}
// Apply global scope to all queries
// app/Providers/AppServiceProvider.php
Address::addGlobalScope('tenant', function ($query) {
if (auth()->check() && auth()->user()->tenant_id) {
$query->where('tenant_id', auth()->user()->tenant_id);
}
});