Skip to content

Commit 0c8532e

Browse files
committed
Wire multi-tenant scope, audit-log observer, /api/v1/tenants/me
Why this commit is more than a controller: a tenant-scoped admin app is only safe if every read goes through the tenant filter and every mutation lands in the audit log automatically. Both belong in the model layer, not in each controller, so the first endpoint can lean on them rather than re-deriving the guard. - TenantScope: global scope filtering by auth()->user()->tenant_id. Skips when unauthenticated (seeders, queue workers) so scope can be opted into by booted() without breaking those paths. - AuditLogObserver: records create/update/delete with before/after attribute snapshots; attached to Registry in AppServiceProvider. Models without a tenant_id (e.g., Tenant itself) are skipped for now — a follow-up handles Tenant-level audit. - Registry + AuditLogEntry now boot the global scope. - config/auth.php gains an 'api' guard backed by Passport so the new route can stand behind auth:api. Passport runtime tables / keys are not required for Passport::actingAs() in tests. - TenantController::me returns the auth user's tenant per the Tenant schema in docs/api/v1.yaml. Resource wrapping disabled so the response is flat (matches the schema; no 'data' envelope). - Feature test covers 401 (unauthenticated) and 200 (returns the caller's tenant with all four schema fields). phpunit: 9/9 green. pint --test: passed.
1 parent 8d5e901 commit 0c8532e

10 files changed

