Skip to content

Authorization & Access Control

Last updated: 2026-03-22

Table of Contents

  1. Overview
  2. Security Warning
  3. Quick Start
  4. Default Behavior
  5. Enabling Authorization
  6. Configuration Options
  7. Adding Ownership Tracking
  8. Custom Policy
  9. Spatie Permission Integration
  10. Common Scenarios
  11. Best Practices
  12. 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

  1. 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
  2. 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
  3. 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
  4. 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
# .env file
ADDRESS_AUTHORIZATION_ENABLED=true

This enables authorization with sensible defaults. Then configure specific permissions:

php
// 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):

php
// 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

env
# .env
ADDRESS_AUTHORIZATION_ENABLED=true

This enables authorization checks while using the default permissions from config.

Method 2: Config File

php
// config/addresses.php
'authorization' => [
    'enabled' => true,
],

Method 3: Dynamic (Runtime)

php
// 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) or false (deny all)
  • Closure: fn($user) => $user->hasRole('admin')
  • Null: Inherit from parent permission

View Permissions

php
'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_by field 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

php
'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.

php
'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:

php
'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:

env
# 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 export

Note: 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

bash
php artisan vendor:publish --tag=filament-address-migrations

2. Customize the Migration

Edit database/migrations/*_create_addresses_table.php and add ownership fields:

php
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:

php
// 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:

php
// 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:

php
'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:

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

bash
php artisan vendor:publish --tag=filament-address-policies

This creates: app/Policies/AddressPolicy.php

Step 2: Customize the Policy

php
<?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

php
// 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

bash
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

Permission Names

The policy automatically checks for these permissions:

  • view-addresses - Can view address list
  • create-addresses - Can create addresses; also controls the "Recalculate Quality Scores" header and bulk actions
  • update-addresses - Can edit addresses
  • delete-addresses - Can delete addresses; also controls the "Delete Selection" bulk action
  • verify-addresses - Can bulk verify
  • import-addresses - Can import addresses
  • export-addresses - Can export addresses

Note on Recalculate Quality Scores: Both the header button and the bulk action are gated behind create-addresses (not update-addresses). The update policy requires a specific model instance and cannot be used for class-level permission checks. Users with only view-addresses will not see these actions.

Creating Roles and Permissions

php
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):

php
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)

php
// 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

php
'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

php
'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

php
// 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.

env
ADDRESS_AUTHORIZATION_ENABLED=true

2. Restrict Bulk Operations

Bulk operations can incur significant costs. Always restrict to trusted users:

php
'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:

php
'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:

bash
composer require spatie/laravel-permission

Then assign permissions via database instead of hard-coded logic.

5. Log Authorization Failures

For compliance, log when users are denied access:

php
// 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:

php
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:

markdown
# 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 allowed

Troubleshooting

Problem: "New Address" Button Not Appearing

Cause: User doesn't have create permission.

Solution:

php
// 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:

bash
# 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:

php
// 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:

bash
# 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:

php
// config/addresses.php
'authorization' => [
    'policy' => \App\Policies\AddressPolicy::class,  // ← Make sure this is correct
],

// Then clear config
php artisan config:clear

Multi-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:

php
// 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);
    }
});

Additional Resources

Released under a commercial license.