Skip to main content

Authorization Architecture

ShelfWise uses Policy-Based Authorization with Laravel Policies.

Authorization Flow

1

Form Request Validation

Form Requests validate input but delegate authorization to Policies
public function authorize(): bool
{
    return $this->user()->can('create', \App\Models\Product::class);
}
2

Controller Authorization

Controllers use Gate::authorize() before service calls
public function store(StoreProductRequest $request)
{
    Gate::authorize('create', Product::class);
    $product = $this->productService->createProduct($request->validated());
    return redirect()->route('products.index');
}
3

Policy Checks

Policies contain all authorization logic with tenant isolation
public function view(User $user, Product $product): bool
{
    if ($user->tenant_id !== $product->tenant_id) {
        return false;
    }
    
    return $user->role->hasPermission('manage_inventory');
}

Policy Pattern

app/Policies/ProductPolicy.php
namespace App\Policies;

use App\Enums\UserRole;
use App\Models\Product;
use App\Models\User;

class ProductPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->role->hasPermission('manage_inventory');
    }

    public function create(User $user): bool
    {
        return $user->role->hasPermission('manage_inventory');
    }

    public function view(User $user, Product $product): bool
    {
        if ($user->tenant_id !== $product->tenant_id) {
            return false;
        }

        if (in_array($user->role->value, [UserRole::OWNER->value, UserRole::GENERAL_MANAGER->value])) {
            return true;
        }

        return $user->shops()->where('shops.id', $product->shop_id)->exists();
    }

    public function update(User $user, Product $product): bool
    {
        if ($user->tenant_id !== $product->tenant_id) {
            return false;
        }

        if (in_array($user->role->value, [UserRole::OWNER->value, UserRole::GENERAL_MANAGER->value])) {
            return true;
        }

        return $user->shops()->where('shops.id', $product->shop_id)->exists();
    }

    public function delete(User $user, Product $product): bool
    {
        if ($user->tenant_id !== $product->tenant_id) {
            return false;
        }

        return $user->role->value === UserRole::OWNER->value ||
               $user->role->value === UserRole::GENERAL_MANAGER->value;
    }

    public function restore(User $user, Product $product): bool
    {
        if ($user->tenant_id !== $product->tenant_id) {
            return false;
        }

        return $user->is_tenant_owner ||
               $user->role->value === UserRole::GENERAL_MANAGER->value;
    }

    public function forceDelete(User $user, Product $product): bool
    {
        if ($user->tenant_id !== $product->tenant_id) {
            return false;
        }

        return $user->is_tenant_owner;
    }
}
Every policy method MUST check tenant isolation first:
if ($user->tenant_id !== $model->tenant_id) {
    return false;
}

Role-Based Access Control

Role Hierarchy

Super Admin (999) - Full system access
  |
  └─ Owner (100) - Full tenant access
       |
       └─ General Manager (80) - Manage all shops
            |
            └─ Store Manager (60) - Manage assigned shop
                 |
                 ├─ Assistant Manager (50) - Limited shop management
                 ├─ Sales Rep (40) - Sales and customer management
                 ├─ Inventory Clerk (30) - Stock management
                 └─ Cashier (30) - POS operations only

Permission Checks

// Role-based permission
if ($user->role->hasPermission('manage_inventory')) {
    // Allow access
}

// Direct role check
if ($user->role->value === UserRole::OWNER->value) {
    // Owner-only action
}

// Multiple roles
if (in_array($user->role->value, [UserRole::OWNER->value, UserRole::GENERAL_MANAGER->value])) {
    // Senior management action
}

Multi-Tenancy Security

Tenant Isolation (CRITICAL)

Every database query MUST filter by tenant_id. Failure to do so is a critical security vulnerability.
// ✅ Correct - Explicitly scoped to tenant
Product::query()->where('tenant_id', auth()->user()->tenant_id)->get();

// ✅ Correct - Service handles scoping
app(ProductService::class)->getAllProducts();

// ❌ CRITICAL ERROR - No tenant isolation
Product::query()->get();

Validation with Tenant Scope

Uniqueness checks MUST be scoped to tenant:
app/Http/Requests/CreateProductRequest.php
public function rules(): array
{
    $tenantId = $this->user()->tenant_id;

    return [
        'sku' => [
            'required',
            'string',
            Rule::unique('product_variants', 'sku')
                ->where(fn ($query) => $query->whereExists(
                    fn ($q) => $q->select(\DB::raw(1))
                        ->from('products')
                        ->whereColumn('products.id', 'product_variants.product_id')
                        ->where('products.tenant_id', $tenantId)
                )),
        ],
    ];
}

Relationship Checks

Verify tenant ownership of related records:
$shop = Shop::query()
    ->where('id', $request->shop_id)
    ->where('tenant_id', auth()->user()->tenant_id)
    ->firstOrFail();

Input Validation

Form Request Validation

All inputs MUST be validated via Form Requests:
class StoreProductRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'price' => ['required', 'numeric', 'min:0'],
            'description' => ['nullable', 'string'],
        ];
    }

    public function messages(): array
    {
        return [
            'name.required' => 'Please provide a name for this product.',
            'price.required' => 'Please enter the selling price.',
            'price.min' => 'Selling price cannot be negative.',
        ];
    }
}

Mass Assignment Protection

Use $fillable or $guarded on all models:
class Product extends Model
{
    protected $fillable = [
        'tenant_id',
        'shop_id',
        'name',
        'description',
    ];
    
    // Never allow mass assignment of these
    protected $guarded = ['id', 'uuid'];
}