Lines changed: 239 additions & 7 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Http\Resources\TenantResource;
7+
use App\Models\Tenant;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Symfony\Component\HttpFoundation\Response;
11+
12+
class TenantController extends Controller
13+
{
14+
/**
15+
* GET /api/v1/tenants/me
16+
*
17+
* Returns the authenticated user's tenant. Conforms to the
18+
* Tenant schema in docs/api/v1.yaml.
19+
*/
20+
public function me(Request $request): JsonResponse
21+
{
22+
$user = $request->user();
23+
if ($user === null) {
24+
abort(Response::HTTP_UNAUTHORIZED);
25+
}
26+
27+
$tenantId = $user->tenant_id;
28+
if (! is_string($tenantId)) {
29+
abort(Response::HTTP_FORBIDDEN, 'user has no tenant');
30+
}
31+
32+
$tenant = Tenant::query()->findOrFail($tenantId);
33+
34+
TenantResource::withoutWrapping();
35+
36+
return (new TenantResource($tenant))
37+
->response()
38+
->setStatusCode(Response::HTTP_OK);
39+
}
40+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Http\Resources;
4+
5+
use App\Models\Tenant;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Http\Resources\Json\JsonResource;
8+
9+
/**
10+
* @mixin Tenant
11+
*/
12+
class TenantResource extends JsonResource
13+
{
14+
/**
15+
* Shape matches the Tenant schema in docs/api/v1.yaml.
16+
*
17+
* @return array<string,mixed>
18+
*/
19+
public function toArray(Request $request): array
20+
{
21+
return [
22+
'id' => $this->id,
23+
'slug' => $this->slug,
24+
'name' => $this->name,
25+
'created_at' => $this->created_at?->toIso8601String(),
26+
];
27+
}
28+
}

app/Models/AuditLogEntry.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Models;
44

5+
use App\Models\Scopes\TenantScope;
56
use Illuminate\Database\Eloquent\Concerns\HasUuids;
67
use Illuminate\Database\Eloquent\Model;
78
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -33,6 +34,11 @@ class AuditLogEntry extends Model
3334
*/
3435
public const UPDATED_AT = null;
3536

37+
protected static function booted(): void
38+
{
39+
static::addGlobalScope(new TenantScope);
40+
}
41+
3642
protected $fillable = [
3743
'tenant_id', 'user_id', 'action',
3844
'target_type', 'target_id',

app/Models/Registry.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Models;
44

5+
use App\Models\Scopes\TenantScope;
56
use Illuminate\Database\Eloquent\Concerns\HasUuids;
67
use Illuminate\Database\Eloquent\Model;
78
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -23,6 +24,11 @@ class Registry extends Model
2324
{
2425
use HasUuids;
2526

27+
protected static function booted(): void
28+
{
29+
static::addGlobalScope(new TenantScope);
30+
}
31+
2632
protected $fillable = ['tenant_id', 'name', 'schema_version', 'body'];
2733

2834
/** @var array<string,string> */

app/Models/Scopes/TenantScope.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Models\Scopes;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Scope;
8+
9+
/**
10+
* Global scope that filters every query on a tenant-scoped model by
11+
* the authenticated user's tenant_id.
12+
*
13+
* Models opt in via `static::addGlobalScope(new TenantScope)` in
14+
* their `booted()` method. Currently applied on Registry and
15+
* AuditLogEntry; Tenant + User are NOT scoped because:
16+
*
17+
* - Tenant: each user only sees their own, but lookup happens via
18+
* `auth()->user()->tenant`, not a query filter.
19+
* - User: a tenant admin should see every user in their tenant, but
20+
* the auth flow (Passport bearer) already resolves to one user.
21+
*
22+
* When no user is authenticated (web sign-in pages, queue workers
23+
* with explicit `withoutGlobalScope`), the scope is a no-op so we
24+
* don't accidentally return zero rows for legitimate unscoped reads.
25+
*/
26+
class TenantScope implements Scope
27+
{
28+
public function apply(Builder $builder, Model $model): void
29+
{
30+
$user = auth()->user();
31+
if ($user === null) {
32+
return;
33+
}
34+
$tenantId = $user->tenant_id ?? null;
35+
if ($tenantId === null) {
36+
return;
37+
}
38+
$builder->where(
39+
$model->qualifyColumn('tenant_id'),
40+
$tenantId,
41+
);
42+
}
43+
}

app/Observers/AuditLogObserver.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace App\Observers;
4+
5+
use App\Models\AuditLogEntry;
6+
use Illuminate\Database\Eloquent\Model;
7+
8+
/**
9+
* Records every create / update / delete on the models it's attached
10+
* to into the audit_log_entries table. The actor (user_id) comes
11+
* from the authenticated request; null when an unauthenticated path
12+
* mutates (e.g., seeders, queue workers).
13+
*
14+
* Registered in AppServiceProvider::boot():
15+
*
16+
* Registry::observe(AuditLogObserver::class);
17+
* Tenant::observe(AuditLogObserver::class); // optional
18+
*/
19+
class AuditLogObserver
20+
{
21+
public function created(Model $model): void
22+
{
23+
$this->record($model, 'create', null, $model->getAttributes());
24+
}
25+
26+
public function updated(Model $model): void
27+
{
28+
$before = $model->getOriginal();
29+
$after = $model->getAttributes();
30+
$this->record($model, 'update', $before, $after);
31+
}
32+
33+
public function deleted(Model $model): void
34+
{
35+
$this->record($model, 'delete', $model->getOriginal(), null);
36+
}
37+
38+
/**
39+
* @param array<string,mixed>|null $before
40+
* @param array<string,mixed>|null $after
41+
*/
42+
private function record(Model $model, string $action, ?array $before, ?array $after): void
43+
{
44+
$tenantId = $model->getAttribute('tenant_id');
45+
if (! is_string($tenantId)) {
46+
// Models without a tenant_id (Tenant itself when first
47+
// created) need a different audit path; skip silently
48+
// for now and surface in v0.2 when we audit Tenant ops.
49+
return;
50+
}
51+
52+
AuditLogEntry::create([
53+
'tenant_id' => $tenantId,
54+
'user_id' => auth()->id(),
55+
'action' => $action,
56+
'target_type' => $model::class,
57+
'target_id' => (string) $model->getKey(),
58+
'before' => $before,
59+
'after' => $after,
60+
]);
61+
}
62+
}

app/Providers/AppServiceProvider.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,19 @@
22

33
namespace App\Providers;
44

5+
use App\Models\Registry;
6+
use App\Observers\AuditLogObserver;
57
use Illuminate\Support\ServiceProvider;
68

79
class AppServiceProvider extends ServiceProvider
810
{
9-
/**
10-
* Register any application services.
11-
*/
1211
public function register(): void
1312
{
1413
//
1514
}
1615

17-
/**
18-
* Bootstrap any application services.
19-
*/
2016
public function boot(): void
2117
{
22-
//
18+
Registry::observe(AuditLogObserver::class);
2319
}
2420
}

config/auth.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
'driver' => 'session',
4343
'provider' => 'users',
4444
],
45+
'api' => [
46+
'driver' => 'passport',
47+
'provider' => 'users',
48+
],
4549
],
4650

4751
/*

routes/api.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
<?php
22

3+
use App\Http\Controllers\Api\V1\TenantController;
34
use Illuminate\Http\Request;
45
use Illuminate\Support\Facades\Route;
56

67
Route::get('/user', function (Request $request) {
78
return $request->user();
89
})->middleware('auth:api');
10+
11+
Route::middleware('auth:api')->prefix('v1')->group(function () {
12+
Route::get('/tenants/me', [TenantController::class, 'me']);
13+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace Tests\Feature\Api\V1;
4+
5+
use App\Models\Tenant;
6+
use App\Models\User;
7+
use Illuminate\Foundation\Testing\RefreshDatabase;
8+
use Laravel\Passport\Passport;
9+
use Tests\TestCase;
10+
11+
class TenantMeTest extends TestCase
12+
{
13+
use RefreshDatabase;
14+
15+
public function test_unauthenticated_request_is_rejected(): void
16+
{
17+
$this->getJson('/api/v1/tenants/me')->assertStatus(401);
18+
}
19+
20+
public function test_returns_the_authenticated_user_tenant(): void
21+
{
22+
$tenant = Tenant::create(['slug' => 'acme', 'name' => 'Acme Inc.']);
23+
$user = User::create([
24+
'tenant_id' => $tenant->id,
25+
'name' => 'Admin',
26+
'email' => 'admin@acme.example',
27+
'password' => 'hashed-not-real',
28+
'role' => 'owner',
29+
]);
30+
31+
Passport::actingAs($user);
32+
33+
$this->getJson('/api/v1/tenants/me')
34+
->assertOk()
35+
->assertJson([
36+
'id' => $tenant->id,
37+
'slug' => 'acme',
38+
'name' => 'Acme Inc.',
39+
])
40+
->assertJsonStructure(['id', 'slug', 'name', 'created_at']);
41+
}
42+
}

0 commit comments

Comments
 (0)