Skip to content

Commit 453a49d

Browse files
committed
feat: Passport (OAuth) + multi-tenant data model + first models
INITIALIZE.md steps 2 + 4 land: **Passport** (step 2): - composer require laravel/passport - php artisan install:api --passport (OAuth client tables + routes) - php artisan passport:keys (RSA keypair in storage/) - User model gets the HasApiTokens trait **Multi-tenant data model** (step 4): - Tenant model + migration: uuid pk, slug (unique), name, timestamps. Each Tenant owns Users, Registries, AuditLogEntries. - Registry model + migration: uuid pk, tenant_id FK (cascadeOnDelete), name, schema_version (default 1), body (JSON cast to array), unique (tenant_id, name). - AuditLogEntry model + migration: uuid pk, tenant_id FK, user_id FK (nullable, nullOnDelete), action enum (create|update|delete), target_type + target_id (polymorphic-ish), before/after JSON. Append-only: `const UPDATED_AT = null`. - User model + migration update: tenant_id FK (nullable for now — the first seeded admin pre-exists the first tenant), oauth_subject (indexed), role enum (owner|admin|editor|viewer, default viewer). Indexes on audit_log_entries (tenant_id, created_at) and (tenant_id, target_type, target_id) for fast tenant-scoped reads. 5 new feature tests (7 total): tenant UUID generation, tenant relationships (registries + users), registry body round-trips as array (the JSON cast), audit-log before/after persistence, audit-log tenant relationship. Next: AuditLogObserver (records every Registry mutation automatically), TenantScope global scope on each model, first controller for /api/v1/tenants/me per docs/api/v1.yaml.
1 parent da2d9f4 commit 453a49d

18 files changed

Lines changed: 1675 additions & 34 deletions

app/Models/AuditLogEntry.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
/**
10+
* One row per tenant-scoped mutation. Append-only; never deleted.
11+
*
12+
* Created by the AuditLogObserver when a Registry / Tenant is
13+
* created / updated / deleted (observer lives in
14+
* App\Observers\AuditLogObserver, registered in AppServiceProvider).
15+
*
16+
* @property string $id
17+
* @property string $tenant_id
18+
* @property int|null $user_id
19+
* @property string $action create|update|delete
20+
* @property string $target_type
21+
* @property string $target_id
22+
* @property array<string,mixed>|null $before
23+
* @property array<string,mixed>|null $after
24+
*/
25+
class AuditLogEntry extends Model
26+
{
27+
use HasUuids;
28+
29+
/**
30+
* Audit log is append-only: it has no `updated_at` semantic; the
31+
* record is immutable from creation. We still let Laravel manage
32+
* `created_at` via timestamps but never expose an update path.
33+
*/
34+
public const UPDATED_AT = null;
35+
36+
protected $fillable = [
37+
'tenant_id', 'user_id', 'action',
38+
'target_type', 'target_id',
39+
'before', 'after',
40+
];
41+
42+
/** @var array<string,string> */
43+
protected $casts = [
44+
'before' => 'array',
45+
'after' => 'array',
46+
];
47+
48+
/** @return BelongsTo<Tenant, $this> */
49+
public function tenant(): BelongsTo
50+
{
51+
return $this->belongsTo(Tenant::class);
52+
}
53+
54+
/** @return BelongsTo<User, $this> */
55+
public function user(): BelongsTo
56+
{
57+
return $this->belongsTo(User::class);
58+
}
59+
}

app/Models/Registry.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
/**
10+
* A get-installer registry owned by one Tenant.
11+
*
12+
* `body` holds the full registry.json payload; the controller layer
13+
* validates it against the get-installer registry.schema.json before
14+
* persisting.
15+
*
16+
* @property string $id
17+
* @property string $tenant_id
18+
* @property string $name
19+
* @property int $schema_version
20+
* @property array<string,mixed> $body
21+
*/
22+
class Registry extends Model
23+
{
24+
use HasUuids;
25+
26+
protected $fillable = ['tenant_id', 'name', 'schema_version', 'body'];
27+
28+
/** @var array<string,string> */
29+
protected $casts = [
30+
'body' => 'array',
31+
'schema_version' => 'integer',
32+
];
33+
34+
/** @return BelongsTo<Tenant, $this> */
35+
public function tenant(): BelongsTo
36+
{
37+
return $this->belongsTo(Tenant::class);
38+
}
39+
}

app/Models/Tenant.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
8+
9+
/**
10+
* One Tenant per organisation. Holds Users, Registries, and the
11+
* AuditLogEntries that record every mutation in that org's scope.
12+
*
13+
* Resolved per-request by the TenantScope middleware:
14+
* `auth()->user()?->tenant`. Models filtered by global scope (see
15+
* Registry::booted + AuditLogEntry::booted).
16+
*
17+
* @property string $id
18+
* @property string $slug
19+
* @property string $name
20+
*/
21+
class Tenant extends Model
22+
{
23+
use HasUuids;
24+
25+
protected $fillable = ['slug', 'name'];
26+
27+
/** @return HasMany<User, $this> */
28+
public function users(): HasMany
29+
{
30+
return $this->hasMany(User::class);
31+
}
32+
33+
/** @return HasMany<Registry, $this> */
34+
public function registries(): HasMany
35+
{
36+
return $this->hasMany(Registry::class);
37+
}
38+
39+
/** @return HasMany<AuditLogEntry, $this> */
40+
public function auditLog(): HasMany
41+
{
42+
return $this->hasMany(AuditLogEntry::class);
43+
}
44+
}

app/Models/User.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,41 @@
55
// use Illuminate\Contracts\Auth\MustVerifyEmail;
66
use Database\Factories\UserFactory;
77
use Illuminate\Database\Eloquent\Factories\HasFactory;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
89
use Illuminate\Foundation\Auth\User as Authenticatable;
910
use Illuminate\Notifications\Notifiable;
11+
use Laravel\Passport\HasApiTokens;
1012

13+
/**
14+
* @property string|null $tenant_id
15+
* @property string|null $oauth_subject
16+
* @property string $role owner|admin|editor|viewer
17+
*/
1118
class User extends Authenticatable
1219
{
1320
/** @use HasFactory<UserFactory> */
14-
use HasFactory, Notifiable;
21+
use HasApiTokens, HasFactory, Notifiable;
1522

1623
/**
1724
* The attributes that are mass assignable.
1825
*
1926
* @var list<string>
2027
*/
2128
protected $fillable = [
29+
'tenant_id',
2230
'name',
2331
'email',
2432
'password',
33+
'oauth_subject',
34+
'role',
2535
];
2636

37+
/** @return BelongsTo<Tenant, $this> */
38+
public function tenant(): BelongsTo
39+
{
40+
return $this->belongsTo(Tenant::class);
41+
}
42+
2743
/**
2844
* The attributes that should be hidden for serialization.
2945
*

bootstrap/app.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
return Application::configure(basePath: dirname(__DIR__))
88
->withRouting(
99
web: __DIR__.'/../routes/web.php',
10+
api: __DIR__.'/../routes/api.php',
1011
commands: __DIR__.'/../routes/console.php',
1112
health: '/up',
1213
)

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"require": {
2121
"php": "^8.2",
2222
"laravel/framework": "^12.0",
23+
"laravel/passport": "^13.0",
2324
"laravel/tinker": "^2.10.1"
2425
},
2526
"require-dev": {

0 commit comments

Comments
 (0)