SQL Injection Prevention

Use Query Builder / Eloquent

Never use raw SQL queries. Always use Eloquent or Query Builder.
// ✅ Correct - Parameterized query
Product::query()
    ->where('tenant_id', $tenantId)
    ->where('name', 'like', '%' . $search . '%')
    ->get();

// ✅ Correct - Eloquent relationship
$product->variants()->where('sku', $sku)->first();

// ❌ VULNERABLE - SQL injection risk
DB::select("SELECT * FROM products WHERE name = '{$name}'");

Parameterized Queries

If raw queries are absolutely necessary, use parameter binding:
// ✅ Acceptable - Parameterized
DB::select('SELECT * FROM products WHERE tenant_id = ? AND name = ?', [$tenantId, $name]);

// ❌ VULNERABLE
DB::select("SELECT * FROM products WHERE tenant_id = {$tenantId}");

XSS Prevention

Frontend Output Escaping

React automatically escapes output, but be careful with dangerouslySetInnerHTML:
// ✅ Safe - React escapes by default
<div>{product.name}</div>

// ⚠️ Use with caution - Must sanitize first
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />

Backend Output

Blade templates auto-escape:
{{-- ✅ Safe - Escaped --}}
{{ $product->name }}

{{-- ⚠️ Unescaped - Only use with trusted content --}}
{!! $trustedHtml !!}

CSRF Protection

All state-changing requests require CSRF token:
import { Form } from '@inertiajs/react';

// ✅ Inertia Form includes CSRF automatically
<Form method="post" action={route('products.store')}>
  <input name="name" />
  <button type="submit">Create</button>
</Form>

Authentication Security

Two-Factor Authentication

ShelfWise supports 2FA via Laravel Fortify:
test('users with two factor enabled are redirected to challenge', function () {
    $user = User::factory()->create();

    $user->forceFill([
        'two_factor_secret' => encrypt('test-secret'),
        'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
        'two_factor_confirmed_at' => now(),
    ])->save();

    $response = $this->post(route('login'), [
        'email' => $user->email,
        'password' => 'password',
    ]);

    $response->assertRedirect(route('two-factor.login'));
});

Rate Limiting

Auth endpoints are rate limited:
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->email.$request->ip());
});

Sensitive Data Handling

Encryption at Rest

Encrypt sensitive fields:
use Illuminate\Database\Eloquent\Casts\Encrypted;

class User extends Model
{
    protected $casts = [
        'two_factor_secret' => Encrypted::class,
        'two_factor_recovery_codes' => Encrypted::class,
    ];
}

Hide Sensitive Attributes

class User extends Model
{
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_secret',
        'two_factor_recovery_codes',
    ];
}

Security Checklist

Before any PR involving user data:
  • Input validation via Form Request
  • Authorization via Policies
  • No raw SQL queries (use Eloquent)
  • Sensitive data encrypted at rest
  • CSRF protection on forms
  • Rate limiting on auth endpoints
  • Tenant isolation verified
  • Mass assignment protection configured
  • XSS prevention verified
  • SQL injection vectors eliminated

Code Review for Security

Automatic Triggers

Invoke /code-review or code-reviewer agent after:
  • Completing a new controller
  • Adding a new service with business logic
  • Creating database migrations
  • Implementing payment/subscription logic
  • Any security-sensitive code (auth, permissions, crypto)
  • Any code touching inventory/stock

Security Review Focus

// Check these patterns:

// 1. Tenant isolation
if ($user->tenant_id !== $model->tenant_id) {
    return false;
}

// 2. Parameterized queries
Model::query()->where('column', $value)->get();

// 3. Authorization checks
Gate::authorize('action', $model);

// 4. Input validation
public function rules(): array { ... }

// 5. Mass assignment protection
protected $fillable = [...];

OWASP Top 10 Compliance

ShelfWise follows OWASP security standards:
VulnerabilityPrevention
InjectionEloquent/Query Builder only
Broken Authentication2FA, rate limiting
Sensitive Data ExposureEncryption, hidden attributes
XML External EntitiesN/A (no XML processing)
Broken Access ControlPolicy-based authorization
Security MisconfigurationEnvironment-based config
XSSReact auto-escaping
Insecure DeserializationValidation before processing
Known Vulnerabilitiescomposer audit, npm audit
Insufficient LoggingLaravel logging, audit trails

Security Testing

Test Authorization

test('unauthorized users cannot delete products', function () {
    $cashier = User::factory()->create(['role' => UserRole::CASHIER]);
    $product = Product::factory()->create();
    
    $this->actingAs($cashier)
        ->delete(route('products.destroy', $product))
        ->assertForbidden();
});

Test Tenant Isolation

test('users cannot access other tenant data', function () {
    $tenant1 = Tenant::factory()->create();
    $tenant2 = Tenant::factory()->create();
    
    $user = User::factory()->for($tenant1)->create();
    $product = Product::factory()->for($tenant2)->create();
    
    $this->actingAs($user)
        ->get(route('products.show', $product))
        ->assertForbidden();
});

Audit Trail

Critical operations MUST create audit records:
StockMovement::query()->create([
    'tenant_id' => $user->tenant_id,
    'product_variant_id' => $variant->id,
    'type' => $type,
    'quantity' => $quantity,
    'quantity_before' => $quantityBefore,
    'quantity_after' => $location->quantity,
    'created_by' => $user->id,
    'reason' => $reason,
]);
All stock changes MUST go through StockMovementService to maintain audit trail.

Additional Resources

Build docs developers (and LLMs) love