feat: implement auth structure system panel
This commit is contained in:
parent
c5fbcaa692
commit
0e3be8bdf4
@ -69,3 +69,7 @@ ENTRA_CLIENT_ID=
|
|||||||
ENTRA_CLIENT_SECRET=
|
ENTRA_CLIENT_SECRET=
|
||||||
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"
|
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"
|
||||||
ENTRA_AUTHORITY_TENANT=organizations
|
ENTRA_AUTHORITY_TENANT=organizations
|
||||||
|
|
||||||
|
# System panel break-glass (Platform Operators)
|
||||||
|
BREAK_GLASS_ENABLED=false
|
||||||
|
BREAK_GLASS_TTL_MINUTES=60
|
||||||
|
|||||||
@ -896,9 +896,9 @@ ### Replaced Utilities
|
|||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
|
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
|
||||||
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0` (063-entra-signin)
|
- PostgreSQL (with a new `platform_users` table) (064-auth-structure)
|
||||||
|
|||||||
@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -22,13 +16,13 @@ class BreakGlassRecovery extends Page
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 999;
|
protected static ?int $navigationSort = 999;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected string $view = 'filament.pages.break-glass-recovery';
|
protected string $view = 'filament.pages.break-glass-recovery';
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
return false;
|
||||||
|
|
||||||
return $user instanceof User && $user->isPlatformSuperadmin();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,62 +30,6 @@ public static function canAccess(): bool
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
Action::make('bootstrap_recover')
|
|
||||||
->label('Assign owner (recovery)')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Break-glass: assign owner')
|
|
||||||
->modalDescription('This grants Owner access to a tenant. Use for recovery only. This action is audited.')
|
|
||||||
->form([
|
|
||||||
Select::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->options(fn (): array => Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->all()),
|
|
||||||
Select::make('user_id')
|
|
||||||
->label('User')
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->options(fn (): array => User::query()
|
|
||||||
->orderBy('name')
|
|
||||||
->pluck('name', 'id')
|
|
||||||
->all()),
|
|
||||||
])
|
|
||||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
|
||||||
$actor = auth()->user();
|
|
||||||
|
|
||||||
if (! $actor instanceof User || ! $actor->isPlatformSuperadmin()) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->whereKey((int) $data['tenant_id'])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
Notification::make()->title('Tenant not found')->danger()->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$member = User::query()->whereKey((int) $data['user_id'])->first();
|
|
||||||
|
|
||||||
if (! $member instanceof User) {
|
|
||||||
Notification::make()->title('User not found')->danger()->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$manager->bootstrapRecover($tenant, $actor, $member);
|
|
||||||
|
|
||||||
Notification::make()->title('Owner assigned')->success()->send();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
app/Filament/System/Pages/Auth/Login.php
Normal file
82
app/Filament/System/Pages/Auth/Login.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages\Auth;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||||
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class Login extends BaseLogin
|
||||||
|
{
|
||||||
|
public function authenticate(): ?LoginResponse
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
$email = (string) ($data['email'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = parent::authenticate();
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
$this->audit(status: 'failure', email: $email, actor: null, reason: 'invalid_credentials');
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var PlatformUser|null $user */
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! ($user instanceof PlatformUser)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
auth('platform')->logout();
|
||||||
|
|
||||||
|
$this->audit(status: 'failure', email: $email, actor: null, reason: 'inactive');
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'data.email' => __('filament-panels::auth/pages/login.messages.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill(['last_login_at' => now()])->saveQuietly();
|
||||||
|
|
||||||
|
$this->audit(status: 'success', email: $email, actor: $user);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function audit(string $status, string $email, ?PlatformUser $actor, ?string $reason = null): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'platform.auth.login',
|
||||||
|
context: [
|
||||||
|
'attempted_email' => $email,
|
||||||
|
'ip' => request()->ip(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
'reason' => $reason,
|
||||||
|
],
|
||||||
|
actorId: $actor?->getKey(),
|
||||||
|
actorEmail: $actor?->email ?? ($email ?: null),
|
||||||
|
actorName: $actor?->name,
|
||||||
|
status: $status,
|
||||||
|
resourceType: 'platform_user',
|
||||||
|
resourceId: $actor ? (string) $actor->getKey() : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Filament/System/Pages/Dashboard.php
Normal file
87
app/Filament/System/Pages/Dashboard.php
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\System\Pages;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Services\Auth\BreakGlassSession;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Dashboard as BaseDashboard;
|
||||||
|
|
||||||
|
class Dashboard extends BaseDashboard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$breakGlass = app(BreakGlassSession::class);
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
$canUseBreakGlass = $breakGlass->isEnabled()
|
||||||
|
&& $user instanceof PlatformUser
|
||||||
|
&& $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Action::make('enter_break_glass')
|
||||||
|
->label('Enter break-glass mode')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Enter break-glass mode')
|
||||||
|
->modalDescription('Recovery mode is time-limited and fully audited. Use for recovery only.')
|
||||||
|
->form([
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Reason')
|
||||||
|
->required()
|
||||||
|
->minLength(5)
|
||||||
|
->maxLength(500)
|
||||||
|
->rows(4),
|
||||||
|
])
|
||||||
|
->action(function (array $data, BreakGlassSession $breakGlass): void {
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Recovery mode enabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
Action::make('exit_break_glass')
|
||||||
|
->label('Exit break-glass')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Exit break-glass mode')
|
||||||
|
->modalDescription('This will immediately end recovery mode.')
|
||||||
|
->action(function (BreakGlassSession $breakGlass): void {
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if (! $user instanceof PlatformUser) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$breakGlass->exit($user);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Recovery mode ended')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Middleware/EnsureCorrectGuard.php
Normal file
41
app/Http/Middleware/EnsureCorrectGuard.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureCorrectGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string $expectedGuard): Response
|
||||||
|
{
|
||||||
|
$expectedGuard = trim($expectedGuard);
|
||||||
|
|
||||||
|
if ($expectedGuard === '') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownGuards = [
|
||||||
|
'web',
|
||||||
|
'platform',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($knownGuards as $guard) {
|
||||||
|
if ($guard === $expectedGuard) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth($guard)->check()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Middleware/EnsurePlatformCapability.php
Normal file
37
app/Http/Middleware/EnsurePlatformCapability.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsurePlatformCapability
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string $capability): Response
|
||||||
|
{
|
||||||
|
$capability = trim($capability);
|
||||||
|
|
||||||
|
if ($capability === '') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows($capability)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Models/PlatformUser.php
Normal file
76
app/Models/PlatformUser.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
|
use Filament\Panel;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
|
class PlatformUser extends Authenticatable implements FilamentUser
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PlatformUserFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'capabilities',
|
||||||
|
'is_active',
|
||||||
|
'last_login_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'capabilities' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_login_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessPanel(Panel $panel): bool
|
||||||
|
{
|
||||||
|
return $panel->getId() === 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCapability(string $capability): bool
|
||||||
|
{
|
||||||
|
$capability = trim($capability);
|
||||||
|
|
||||||
|
if ($capability === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilities = $this->capabilities;
|
||||||
|
|
||||||
|
if (! is_array($capabilities)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($capability, $capabilities, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -171,8 +171,9 @@ public function getFilamentName(): string
|
|||||||
|
|
||||||
public function users(): BelongsToMany
|
public function users(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class)
|
return $this->belongsToMany(User::class, 'tenant_memberships')
|
||||||
->withPivot('role')
|
->using(TenantMembership::class)
|
||||||
|
->withPivot(['id', 'role', 'source', 'source_ref', 'created_by_user_id'])
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,15 +57,9 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'is_platform_superadmin' => 'bool',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isPlatformSuperadmin(): bool
|
|
||||||
{
|
|
||||||
return (bool) $this->is_platform_superadmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@ -133,10 +127,6 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isPlatformSuperadmin()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->tenantPivotTableExists()) {
|
if (! $this->tenantPivotTableExists()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -148,13 +138,6 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
{
|
{
|
||||||
if ($this->isPlatformSuperadmin()) {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->tenantPivotTableExists()) {
|
if (! $this->tenantPivotTableExists()) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
@ -167,13 +150,6 @@ public function getTenants(Panel $panel): array|Collection
|
|||||||
|
|
||||||
public function getDefaultTenant(Panel $panel): ?Model
|
public function getDefaultTenant(Panel $panel): ?Model
|
||||||
{
|
{
|
||||||
if ($this->isPlatformSuperadmin()) {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->tenantPivotTableExists()) {
|
if (! $this->tenantPivotTableExists()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
@ -45,5 +47,11 @@ public function boot(): void
|
|||||||
] as $capability) {
|
] as $capability) {
|
||||||
$defineTenantCapability($capability);
|
$defineTenantCapability($capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (PlatformCapabilities::all() as $capability) {
|
||||||
|
Gate::define($capability, function (PlatformUser $user) use ($capability): bool {
|
||||||
|
return $user->hasCapability($capability);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,10 +48,6 @@ public function panel(Panel $panel): Panel
|
|||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
->renderHook(
|
|
||||||
PanelsRenderHook::BODY_START,
|
|
||||||
fn () => view('filament.partials.break-glass-banner')->render()
|
|
||||||
)
|
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::HEAD_END,
|
PanelsRenderHook::HEAD_END,
|
||||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||||
@ -82,6 +78,7 @@ public function panel(Panel $panel): Panel
|
|||||||
ShareErrorsFromSession::class,
|
ShareErrorsFromSession::class,
|
||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
DenyNonMemberTenantAccess::class,
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
|
|||||||
59
app/Providers/Filament/SystemPanelProvider.php
Normal file
59
app/Providers/Filament/SystemPanelProvider.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Auth\Login;
|
||||||
|
use App\Filament\System\Pages\Dashboard;
|
||||||
|
use Filament\Http\Middleware\Authenticate;
|
||||||
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Panel;
|
||||||
|
use Filament\PanelProvider;
|
||||||
|
use Filament\Support\Colors\Color;
|
||||||
|
use Filament\View\PanelsRenderHook;
|
||||||
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
|
||||||
|
class SystemPanelProvider extends PanelProvider
|
||||||
|
{
|
||||||
|
public function panel(Panel $panel): Panel
|
||||||
|
{
|
||||||
|
return $panel
|
||||||
|
->id('system')
|
||||||
|
->path('system')
|
||||||
|
->authGuard('platform')
|
||||||
|
->login(Login::class)
|
||||||
|
->colors([
|
||||||
|
'primary' => Color::Blue,
|
||||||
|
])
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::BODY_START,
|
||||||
|
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||||
|
)
|
||||||
|
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
|
||||||
|
->pages([
|
||||||
|
Dashboard::class,
|
||||||
|
])
|
||||||
|
->middleware([
|
||||||
|
EncryptCookies::class,
|
||||||
|
AddQueuedCookiesToResponse::class,
|
||||||
|
StartSession::class,
|
||||||
|
AuthenticateSession::class,
|
||||||
|
ShareErrorsFromSession::class,
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
SubstituteBindings::class,
|
||||||
|
'ensure-correct-guard:platform',
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
])
|
||||||
|
->authMiddleware([
|
||||||
|
Authenticate::class,
|
||||||
|
'ensure-platform-capability:platform.access_system_panel',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
app/Services/Auth/BreakGlassSession.php
Normal file
176
app/Services/Auth/BreakGlassSession.php
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Contracts\Session\Session;
|
||||||
|
|
||||||
|
class BreakGlassSession
|
||||||
|
{
|
||||||
|
private const KEY_PREFIX = 'system.break_glass.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly AuditLogger $auditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('tenantpilot.break_glass.enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ttlMinutes(): int
|
||||||
|
{
|
||||||
|
return (int) config('tenantpilot.break_glass.ttl_minutes', 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = $this->expiresAt();
|
||||||
|
|
||||||
|
if (! $expiresAt instanceof CarbonImmutable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiresAt->isPast()) {
|
||||||
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
|
if ($user instanceof PlatformUser) {
|
||||||
|
$this->expire($user);
|
||||||
|
} else {
|
||||||
|
$this->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(PlatformUser $user, string $reason): void
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = trim($reason);
|
||||||
|
|
||||||
|
$now = CarbonImmutable::now();
|
||||||
|
$expiresAt = $now->addMinutes($this->ttlMinutes());
|
||||||
|
|
||||||
|
$this->session->put(self::KEY_PREFIX.'started_at', $now->toISOString());
|
||||||
|
$this->session->put(self::KEY_PREFIX.'expires_at', $expiresAt->toISOString());
|
||||||
|
$this->session->put(self::KEY_PREFIX.'reason', $reason);
|
||||||
|
|
||||||
|
$this->audit(
|
||||||
|
$user,
|
||||||
|
action: 'platform.break_glass.enter',
|
||||||
|
status: 'success',
|
||||||
|
metadata: [
|
||||||
|
'reason' => $reason,
|
||||||
|
'started_at' => $now->toISOString(),
|
||||||
|
'expires_at' => $expiresAt->toISOString(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exit(PlatformUser $user): void
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->expiresAt() instanceof CarbonImmutable) {
|
||||||
|
$this->clear();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'started_at' => $this->session->get(self::KEY_PREFIX.'started_at'),
|
||||||
|
'expires_at' => $this->session->get(self::KEY_PREFIX.'expires_at'),
|
||||||
|
'reason' => $this->session->get(self::KEY_PREFIX.'reason'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->clear();
|
||||||
|
|
||||||
|
$this->audit(
|
||||||
|
$user,
|
||||||
|
action: 'platform.break_glass.exit',
|
||||||
|
status: 'success',
|
||||||
|
metadata: array_filter($metadata, fn ($value): bool => $value !== null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->session->forget([
|
||||||
|
self::KEY_PREFIX.'started_at',
|
||||||
|
self::KEY_PREFIX.'expires_at',
|
||||||
|
self::KEY_PREFIX.'reason',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expiresAt(): ?CarbonImmutable
|
||||||
|
{
|
||||||
|
$raw = $this->session->get(self::KEY_PREFIX.'expires_at');
|
||||||
|
|
||||||
|
if (! is_string($raw) || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return CarbonImmutable::parse($raw);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expire(PlatformUser $user): void
|
||||||
|
{
|
||||||
|
$metadata = [
|
||||||
|
'started_at' => $this->session->get(self::KEY_PREFIX.'started_at'),
|
||||||
|
'expires_at' => $this->session->get(self::KEY_PREFIX.'expires_at'),
|
||||||
|
'reason' => $this->session->get(self::KEY_PREFIX.'reason'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->clear();
|
||||||
|
|
||||||
|
$this->audit(
|
||||||
|
$user,
|
||||||
|
action: 'platform.break_glass.expired',
|
||||||
|
status: 'success',
|
||||||
|
metadata: array_filter($metadata, fn ($value): bool => $value !== null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function audit(PlatformUser $user, string $action, string $status, array $metadata): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
$tenant,
|
||||||
|
action: $action,
|
||||||
|
context: [
|
||||||
|
'metadata' => $metadata,
|
||||||
|
],
|
||||||
|
actorId: (int) $user->getKey(),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
status: $status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,10 +22,6 @@ class CapabilityResolver
|
|||||||
*/
|
*/
|
||||||
public function getRole(User $user, Tenant $tenant): ?TenantRole
|
public function getRole(User $user, Tenant $tenant): ?TenantRole
|
||||||
{
|
{
|
||||||
if ($user->isPlatformSuperadmin()) {
|
|
||||||
return TenantRole::Owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
$membership = $this->getMembership($user, $tenant);
|
$membership = $this->getMembership($user, $tenant);
|
||||||
|
|
||||||
if ($membership === null) {
|
if ($membership === null) {
|
||||||
@ -40,10 +36,6 @@ public function getRole(User $user, Tenant $tenant): ?TenantRole
|
|||||||
*/
|
*/
|
||||||
public function can(User $user, Tenant $tenant, string $capability): bool
|
public function can(User $user, Tenant $tenant, string $capability): bool
|
||||||
{
|
{
|
||||||
if ($user->isPlatformSuperadmin()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$role = $this->getRole($user, $tenant);
|
$role = $this->getRole($user, $tenant);
|
||||||
|
|
||||||
if ($role === null) {
|
if ($role === null) {
|
||||||
@ -58,10 +50,6 @@ public function can(User $user, Tenant $tenant, string $capability): bool
|
|||||||
*/
|
*/
|
||||||
public function isMember(User $user, Tenant $tenant): bool
|
public function isMember(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
if ($user->isPlatformSuperadmin()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getMembership($user, $tenant) !== null;
|
return $this->getMembership($user, $tenant) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,13 +34,6 @@ public function resolve(User $user): string
|
|||||||
*/
|
*/
|
||||||
private function getActiveTenants(User $user): Collection
|
private function getActiveTenants(User $user): Collection
|
||||||
{
|
{
|
||||||
if ($user->isPlatformSuperadmin()) {
|
|
||||||
return Tenant::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->tenants()
|
return $user->tenants()
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
|
|||||||
26
app/Support/Auth/PlatformCapabilities.php
Normal file
26
app/Support/Auth/PlatformCapabilities.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform Capability Registry
|
||||||
|
*
|
||||||
|
* These capabilities are used for platform operators authenticated via the
|
||||||
|
* `platform` guard (System panel).
|
||||||
|
*/
|
||||||
|
class PlatformCapabilities
|
||||||
|
{
|
||||||
|
public const ACCESS_SYSTEM_PANEL = 'platform.access_system_panel';
|
||||||
|
|
||||||
|
public const USE_BREAK_GLASS = 'platform.use_break_glass';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass(self::class);
|
||||||
|
|
||||||
|
return array_values($reflection->getConstants());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,15 @@
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->alias([
|
||||||
|
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
|
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$middleware->prependToPriorityList(
|
||||||
|
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||||
|
\App\Http\Middleware\EnsureCorrectGuard::class,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@ -4,4 +4,5 @@
|
|||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\AuthServiceProvider::class,
|
App\Providers\AuthServiceProvider::class,
|
||||||
App\Providers\Filament\AdminPanelProvider::class,
|
App\Providers\Filament\AdminPanelProvider::class,
|
||||||
|
App\Providers\Filament\SystemPanelProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -40,6 +40,11 @@
|
|||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'platform' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'platform_users',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -65,6 +70,11 @@
|
|||||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'platform_users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => App\Models\PlatformUser::class,
|
||||||
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
// 'driver' => 'database',
|
// 'driver' => 'database',
|
||||||
// 'table' => 'users',
|
// 'table' => 'users',
|
||||||
@ -97,6 +107,13 @@
|
|||||||
'expire' => 60,
|
'expire' => 60,
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'platform_users' => [
|
||||||
|
'provider' => 'platform_users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'break_glass' => [
|
||||||
|
'enabled' => (bool) env('BREAK_GLASS_ENABLED', false),
|
||||||
|
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
||||||
|
],
|
||||||
|
|
||||||
'supported_policy_types' => [
|
'supported_policy_types' => [
|
||||||
[
|
[
|
||||||
'type' => 'deviceConfiguration',
|
'type' => 'deviceConfiguration',
|
||||||
|
|||||||
36
database/factories/PlatformUserFactory.php
Normal file
36
database/factories/PlatformUserFactory.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PlatformUser>
|
||||||
|
*/
|
||||||
|
class PlatformUserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'capabilities' => [],
|
||||||
|
'is_active' => true,
|
||||||
|
'last_login_at' => null,
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('platform_users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->string('password');
|
||||||
|
$table->jsonb('capabilities')->default('[]');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('last_login_at')->nullable();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('platform_users');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -18,6 +18,7 @@ public function run(): void
|
|||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
PoliciesSeeder::class,
|
PoliciesSeeder::class,
|
||||||
|
PlatformUserSeeder::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
User::query()->updateOrCreate(
|
User::query()->updateOrCreate(
|
||||||
|
|||||||
35
database/seeders/PlatformUserSeeder.php
Normal file
35
database/seeders/PlatformUserSeeder.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class PlatformUserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
Tenant::query()->updateOrCreate(
|
||||||
|
['external_id' => 'platform'],
|
||||||
|
['name' => 'Platform'],
|
||||||
|
);
|
||||||
|
|
||||||
|
PlatformUser::query()->updateOrCreate(
|
||||||
|
['email' => 'operator@tenantpilot.io'],
|
||||||
|
[
|
||||||
|
'name' => 'Platform Operator',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'capabilities' => [
|
||||||
|
'platform.access_system_panel',
|
||||||
|
'platform.use_break_glass',
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +1,4 @@
|
|||||||
@php
|
{{--
|
||||||
/** @var \App\Models\User|null $user */
|
Legacy break-glass banner removed.
|
||||||
$user = auth()->user();
|
Admin panel must not expose break-glass UI.
|
||||||
@endphp
|
--}}
|
||||||
|
|
||||||
@if ($user instanceof \App\Models\User && $user->isPlatformSuperadmin())
|
|
||||||
<div class="fi-topbar sticky top-0 z-50 border-b border-red-500/30 bg-red-600 text-white">
|
|
||||||
<div class="mx-auto flex max-w-screen-2xl items-center justify-between gap-4 px-4 py-2">
|
|
||||||
<div class="text-sm font-semibold">
|
|
||||||
Break-glass mode: platform superadmin access
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs opacity-90">
|
|
||||||
Use for recovery only. All actions are audited.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
@php
|
||||||
|
/** @var \App\Services\Auth\BreakGlassSession $breakGlass */
|
||||||
|
$breakGlass = app(\App\Services\Auth\BreakGlassSession::class);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($breakGlass->isActive())
|
||||||
|
<div class="fi-topbar sticky top-0 z-50 border-b border-red-500/30 bg-red-600 text-white">
|
||||||
|
<div class="mx-auto flex max-w-screen-2xl items-center justify-between gap-4 px-4 py-2">
|
||||||
|
<div class="text-sm font-semibold">
|
||||||
|
Recovery mode active
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($breakGlass->expiresAt())
|
||||||
|
<div class="text-xs opacity-90">
|
||||||
|
Expires at {{ $breakGlass->expiresAt()->toDayDateTimeString() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
34
specs/064-auth-structure/checklists/requirements.md
Normal file
34
specs/064-auth-structure/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Auth Structure v1.0: Panel & Identity Separation
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: Tuesday, January 27, 2026
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
All checks passed. The specification is comprehensive and ready for the planning phase. The provided input was exceptionally detailed, leaving no room for ambiguity.
|
||||||
50
specs/064-auth-structure/data-model.md
Normal file
50
specs/064-auth-structure/data-model.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Data Model: Auth Structure
|
||||||
|
|
||||||
|
This document defines the database schema changes for the `064-auth-structure` feature.
|
||||||
|
|
||||||
|
## New Tables
|
||||||
|
|
||||||
|
### `platform_users`
|
||||||
|
|
||||||
|
This table stores the authentication and profile information for Platform Operators. These users are managed locally and are entirely separate from the tenant-facing `users` table.
|
||||||
|
|
||||||
|
**Purpose**: To provide a dedicated identity store for system administrators and operators, enabling secure access to the `/system` panel.
|
||||||
|
|
||||||
|
**Laravel Migration Definition**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schema::create('platform_users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->string('password');
|
||||||
|
$table->jsonb('capabilities')->default('[]');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('last_login_at')->nullable();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Definitions
|
||||||
|
|
||||||
|
| Column | Type | Description | Notes |
|
||||||
|
|----------------|----------------------|-----------------------------------------------------------------------------------------------------------|----------------------------------------|
|
||||||
|
| `id` | `bigint`, `unsigned` | Primary key. | Auto-incrementing. |
|
||||||
|
| `name` | `string` | The full name of the platform operator. | Required. |
|
||||||
|
| `email` | `string` | The unique email address used for login. | Must be unique across the table. |
|
||||||
|
| `password` | `string` | The hashed password for the user. | Never stored in plain text. |
|
||||||
|
| `capabilities` | `jsonb` | A list of string identifiers for permissions (e.g., `["platform.use_break_glass"]`). | Defaults to an empty array (`[]`). |
|
||||||
|
| `is_active` | `boolean` | Flag to enable or disable the account. Inactive users cannot log in. | Defaults to `true`. |
|
||||||
|
| `last_login_at`| `timestamp` | Records the timestamp of the user's last successful login. | Nullable. |
|
||||||
|
| `remember_token` | `string` | Used by Laravel's "Remember Me" functionality. | Nullable. |
|
||||||
|
| `created_at` | `timestamp` | Timestamp of when the record was created. | Managed by Eloquent. |
|
||||||
|
| `updated_at` | `timestamp` | Timestamp of when the record was last updated. | Managed by Eloquent. |
|
||||||
|
|
||||||
|
## Modified Tables
|
||||||
|
|
||||||
|
No existing tables will be modified as part of the core data model changes.
|
||||||
|
|
||||||
|
## Deprecations
|
||||||
|
|
||||||
|
- **`users.is_platform_superadmin`**: This column in the `users` table is now considered deprecated. No new code should rely on it for authorization. A separate, future migration will be responsible for its removal after a backfill process is complete.
|
||||||
103
specs/064-auth-structure/plan.md
Normal file
103
specs/064-auth-structure/plan.md
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
# Implementation Plan: Auth Structure v1.0
|
||||||
|
|
||||||
|
**Branch**: `064-auth-structure` | **Date**: Tuesday, January 27, 2026 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/064-auth-structure/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This feature will implement a strict separation between Tenant and Platform administrator identities by creating two distinct Filament panels: `/admin` for Entra OIDC-authenticated tenants and `/system` for locally authenticated platform operators. The technical approach involves creating a new `PlatformUser` model and `platform_users` table, configuring custom authentication guards and providers in Laravel, and implementing middleware to enforce routing isolation. A secure, session-based "break-glass" mode for emergency recovery will be added to the system panel, gated by a feature flag and fully audited. The /admin panel MUST not expose break-glass or local password login UI; it remains tenant-user Entra OIDC only.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Storage**: PostgreSQL (with a new `platform_users` table)
|
||||||
|
**Testing**: Pest
|
||||||
|
**Target Platform**: Web Application (deployed via Dokploy)
|
||||||
|
**Project Type**: Monolithic web application
|
||||||
|
**Performance Goals**: Standard web application responsiveness for authentication and panel loading.
|
||||||
|
**Constraints**: Must enforce strict identity and panel separation. Cross-panel access attempts must result in 404 Not Found errors to prevent information leakage. All enforcement must be implemented using files/locations that exist in this repo (no assumptions about RouteServiceProvider).
|
||||||
|
**Scale/Scope**: Two distinct user authentication stacks within a single Laravel application.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- **Inventory-first**: Not applicable.
|
||||||
|
- **Read/write separation**: **PASS**. The "break-glass" feature, which allows privileged actions, is designed with explicit safety gates: it requires a feature flag (`BREAK_GLASS_ENABLED`), user confirmation, is time-limited, and all state changes (enter, exit, expire) are audited. Tenant recovery actions are also explicitly audited.
|
||||||
|
- **Graph contract path**: **PASS**. No new Graph calls are introduced.
|
||||||
|
- **Deterministic capabilities**: **PASS**. Platform authorization will be handled via a testable, capability-based system (`platform_users.capabilities` column and associated Gates).
|
||||||
|
- **Tenant isolation**: **PASS**. This is the core goal. The plan enforces isolation via separate guards, providers, and middleware that returns 404 on cross-scope access.
|
||||||
|
- **Run observability**: **PASS**. While login and break-glass actions do not create `OperationRun` records, the spec mandates they create detailed `AuditLog` entries, aligning with the constitution's requirements for security-relevant actions.
|
||||||
|
- **Automation**: Not applicable.
|
||||||
|
- **Data minimization**: **PASS**. No new sensitive data is being stored beyond what is necessary for authentication.
|
||||||
|
- **Badge semantics (BADGE-001)**: Not applicable.
|
||||||
|
|
||||||
|
*(Note: Apply the repo's governance/constitution rules for identity separation, break-glass recovery-only posture, and deny-as-not-found isolation. This plan assumes the constitution exists and is authoritative.)*
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/064-auth-structure/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output (N/A for this feature)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
This is a monolithic Laravel application. The changes will be integrated into the existing structure.
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ └── System/ # System panel pages/resources (guard=platform, path=/system)
|
||||||
|
├── Http/
|
||||||
|
│ └── Middleware/
|
||||||
|
│ └── EnsureCorrectGuard.php # New middleware for 404 enforcement
|
||||||
|
├── Models/
|
||||||
|
│ └── PlatformUser.php # New Eloquent model
|
||||||
|
└── Providers/
|
||||||
|
├── AuthServiceProvider.php # Modified to register platform capability gates (and existing tenant gates)
|
||||||
|
└── Filament/
|
||||||
|
├── AdminPanelProvider.php # Existing tenant panel provider
|
||||||
|
└── SystemPanelProvider.php # New platform panel provider
|
||||||
|
config/
|
||||||
|
├── auth.php # Modified for new guards and providers
|
||||||
|
database/
|
||||||
|
├── factories/
|
||||||
|
│ └── PlatformUserFactory.php # New factory
|
||||||
|
├── migrations/
|
||||||
|
│ └── ..._create_platform_users_table.php # New migration
|
||||||
|
└── seeders/
|
||||||
|
├── DatabaseSeeder.php # Modified to call PlatformUserSeeder
|
||||||
|
└── PlatformUserSeeder.php # New seeder for initial platform admin
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
└── system/
|
||||||
|
└── components/
|
||||||
|
└── break-glass-banner.blade.php # New view for banner
|
||||||
|
routes/
|
||||||
|
├── web.php # Modified to apply per-panel guard middleware and deny-as-not-found (404) cross-scope isolation
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Auth/
|
||||||
|
│ ├── AdminPanelAuthTest.php # New
|
||||||
|
│ ├── SystemPanelAuthTest.php # New
|
||||||
|
│ ├── BreakGlassModeTest.php # New
|
||||||
|
│ └── CrossScopeAccessTest.php # New (assert 404 deny-as-not-found on cross-scope access)
|
||||||
|
└── Deprecation/
|
||||||
|
└── IsPlatformSuperadminDeprecationTest.php # New arch test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: The implementation will extend the existing monolithic Laravel application structure. New concepts like the System Panel and Platform User will be encapsulated in their own directories (`app/Filament/System`, `app/Models/PlatformUser.php`, `app/Providers/Filament/SystemPanelProvider.php`) to maintain organization, following standard Laravel and Filament conventions.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitutional violations requiring justification.
|
||||||
68
specs/064-auth-structure/quickstart.md
Normal file
68
specs/064-auth-structure/quickstart.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Quickstart: Auth Structure
|
||||||
|
|
||||||
|
This guide provides the essential steps for a developer to set up and test the `064-auth-structure` feature locally.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Ensure you are on the `064-auth-structure` feature branch.
|
||||||
|
- A working local development environment (Laravel Sail is preferred).
|
||||||
|
- Entra ID application credentials must be configured in your `.env` file to test the `/admin` panel.
|
||||||
|
|
||||||
|
## 1. Apply Database Changes
|
||||||
|
|
||||||
|
Run the new migration to create the `platform_users` table and seed it with an initial administrator account.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using Laravel Sail
|
||||||
|
./vendor/bin/sail artisan migrate
|
||||||
|
./vendor/bin/sail artisan db:seed --class=PlatformUserSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
The default seeded platform user will be:
|
||||||
|
- **Email**: `operator@tenantpilot.io`
|
||||||
|
- **Password**: `password`
|
||||||
|
|
||||||
|
## 2. Configure Environment Variables
|
||||||
|
|
||||||
|
Add the following variables to your local `.env` file to control the new features.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# .env
|
||||||
|
|
||||||
|
# Enables the "break-glass" feature in the System Panel.
|
||||||
|
# Default: false
|
||||||
|
BREAK_GLASS_ENABLED=true
|
||||||
|
|
||||||
|
# Sets the duration (in minutes) for a break-glass session before it auto-expires.
|
||||||
|
# Default: 60
|
||||||
|
BREAK_GLASS_TTL_MINUTES=60
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Verification Steps
|
||||||
|
|
||||||
|
Follow these steps to confirm the feature is working correctly.
|
||||||
|
|
||||||
|
### a. Test System Panel Access
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost/system/login`.
|
||||||
|
2. Log in using the seeded platform operator credentials:
|
||||||
|
- Email: `operator@tenantpilot.io`
|
||||||
|
- Password: `password`
|
||||||
|
3. You should be successfully redirected to the System Panel dashboard.
|
||||||
|
4. If `BREAK_GLASS_ENABLED` is `true`, find and activate the "Enter break-glass mode" feature. A persistent banner should appear at the top of the page.
|
||||||
|
|
||||||
|
### b. Test Admin Panel Access
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost/admin/login`.
|
||||||
|
2. Log in using a valid Microsoft Entra ID test user associated with a tenant.
|
||||||
|
3. You should be successfully redirected to that tenant's dashboard.
|
||||||
|
|
||||||
|
### c. Test Isolation (Cross-Scope Access)
|
||||||
|
|
||||||
|
1. **While logged into the System Panel** (`/system`), attempt to navigate directly to a tenant-scoped admin URL (e.g., `http://localhost/admin/t/1/dashboard`).
|
||||||
|
- **Expected Result**: You should receive a **404 Not Found** error page.
|
||||||
|
|
||||||
|
2. **While logged into the Admin Panel** (`/admin`), attempt to navigate directly to a system panel URL (e.g., `http://localhost/system/dashboard`).
|
||||||
|
- **Expected Result**: You should receive a **404 Not Found** error page.
|
||||||
|
|
||||||
|
If all the above steps are successful, the local setup is complete and correct.
|
||||||
27
specs/064-auth-structure/research.md
Normal file
27
specs/064-auth-structure/research.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Research: Auth Structure v1.0
|
||||||
|
|
||||||
|
This document summarizes the technical decisions made during the planning phase for the `064-auth-structure` feature. The feature specification was highly detailed, so this research phase primarily serves to confirm and document the rationale behind the chosen implementation patterns.
|
||||||
|
|
||||||
|
### Decision 1: Use Two Separate Filament Panels
|
||||||
|
|
||||||
|
- **Decision**: Implement two distinct Filament Panel Providers: `AdminPanelProvider` for `/admin` and a new `SystemPanelProvider` for `/system`. Each panel will be configured with its own authentication guard.
|
||||||
|
- **Rationale**: This is the standard and recommended architecture in Filament v5 for applications requiring strict separation between different user audiences or identity stacks. It provides the strongest possible isolation at multiple layers:
|
||||||
|
- **Routing**: Each panel has its own route prefix.
|
||||||
|
- **Authentication**: Each panel is tied to a specific Laravel authentication guard (`web` vs. `platform`), ensuring that sessions are not shared or interchangeable.
|
||||||
|
- **Middleware**: Allows for panel-specific middleware stacks, which is essential for the "break-glass" banner and cross-scope access checks.
|
||||||
|
- **Alternatives Considered**:
|
||||||
|
- *Single Panel with Role-Based Visibility*: Using a single panel and hiding/showing navigation items and resources based on a user's role (e.g., `is_platform_user`). This was rejected because it does not provide true authentication separation, complicates authorization logic, and increases the risk of information leakage if visibility rules are not perfectly implemented. It would not satisfy the core "Two Panels, Two Identities" principle from the specification.
|
||||||
|
|
||||||
|
### Decision 2: Create a Dedicated `platform_users` Table and `PlatformUser` Model
|
||||||
|
|
||||||
|
- **Decision**: A new `platform_users` database table and a corresponding `App\Models\PlatformUser` Eloquent model will be created for the platform operators.
|
||||||
|
- **Rationale**: This directly addresses the "Kein users.is_platform_superadmin" principle. It physically separates platform operator identities from tenant identities, preventing the `users` table from becoming a mix of unrelated identity types. This simplifies the domain model and makes future development cleaner.
|
||||||
|
- **Alternatives Considered**:
|
||||||
|
- *Adding a `type` column to the `users` table*: This would involve keeping all users in one table and differentiating them with a column like `user_type` ('tenant' or 'platform'). This was rejected as it violates the spirit of the specification and leads to a "God" user table, where nullability and attribute relevance become confusing (e.g., a platform user wouldn't have a `tenant_id`).
|
||||||
|
|
||||||
|
### Decision 3: Implement "Break-Glass" as a Temporary Session State
|
||||||
|
|
||||||
|
- **Decision**: The "break-glass" mode will be implemented as a set of temporary keys stored in the user's session, not as a persistent database state.
|
||||||
|
- **Rationale**: This approach enhances security by ensuring that elevated privileges are ephemeral and automatically expire. Storing this state in the session avoids creating highly-privileged database records that could be forgotten or misused. It is also easier to clear on logout, timeout, or manual exit. All state transitions are still captured in the permanent `AuditLog`.
|
||||||
|
- **Alternatives Considered**:
|
||||||
|
- *A `is_in_break_glass_mode` column on `platform_users`*: This was rejected because it introduces persistent, high-risk state into the database. A server restart, crash, or logic error could leave an account in a privileged state indefinitely. Session-based state is inherently safer for temporary privilege escalation.
|
||||||
101
specs/064-auth-structure/spec.md
Normal file
101
specs/064-auth-structure/spec.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Feature Specification: Auth Structure v1.0: Panel & Identity Separation
|
||||||
|
|
||||||
|
**Feature Branch**: `064-auth-structure`
|
||||||
|
**Created**: Tuesday, January 27, 2026
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 064 — Auth Structure v1.0 Panel & Identity Separation..."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Tenant Admin Access (Priority: P1)
|
||||||
|
|
||||||
|
A Tenant Administrator needs to securely access their organization's resources. They will log in to the `/admin` panel using their existing company credentials via Microsoft Entra ID (OIDC). The system must ensure they only see their tenant's data and have no access to platform-level controls.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary user flow for the application's customers. It must be secure, seamless, and strictly isolated from the platform operations.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by attempting to log in with a valid Entra ID account, verifying successful access to `/admin/t/{tenant}`, and confirming that `/system` returns a 404 error. This delivers the core value of tenant administration.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is not authenticated, **When** they navigate to `/admin/login`, **Then** they are presented only with a "Sign in with Microsoft" option.
|
||||||
|
2. **Given** a valid Tenant Admin uses the Microsoft login, **When** they successfully authenticate, **Then** they are redirected to their tenant-specific dashboard within the `/admin` path.
|
||||||
|
3. **Given** an authenticated Tenant Admin, **When** they attempt to access any `/system/*` URL, **Then** they receive a 404 Not Found response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Platform Operator Access (Priority: P1)
|
||||||
|
|
||||||
|
A Platform Operator needs to manage the overall application and perform system-level tasks. They will log in to the `/system` panel using a locally stored username and password. Their access is separate from any tenant's identity system.
|
||||||
|
|
||||||
|
**Why this priority**: This flow is critical for system maintenance, operations, and emergency recovery. It guarantees that platform administrators can always access the system, even if a tenant's Entra ID is unavailable.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by logging in with a seeded `platform_user` account, verifying access to the `/system` dashboard, and confirming that tenant-scoped admin routes (`/admin/t/*`) return a 404 error.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is not authenticated, **When** they navigate to `/system/login`, **Then** they are presented with an email and password login form.
|
||||||
|
2. **Given** a valid Platform Operator provides correct credentials, **When** they log in, **Then** they are redirected to the `/system` dashboard.
|
||||||
|
3. **Given** an authenticated Platform Operator, **When** they attempt to access a tenant-scoped URL like `/admin/t/{tenant}/*`, **Then** they receive a 404 Not Found response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Break-glass Emergency Recovery (Priority: P2)
|
||||||
|
|
||||||
|
A Platform Operator with the appropriate permissions needs to perform an emergency recovery action on a tenant's behalf. They must enter a temporary, audited "break-glass" mode from within the `/system` panel to gain the necessary (but limited and logged) privileges.
|
||||||
|
|
||||||
|
**Why this priority**: Provides a crucial, secure mechanism for disaster recovery and support, while ensuring such powerful access is explicitly enabled, temporary, and fully audited. It's a safety net, not a primary workflow.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by enabling the feature flag, having a privileged Platform Operator start the break-glass session, verifying the persistent banner appears, performing a recovery action, and then exiting the mode. The audit log must reflect all state changes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the `BREAK_GLASS_ENABLED` flag is `true` and a Platform Operator has the `platform.use_break_glass` capability, **When** they initiate the "Enter break-glass mode" action, **Then** a persistent banner indicating "Recovery mode active" is displayed on all `/system` pages.
|
||||||
|
2. **Given** a Platform Operator is in break-glass mode, **When** the configured TTL expires, **Then** the break-glass session is automatically terminated, the banner is removed, and an "expired" event is logged.
|
||||||
|
3. **Given** a Platform Operator is in break-glass mode, **When** they click the "Exit break-glass" button, **Then** the session is immediately terminated, the banner is removed, and an "exit" event is logged.
|
||||||
|
4. **Given** the `BREAK_GLASS_ENABLED` flag is `false`, **Then** no option to enter break-glass mode is visible in the UI.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if a user's session expires while in break-glass mode? The break-glass state should be cleared along with the regular session.
|
||||||
|
- How does the system handle a login attempt for a `platform_user` whose account is marked as inactive? The login should fail with a generic "invalid credentials" message.
|
||||||
|
- What if a backfill migration runs for a `users.is_platform_superadmin` who already has a `platform_user` account? It should idempotently add the capabilities without creating a duplicate user.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces significant changes to authentication and authorization.
|
||||||
|
- **Graph Calls**: No new direct Graph calls are specified for this core auth structure, but the `/admin` panel relies on Entra OIDC (from feature 063).
|
||||||
|
- **Safety Gates**: The "break-glass" feature is a critical safety gate itself. It is gated by a feature flag (`BREAK_GLASS_ENABLED`), requires explicit user action with confirmation, is time-limited (TTL), and produces a detailed audit trail. Tenant recovery actions within this mode are also explicitly audited.
|
||||||
|
- **Tenant Isolation**: This is a primary goal. The spec enforces strict separation via different authentication scopes and routing rules that return 404 for cross-scope access attempts, preventing information leakage.
|
||||||
|
- **Run Observability**: While auth handshakes don't create `OperationRun`s (OPS-EX-AUTH-001), security-relevant actions MUST create detailed `AuditLog` entries. This includes platform login attempts (success and failure) and break-glass lifecycle events (enter, exit, expire), including actor, action, outcome, and timestamp.
|
||||||
|
- **Badge Alignment**: Not applicable.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST provide two separate admin experiences: `/admin` for Tenants and `/system` for Platform Operators.
|
||||||
|
- **FR-002**: The `/admin` experience MUST authenticate tenant admins via Microsoft Entra ID (OIDC).
|
||||||
|
- **FR-003**: The `/system` experience MUST authenticate platform operators via locally managed credentials stored separately from tenant identities.
|
||||||
|
- **FR-004**: The system MUST implement a "break-glass" mode for privileged platform users, which is disabled by default via a `BREAK_GLASS_ENABLED` environment variable.
|
||||||
|
- **FR-005**: When active, break-glass mode MUST display a persistent banner on all `/system` pages and expire automatically after a configurable TTL. All state transitions (start, exit, expiry) MUST be audited.
|
||||||
|
- **FR-006**: The `users.is_platform_superadmin` database column MUST be deprecated; no new code may use it for authorization checks.
|
||||||
|
- **FR-007**: An authenticated tenant session attempting to access any `/system/*` route MUST receive a 404 Not Found response.
|
||||||
|
- **FR-008**: An authenticated platform session attempting to access any tenant-scoped `/admin/t/{tenant}/*` route MUST receive a 404 Not Found response.
|
||||||
|
- **FR-009**: The system MUST provide a capability-based authorization system for platform users (e.g., `platform.access_system_panel`, `platform.use_break_glass`).
|
||||||
|
- **FR-010**: The login page at `/admin/login` MUST NOT contain password fields or links related to local auth or break-glass.
|
||||||
|
- **FR-011**: All platform login attempts (success and failure) and break-glass lifecycle events MUST be recorded in an audit log.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **PlatformUser**: Represents a platform operator with local credentials. Attributes include name, email, hashed password, and a list of capabilities. It is distinct from the tenant user identity.
|
||||||
|
- **Break-glass Session**: A temporary, elevated session state for a PlatformUser, not a database entity. It is managed via session state that tracks the expiry time, the initiating actor, and an operator-provided reason.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: 100% of authentication attempts to the `/admin` panel are routed through the Entra OIDC flow.
|
||||||
|
- **SC-002**: 100% of authentication attempts to the `/system` panel are routed through the local `platform_users` provider.
|
||||||
|
- **SC-003**: 0% of authenticated users from one panel can successfully access scoped resource pages in the other panel (verified by 404 responses).
|
||||||
|
- **SC-004**: When break-glass mode is activated, a persistent visual indicator is present on 100% of `/system` pages until the session is terminated (manually or by TTL).
|
||||||
|
- **SC-005**: All break-glass session activations, expirations, and manual exits are logged, with 100% of events recorded in the audit trail.
|
||||||
|
- **SC-006**: Code coverage for the new authentication guards, middleware, and platform authorization gates is at or above 80%.
|
||||||
|
- **SC-006**: Automated test coverage for the authentication separation and break-glass flows is at or above 80%.
|
||||||
80
specs/064-auth-structure/tasks.md
Normal file
80
specs/064-auth-structure/tasks.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# Tasks: Auth Structure v1.0
|
||||||
|
|
||||||
|
**Feature**: `064-auth-structure`
|
||||||
|
**Plan**: [plan.md](./plan.md)
|
||||||
|
**Specification**: [spec.md](./spec.md)
|
||||||
|
|
||||||
|
This document breaks down the implementation of the Auth Structure feature into actionable, dependency-ordered tasks.
|
||||||
|
|
||||||
|
## Phase 1: Foundational Setup (Prerequisites)
|
||||||
|
|
||||||
|
These tasks create the core data structure and must be completed before any auth logic is built.
|
||||||
|
|
||||||
|
- [X] T001 Create migration for `platform_users` table in `database/migrations/`
|
||||||
|
- [X] T002 Create `PlatformUser` model in `app/Models/PlatformUser.php`
|
||||||
|
- [X] T003 Create `PlatformUserFactory` in `database/factories/PlatformUserFactory.php`
|
||||||
|
|
||||||
|
## Phase 2: Core Authentication Configuration
|
||||||
|
|
||||||
|
These tasks configure Laravel's authentication system to recognize the new user type and guard.
|
||||||
|
|
||||||
|
- [X] T004 Define `platform` guard and `platform_users` provider in `config/auth.php`
|
||||||
|
- [X] T005 [P] Create `PlatformUserSeeder` to seed an initial operator account in `database/seeders/PlatformUserSeeder.php`
|
||||||
|
- [X] T006 [P] Update `DatabaseSeeder` to call the `PlatformUserSeeder` in `database/seeders/DatabaseSeeder.php`
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 - Platform Panel Implementation (P1)
|
||||||
|
|
||||||
|
**Goal**: A platform operator can log in to a new, separate `/system` panel.
|
||||||
|
**Independent Test**: A seeded user can authenticate at `/system/login` and access a system dashboard, while being denied access to `/admin/t/*` routes.
|
||||||
|
|
||||||
|
- [X] T007 [US2] Create `SystemPanelProvider` in `app/Providers/Filament/SystemPanelProvider.php` and configure it to use the `platform` auth guard (path `/system`).
|
||||||
|
- [X] T008 [US2] Register `SystemPanelProvider` in `bootstrap/providers.php`
|
||||||
|
- [X] T009 [US2] Create Filament v5 System login page at `app/Filament/System/Pages/Auth/Login.php` (local email+password) and wire it via `SystemPanelProvider->login(...)`.
|
||||||
|
- [X] T010 [US2] Create a basic dashboard page for the System Panel in `app/Filament/System/Pages/Dashboard.php`
|
||||||
|
- [X] T011 [US2] Create `SystemPanelAuthTest.php` to verify platform user login and logout in `tests/Feature/Auth/SystemPanelAuthTest.php`
|
||||||
|
- [X] T011b [US2] Implement audit logging for platform login attempts (success and failure) and add assertions to `SystemPanelAuthTest.php`.
|
||||||
|
- [X] T018b [US2] Enforce minimum capability `platform.access_system_panel` for any authenticated platform session to use `/system` (otherwise deny-as-not-found / 404). Depends on T017 + T018. Add a small test case to `SystemPanelAuthTest.php`.
|
||||||
|
|
||||||
|
## Phase 4: User Story 1 - Admin Panel Isolation & Cross-Scope Enforcement (P1)
|
||||||
|
|
||||||
|
**Goal**: A tenant admin can log in via Entra ID and cannot access the system panel.
|
||||||
|
**Independent Test**: An Entra-authenticated user can access `/admin/t/{tenant}` but receives a 404 upon visiting `/system`.
|
||||||
|
|
||||||
|
- [X] T012 [US1] Create `EnsureCorrectGuard` middleware to enforce 404s on cross-panel access in `app/Http/Middleware/EnsureCorrectGuard.php`
|
||||||
|
- [X] T013 [US1] Apply `EnsureCorrectGuard` to BOTH panels deterministically (preferred: panel middleware stack in each PanelProvider; acceptable: explicit route-group middleware in routes/web.php). Cross-scope MUST return 404.
|
||||||
|
- [X] T014 [US1] [P] Verify `/admin/login` is Entra-only: no password inputs rendered and no break-glass link/banner appears in the admin login surface (assert via feature test or DOM assertions).
|
||||||
|
- [X] T015 [US1] Create `CrossScopeAccessTest.php` to verify 404 responses for both tenant->system and platform->admin access attempts in `tests/Feature/Auth/CrossScopeAccessTest.php`
|
||||||
|
- [X] T016 [US1] Create `AdminPanelAuthTest.php` to confirm Entra-only login flow works as expected in `tests/Feature/Auth/AdminPanelAuthTest.php`
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Break-glass Mode (P2)
|
||||||
|
|
||||||
|
**Goal**: A privileged platform operator can enter a temporary, audited "break-glass" session.
|
||||||
|
**Independent Test**: An operator with the correct capability can start the session, see a banner, and have the session expire or exit, with all events being logged.
|
||||||
|
|
||||||
|
- [X] T017 [US3] Define platform capabilities (e.g., `platform.access_system_panel`, `platform.use_break_glass`) in `app/Support/Auth/PlatformCapabilities.php`
|
||||||
|
- [X] T018 [US3] Register gates for platform capabilities in `app/Providers/AuthServiceProvider.php` (do not introduce a new provider just for this).
|
||||||
|
- [X] T019 [US3] Implement the session logic for starting, checking, and clearing the break-glass state (including capturing an operator-provided reason).
|
||||||
|
- [X] T020 [US3] [P] Create the `break-glass-banner.blade.php` view component in `resources/views/filament/system/components/`
|
||||||
|
- [X] T021 [US3] Add the "Enter break-glass mode" `Action` to a page in the System Panel, visible only to users with the `platform.use_break_glass` capability, and include `->requiresConfirmation()`.
|
||||||
|
- [X] T022 [US3] Register a render hook in `SystemPanelProvider` to display the banner when in break-glass mode.
|
||||||
|
- [X] T023 [US3] Implement `AuditLog` entries for break-glass state changes (enter, exit, expire).
|
||||||
|
- [X] T024 [US3] Create `BreakGlassModeTest.php` to test the full lifecycle of the break-glass feature in `tests/Feature/Auth/BreakGlassModeTest.php`
|
||||||
|
|
||||||
|
## Phase 6: Deprecation & Polish
|
||||||
|
|
||||||
|
Final checks, cleanup, and architectural enforcement.
|
||||||
|
|
||||||
|
- [X] T025 [P] Create `IsPlatformSuperadminDeprecationTest.php` as an architecture test to fail if `users.is_platform_superadmin` is used in `tests/Deprecation/`
|
||||||
|
- [X] T026 Run formatting with `./vendor/bin/sail bin pint --dirty` and keep the working tree clean.
|
||||||
|
- [X] T027 [P] Add/verify `BREAK_GLASS_ENABLED` and `BREAK_GLASS_TTL_MINUTES` in `.env.example` (required). Optionally document in README.md.
|
||||||
|
|
||||||
|
## Dependencies & Implementation Strategy
|
||||||
|
|
||||||
|
- **MVP**: The MVP consists of completing **Phase 1, 2, 3, and 4**. This delivers the core value of two separate, isolated authentication panels.
|
||||||
|
- **Dependencies**:
|
||||||
|
- `Phase 3 (US2)` and `Phase 4 (US1)` can be developed in parallel after `Phase 2` is complete. However, the final cross-scope tests (`T015`) depend on both panels being functional.
|
||||||
|
- `Phase 5 (US3)` is strictly dependent on the completion of `Phase 3 (US2)`.
|
||||||
|
- `T018b` depends on `T017` + `T018` (capability definitions + gate registration).
|
||||||
|
- **Parallel Execution**: Within phases, tasks marked with `[P]` can be worked on concurrently. For example, after the migration is created (T001), the Model (T002) and Factory (T003) can be created in parallel.
|
||||||
|
|
||||||
|
This structure allows for incremental delivery. The core platform panel (US2) can be delivered first, followed by the admin panel isolation (US1) and finally the break-glass enhancement (US3).
|
||||||
58
tests/Deprecation/IsPlatformSuperadminDeprecationTest.php
Normal file
58
tests/Deprecation/IsPlatformSuperadminDeprecationTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
it('does not use legacy platform-superadmin flag in code or tests', function () {
|
||||||
|
$forbiddenPatterns = [
|
||||||
|
'/\bis_platform_superadmin\b/',
|
||||||
|
'/\bisPlatformSuperadmin\s*\(/',
|
||||||
|
];
|
||||||
|
|
||||||
|
$basePaths = [
|
||||||
|
app_path(),
|
||||||
|
resource_path('views'),
|
||||||
|
base_path('tests'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$allowedPathFragments = [
|
||||||
|
DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations'.DIRECTORY_SEPARATOR,
|
||||||
|
DIRECTORY_SEPARATOR.'specs'.DIRECTORY_SEPARATOR,
|
||||||
|
DIRECTORY_SEPARATOR.'docs'.DIRECTORY_SEPARATOR,
|
||||||
|
DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR,
|
||||||
|
DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR,
|
||||||
|
DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'cache'.DIRECTORY_SEPARATOR,
|
||||||
|
DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'Deprecation'.DIRECTORY_SEPARATOR,
|
||||||
|
];
|
||||||
|
|
||||||
|
$violations = [];
|
||||||
|
|
||||||
|
foreach ($basePaths as $basePath) {
|
||||||
|
foreach (File::allFiles($basePath) as $file) {
|
||||||
|
$path = $file->getRealPath();
|
||||||
|
|
||||||
|
if (! is_string($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($allowedPathFragments as $fragment) {
|
||||||
|
if (str_contains($path, $fragment)) {
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = File::get($path);
|
||||||
|
|
||||||
|
foreach ($forbiddenPatterns as $pattern) {
|
||||||
|
if (preg_match($pattern, $contents) === 1) {
|
||||||
|
$violations[] = $path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($violations)
|
||||||
|
->toBeEmpty('Remove usages of `users.is_platform_superadmin` / `isPlatformSuperadmin()` from runtime code and tests. Found in: '.implode(', ', $violations));
|
||||||
|
});
|
||||||
39
tests/Feature/Auth/AdminPanelAuthTest.php
Normal file
39
tests/Feature/Auth/AdminPanelAuthTest.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('renders an Entra-only admin login page', function () {
|
||||||
|
$response = $this->get('/admin/login');
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
|
||||||
|
$response->assertSee('Sign in with Microsoft');
|
||||||
|
$response->assertSee(route('auth.entra.redirect'), escape: false);
|
||||||
|
|
||||||
|
$response->assertDontSee('type="password"', escape: false);
|
||||||
|
$response->assertDontSee('Break-glass mode', escape: false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to the Entra authorize endpoint when configured', function () {
|
||||||
|
config()->set('services.microsoft.client_id', 'test-client-id');
|
||||||
|
config()->set('services.microsoft.client_secret', 'test-client-secret');
|
||||||
|
config()->set('services.microsoft.redirect', 'https://example.test/auth/entra/callback');
|
||||||
|
config()->set('services.microsoft.tenant', 'organizations');
|
||||||
|
|
||||||
|
$response = $this->get(route('auth.entra.redirect'));
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHas('entra_state');
|
||||||
|
|
||||||
|
$location = (string) $response->headers->get('Location');
|
||||||
|
|
||||||
|
expect($location)->toContain('https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize');
|
||||||
|
expect($location)->toContain('client_id=test-client-id');
|
||||||
|
expect($location)->toContain('redirect_uri='.urlencode('https://example.test/auth/entra/callback'));
|
||||||
|
expect($location)->toContain('response_type=code');
|
||||||
|
expect($location)->toContain('scope=openid+profile+email');
|
||||||
|
});
|
||||||
105
tests/Feature/Auth/BreakGlassModeTest.php
Normal file
105
tests/Feature/Auth/BreakGlassModeTest.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Dashboard;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
config()->set('tenantpilot.break_glass.enabled', true);
|
||||||
|
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the break-glass action when disabled', function () {
|
||||||
|
config()->set('tenantpilot.break_glass.enabled', false);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->assertActionHidden('enter_break_glass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can enter and exit break-glass mode and audits transitions', function () {
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->callAction('enter_break_glass', data: [
|
||||||
|
'reason' => 'Recovery test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/system')->assertSuccessful()->assertSee('Recovery mode active');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->callAction('exit_break_glass');
|
||||||
|
|
||||||
|
$this->get('/system')->assertSuccessful()->assertDontSee('Recovery mode active');
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||||
|
|
||||||
|
expect(AuditLog::query()->where('tenant_id', $tenant->getKey())->where('action', 'platform.break_glass.enter')->exists())->toBeTrue();
|
||||||
|
expect(AuditLog::query()->where('tenant_id', $tenant->getKey())->where('action', 'platform.break_glass.exit')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires break-glass mode after TTL and audits expiry', function () {
|
||||||
|
config()->set('tenantpilot.break_glass.ttl_minutes', 1);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [
|
||||||
|
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
|
PlatformCapabilities::USE_BREAK_GLASS,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform');
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-27 12:00:00'));
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->callAction('enter_break_glass', data: [
|
||||||
|
'reason' => 'TTL test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/system')->assertSuccessful()->assertSee('Recovery mode active');
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-27 12:02:00'));
|
||||||
|
|
||||||
|
$this->get('/system')->assertSuccessful()->assertDontSee('Recovery mode active');
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||||
|
|
||||||
|
expect(AuditLog::query()->where('tenant_id', $tenant->getKey())->where('action', 'platform.break_glass.expired')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
31
tests/Feature/Auth/CrossScopeAccessTest.php
Normal file
31
tests/Feature/Auth/CrossScopeAccessTest.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('allows guests to access both panel login pages', function () {
|
||||||
|
$this->get('/admin/login')->assertSuccessful();
|
||||||
|
$this->get('/system/login')->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when a platform session accesses the admin panel', function () {
|
||||||
|
$platformUser = PlatformUser::factory()->create();
|
||||||
|
$this->actingAs($platformUser, 'platform');
|
||||||
|
|
||||||
|
$this->get('/admin/login')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when a tenant session accesses the system panel', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$this->get('/system/login')->assertNotFound();
|
||||||
|
|
||||||
|
// Filament may switch the active guard within the test process,
|
||||||
|
// so ensure the tenant session is set for each request we assert.
|
||||||
|
$this->actingAs($user);
|
||||||
|
$this->get('/system')->assertNotFound();
|
||||||
|
});
|
||||||
160
tests/Feature/Auth/SystemPanelAuthTest.php
Normal file
160
tests/Feature/Auth/SystemPanelAuthTest.php
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\System\Pages\Auth\Login;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Filament::setCurrentPanel('system');
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the system login page', function () {
|
||||||
|
$this->get('/system/login')->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates a platform user and audits success', function () {
|
||||||
|
$platformTenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'email' => 'operator@tenantpilot.io',
|
||||||
|
'is_active' => true,
|
||||||
|
'last_login_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->set('data.email', $user->email)
|
||||||
|
->set('data.password', 'password')
|
||||||
|
->call('authenticate');
|
||||||
|
|
||||||
|
expect(auth('platform')->check())->toBeTrue();
|
||||||
|
expect(auth('platform')->id())->toBe($user->getKey());
|
||||||
|
expect($user->fresh()->last_login_at)->not->toBeNull();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('tenant_id', $platformTenant->getKey())
|
||||||
|
->where('action', 'platform.auth.login')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull();
|
||||||
|
expect($audit->status)->toBe('success');
|
||||||
|
expect($audit->actor_id)->toBe($user->getKey());
|
||||||
|
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid credentials and audits failure', function () {
|
||||||
|
$platformTenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'email' => 'operator@tenantpilot.io',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->set('data.email', $user->email)
|
||||||
|
->set('data.password', 'wrong-password')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertHasErrors(['data.email']);
|
||||||
|
|
||||||
|
expect(auth('platform')->check())->toBeFalse();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('tenant_id', $platformTenant->getKey())
|
||||||
|
->where('action', 'platform.auth.login')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull();
|
||||||
|
expect($audit->status)->toBe('failure');
|
||||||
|
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
|
||||||
|
expect($audit->metadata['reason'] ?? null)->toBe('invalid_credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects inactive platform users and audits failure', function () {
|
||||||
|
$platformTenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'email' => 'operator@tenantpilot.io',
|
||||||
|
'is_active' => false,
|
||||||
|
'last_login_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->set('data.email', $user->email)
|
||||||
|
->set('data.password', 'password')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertHasErrors(['data.email']);
|
||||||
|
|
||||||
|
expect(auth('platform')->check())->toBeFalse();
|
||||||
|
expect($user->fresh()->last_login_at)->toBeNull();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('tenant_id', $platformTenant->getKey())
|
||||||
|
->where('action', 'platform.auth.login')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull();
|
||||||
|
expect($audit->status)->toBe('failure');
|
||||||
|
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
|
||||||
|
expect($audit->metadata['reason'] ?? null)->toBe('inactive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies system panel access (404) for platform users without the required capability', function () {
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->set('data.email', $user->email)
|
||||||
|
->set('data.password', 'password')
|
||||||
|
->call('authenticate');
|
||||||
|
|
||||||
|
expect(auth('platform')->check())->toBeTrue();
|
||||||
|
|
||||||
|
$this->get('/system')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows system panel access for platform users with the required capability', function () {
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'tenant_id' => null,
|
||||||
|
'external_id' => 'platform',
|
||||||
|
'name' => 'Platform',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = PlatformUser::factory()->create([
|
||||||
|
'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'platform');
|
||||||
|
|
||||||
|
$this->get('/system')->assertSuccessful();
|
||||||
|
});
|
||||||
@ -9,7 +9,7 @@
|
|||||||
it('allows access to monitoring page for tenant members', function () {
|
it('allows access to monitoring page for tenant members', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$tenant->users()->attach($user);
|
$tenant->users()->attach($user, ['role' => 'owner']);
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -29,7 +29,7 @@
|
|||||||
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$tenant->users()->attach($user);
|
$tenant->users()->attach($user, ['role' => 'owner']);
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -62,7 +62,7 @@
|
|||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$tenantA->users()->attach($user);
|
$tenantA->users()->attach($user, ['role' => 'owner']);
|
||||||
|
|
||||||
// We must simulate being in tenant context
|
// We must simulate being in tenant context
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
it('sanitizes persisted run failures and terminal notifications', function () {
|
it('sanitizes persisted run failures and terminal notifications', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$tenant->users()->attach($user);
|
$tenant->users()->attach($user, ['role' => 'owner']);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|||||||
@ -1,39 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\BreakGlassRecovery;
|
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('allows platform superadmin to assign an owner via break-glass recovery and audits it', function () {
|
it('does not allow legacy platform-superadmin break-glass recovery flow', function () {
|
||||||
$superadmin = User::factory()->create(['is_platform_superadmin' => true]);
|
$user = User::factory()->create();
|
||||||
$this->actingAs($superadmin);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$targetUser = User::factory()->create();
|
|
||||||
|
|
||||||
Livewire::test(BreakGlassRecovery::class)
|
$this->get('/admin/break-glass-recovery')->assertNotFound();
|
||||||
->callAction('bootstrap_recover', data: [
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'user_id' => $targetUser->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_memberships', [
|
expect(AuditLog::query()->where('tenant_id', $tenant->getKey())->where('action', 'tenant_membership.bootstrap_recover')->exists())
|
||||||
'tenant_id' => $tenant->getKey(),
|
->toBeFalse();
|
||||||
'user_id' => $targetUser->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
'source' => 'break_glass',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('action', 'tenant_membership.bootstrap_recover')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns all active tenants for platform superadmins', function () {
|
it('returns all active tenants for platform superadmins', function () {
|
||||||
$user = User::factory()->create(['is_platform_superadmin' => true]);
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$a = Tenant::factory()->create(['name' => 'A']);
|
$a = Tenant::factory()->create(['name' => 'A']);
|
||||||
$b = Tenant::factory()->create(['name' => 'B']);
|
$b = Tenant::factory()->create(['name' => 'B']);
|
||||||
@ -40,7 +40,5 @@
|
|||||||
|
|
||||||
$tenants = $user->getTenants($panel);
|
$tenants = $user->getTenants($panel);
|
||||||
|
|
||||||
expect($tenants->pluck('id')->all())
|
expect($tenants)->toHaveCount(0);
|
||||||
->toContain($a->getKey())
|
|
||||||
->toContain($b->getKey());
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,6 +25,9 @@
|
|||||||
pest()->extend(Tests\TestCase::class)
|
pest()->extend(Tests\TestCase::class)
|
||||||
->in('Unit');
|
->in('Unit');
|
||||||
|
|
||||||
|
pest()->extend(Tests\TestCase::class)
|
||||||
|
->in('Deprecation');
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
putenv('INTUNE_TENANT_ID');
|
putenv('INTUNE_TENANT_ID');
|
||||||
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user