Compare commits

..

8 Commits

Author SHA1 Message Date
439248ba15 feat: verification report framework (074) (#89)
Implements the 074 verification checklist framework.

Highlights:
- Versioned verification report contract stored in operation_runs.context.verification_report (DB-only viewer).
- Strict sanitizer/redaction (evidence pointers only; no tokens/headers/payloads) + schema validation.
- Centralized BADGE-001 semantics for check status, severity, and overall report outcome.
- Deterministic start (dedupe while active) via shared StartVerification service; capability-first authorization (non-member 404, member missing capability 403).
- Completion audit event (verification.completed) with redacted metadata.
- Integrations: OperationRun detail viewer, onboarding wizard verification step, provider connection start surfaces.

Tests:
- vendor/bin/sail artisan test --compact tests/Feature/Verification tests/Unit/Badges/VerificationBadgesTest.php
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #89
2026-02-03 23:58:17 +00:00
b6343d5c3a feat: unified managed tenant onboarding wizard (#88)
Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073).

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #88
2026-02-03 17:30:15 +00:00
5f9e6fb04a feat: workspace-first managed tenants + RBAC membership UI fixes (072) (#87)
Implements spec 072 (workspace-first managed tenants enforcement) and follow-up RBAC fixes.

Highlights:
- Workspace-scoped managed tenants landing and enforcement for tenant routes.
- Workspace membership management UI fixed to use workspace capabilities.
- Membership tables now show user email + domain for clearer identification.

Tests:
- Targeted Pest tests for routing/enforcement and RBAC UI enforcement.
- Pint ran on dirty files.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #87
2026-02-02 23:54:22 +00:00
38d9826f5e feat: workspace context enforcement + ownership safeguards (#86)
Implements workspace-first enforcement and UX:
- Workspace selected before tenant flows; /admin routes into choose-workspace/choose-tenant
- Tenant lists and default tenant selection are scoped to current workspace
- Workspaces UI is tenantless at /admin/workspaces

Security hardening:
- Workspaces can never have 0 owners (blocks last-owner removal/demotion)
- Blocked attempts are audited with action_id=workspace_membership.last_owner_blocked + required metadata
- Optional break-glass recovery page to re-assign workspace owner (audited)

Tests:
- Added/updated Pest feature tests covering redirects, scoping, tenantless workspaces, last-owner guards, and break-glass recovery.

Notes:
- Filament v5 strict Page property signatures respected in RepairWorkspaceOwners.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #86
2026-02-02 23:00:56 +00:00
a989ef1a23 feat: workspace context enforcement (specs 070–072) (#85)
Implements specs 070–072 (workspace foundation, workspace-scoped tenant selection, managed-tenants workspace enforcement).

Highlights
- Adds Workspace + WorkspaceMembership models/migrations + middleware to persist/enforce current workspace context.
- Scopes tenant selection to the current workspace.
- Makes legacy `/admin/managed-tenants*` routes redirect into workspace-scoped URLs.
- Enforces tenant routes under `/admin/t/{tenant}` to 404 when workspace context is missing or mismatched.
- Fixes Filament page Blade wrappers so header actions render on choose-workspace / choose-tenant / no-access pages.

Verification
- Pint: `vendor/bin/sail bin pint --dirty`
- Tests: `vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php tests/Feature/Workspaces tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php tests/Feature/ManagedTenants tests/Feature/AdminNewRedirectTest.php`

Notes
- Filament v5 / Livewire v4 compatible.
- Panel provider registration stays in `bootstrap/providers.php` (Laravel 11+ rule).
- No new heavy frontend assets added.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #85
2026-02-02 10:07:41 +00:00
3490fb9e2c feat: RBAC troubleshooting & tenant UI bugfix pack (spec 067) (#84)
Summary
Implements Spec 067 “RBAC Troubleshooting & Tenant UI Bugfix Pack v1” for the tenant admin plane (/admin) with strict RBAC UX semantics:

Non-member tenant scope ⇒ 404 (deny-as-not-found)
Member lacking capability ⇒ 403 server-side, while the UI stays visible-but-disabled with standardized tooltips
What changed
Tenant view header actions now use centralized UI enforcement (no “normal click → error page” for readonly members).
Archived tenants remain resolvable in tenant-scoped routes for entitled members; an “Archived” banner is shown.
Adds tenant-scoped diagnostics page (/admin/t/{tenant}/diagnostics) with safe repair actions (confirmation + authorization + audit log).
Adds/updates targeted Pest tests to lock the 404 vs 403 semantics and action UX.
Implementation notes
Livewire v4.0+ compliance: Uses Filament v5 + Livewire v4 conventions; widget Blade views render a single root element.
Provider registration: Laravel 11+ providers stay in providers.php (no changes required).
Global search: No global search behavior/resources changed in this PR.
Destructive actions:
Tenant archive/restore/force delete and diagnostics repairs execute via ->action(...) and include ->requiresConfirmation().
Server-side authorization is enforced (non-members 404, insufficient capability 403).
Assets: No new assets. No change to php artisan filament:assets expectations.
Tests
Ran:

vendor/bin/sail bin pint --dirty
vendor/bin/sail artisan test --compact (focused files for Spec 067)

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #84
2026-01-31 20:09:25 +00:00
d1a9989037 feat/066-rbac-ui-enforcement-helper-v2 (#83)
Implementiert Feature 066: “RBAC UI Enforcement Helper v2” inkl. Migration der betroffenen Filament-Surfaces + Regression-Tests.

Was ist drin

Neuer Helper:
UiEnforcement.php: mixed visibility (preserveVisibility, andVisibleWhen, andHiddenWhen), tenant resolver (tenantFromFilament, tenantFromRecord, tenantFrom(callable)), bulk preflight (preflightByCapability, preflightByTenantMembership, preflightSelection) + server-side authorizeOrAbort() / authorizeBulkSelectionOrAbort().
UiTooltips.php: standard Tooltip “Insufficient permission — ask a tenant Owner.”
Filament migrations (weg von Gate::… / abort_* hin zu UiEnforcement):
Backup/Restore (mixed visibility)
TenantResource (record-scoped tenant actions + bulk preflight)
Inventory/Entra/ProviderConnections (Tier-2 surfaces)
Guardrails:
NoAdHocFilamentAuthPatternsTest.php als CI-failing allowlist guard für app/Filament/**.
Verhalten / Contract

Non-member: deny-as-not-found (404) auf tenant routes; Actions hidden.
Member ohne Capability: Action visible but disabled + standard tooltip; keine Ausführung.
Member mit Capability: Action enabled; destructive/high-impact Actions bleiben confirmation-gated (->requiresConfirmation()).
Server-side Enforcement bleibt vorhanden: Mutations/Operations rufen authorizeOrAbort() / authorizeBulkSelectionOrAbort().
Tests

Neue/erweiterte Feature-Tests für RBAC UX inkl. Http::preventStrayRequests() (DB-only render):
BackupSetUiEnforcementTest.php
RestoreRunUiEnforcementTest.php
ProviderConnectionsUiEnforcementTest.php
diverse bestehende Filament Tests erweitert (Inventory/Entra/Tenant actions/bulk)
Unit-Tests:
UiEnforcementTest.php
UiEnforcementBulkPreflightQueryCountTest.php
Verification

vendor/bin/sail bin pint --dirty 
vendor/bin/sail artisan test --compact tests/Unit/Auth tests/Feature/Filament tests/Feature/Guards tests/Feature/Rbac  (185 passed, 5 skipped)
Notes für Reviewer

Filament v5 / Livewire v4 compliant.
Destructive actions: weiterhin ->requiresConfirmation() + server-side auth.
Bulk: authorization preflight ist set-based (Query-count test vorhanden).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #83
2026-01-30 17:28:47 +00:00
7217559e5a spec/066-rbac-ui-enforcement-helper-v2 (#82)
Ziel: Spec/Plan/Tasks für “RBAC UI Enforcement Helper v2” (suite-wide, mixed visibility, record-scoped tenant) bereitstellen, damit die anschließende Implementierung sauber reviewbar ist.

Enthält

Feature-Spec inkl. RBAC-UX Contract (Non-member 404/hidden, member-no-cap disabled + Tooltip, member-with-cap enabled).
Implementation Plan + Research/Decisions.
Contracts:
UiEnforcement v2 (mixed visibility composition, tenant resolvers, bulk preflight).
Guardrails (CI-failing allowlist guard gegen ad-hoc Filament auth patterns).
Data-model/Quickstart/Tasks inkl. “Definition of Done”.
Review-Fokus

Scope: Tenant plane only (/admin/t/{tenant}), Platform plane out of scope.
Bulk semantics: authorization-only all-or-nothing; eligibility separat mit Feedback.
preserveVisibility() nur tenant-scoped, verboten für record-scoped/cross-tenant.
Standard tooltip copy: “Insufficient permission — ask a tenant Owner.”
Keine Code-Änderungen

PR ist spec-only (keine Runtime-Änderungen).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #82
2026-01-30 17:22:25 +00:00
231 changed files with 15118 additions and 1037 deletions

View File

@ -12,6 +12,10 @@ ## Active Technologies
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -31,9 +35,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
<!-- MANUAL ADDITIONS START -->

View File

@ -1056,9 +1056,9 @@ ### Replaced Utilities
</laravel-boost-guidelines>
## Active Technologies
- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416)
- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416)
- PHP 8.4.15 (Laravel 12) + Filament v5 + Livewire v4 (059-unified-badges)
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4
- PostgreSQL (Sail)
- Tailwind CSS v4
## Recent Changes
- 054-unify-runs-suitewide-session-1768601416: Added PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3
- 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts)

View File

@ -34,6 +34,6 @@ private function resolveTenant(): Tenant
->firstOrFail();
}
return Tenant::current();
return Tenant::currentOrFail();
}
}

View File

@ -138,7 +138,7 @@ private function resolveTenants()
}
try {
return collect([Tenant::current()]);
return collect([Tenant::currentOrFail()]);
} catch (RuntimeException) {
return collect();
}

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ChooseWorkspace extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-workspace';
protected static ?string $title = 'Choose workspace';
protected string $view = 'filament.pages.choose-workspace';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
}
/**
* @return Collection<int, Workspace>
*/
public function getWorkspaces(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Workspace::query()->whereRaw('1 = 0')->get();
}
return Workspace::query()
->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->orderBy('name')
->get();
}
public function selectWorkspace(int $workspaceId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect($this->redirectAfterWorkspaceSelected($user));
}
private function redirectAfterWorkspaceSelected(User $user): string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return self::getUrl();
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return self::getUrl();
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(tenant: $tenant);
}
}
return ChooseTenant::getUrl();
}
}

View File

@ -4,6 +4,13 @@
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class NoAccess extends Page
@ -19,4 +26,60 @@ class NoAccess extends Page
protected static ?string $title = 'No access';
protected string $view = 'filament.pages.no-access';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
}
/**
* @param array{name: string, slug?: string|null} $data
*/
public function createWorkspace(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = Workspace::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
]);
WorkspaceMembership::query()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request());
Notification::make()
->title('Workspace created')
->success()
->send();
$this->redirect(ChooseTenant::getUrl());
}
}

View File

@ -4,9 +4,11 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema;
@ -27,6 +29,20 @@ public static function canView(): bool
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$canRegisterInWorkspace = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
if ($canRegisterInWorkspace) {
return true;
}
}
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
if ($tenantIds->isEmpty()) {
@ -95,6 +111,12 @@ protected function handleRegistration(array $data): Model
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
$tenant = Tenant::create($data);
$user = auth()->user();

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\TenantDiagnosticsService;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Pages\Page;
class TenantDiagnostics extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'diagnostics';
protected string $view = 'filament.pages.tenant-diagnostics';
public bool $missingOwner = false;
public bool $hasDuplicateMembershipsForCurrentUser = false;
public function mount(): void
{
$tenant = Tenant::current();
$tenantId = (int) $tenant->getKey();
$this->missingOwner = ! TenantMembership::query()
->where('tenant_id', $tenantId)
->where('role', 'owner')
->exists();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
->userHasDuplicateMemberships($tenant, $user);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('bootstrapOwner')
->label('Bootstrap owner')
->requiresConfirmation()
->action(fn () => $this->bootstrapOwner()),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->destructive()
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->missingOwner),
UiEnforcement::forAction(
Action::make('mergeDuplicateMemberships')
->label('Merge duplicate memberships')
->requiresConfirmation()
->action(fn () => $this->mergeDuplicateMemberships()),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->destructive()
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->hasDuplicateMembershipsForCurrentUser),
];
}
public function bootstrapOwner(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
$this->mount();
}
public function mergeDuplicateMemberships(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
$this->mount();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ManagedTenantsLanding extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace;
public function mount(Workspace $workspace): void
{
$this->workspace = $workspace;
}
/**
* @return Collection<int, Tenant>
*/
public function getTenants(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Tenant::query()->whereRaw('1 = 0')->get();
}
return $user->tenants()
->where('workspace_id', $this->workspace->getKey())
->where('status', 'active')
->orderBy('name')
->get();
}
public function goToChooseTenant(): void
{
$this->redirect(ChooseTenant::getUrl());
}
public function openTenant(int $tenantId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $this->workspace->getKey())
->whereKey($tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
}

View File

@ -938,7 +938,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array
public static function assignTenant(array $data): array
{
$data['tenant_id'] = Tenant::current()->getKey();
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
return $data;
}

View File

@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -13,9 +15,7 @@ class ListBackupSets extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->disabled(fn (): bool => ! BackupSetResource::canCreate())
->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'),
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
@ -136,12 +137,35 @@ public static function infolist(Schema $schema): Schema
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(),
Section::make('Verification report')
->schema([
ViewEntry::make('verification_report')
->label('')
->view('filament.components.verification-report-viewer')
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
->columnSpanFull(),
Section::make('Context')
->schema([
ViewEntry::make('context')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->context ?? [])
->state(function (OperationRun $record): array {
$context = $record->context ?? [];
$context = is_array($context) ? $context : [];
if (array_key_exists('verification_report', $context)) {
$context['verification_report'] = [
'redacted' => true,
'note' => 'Rendered in the Verification report section.',
];
}
return $context;
})
->columnSpanFull(),
])
->columnSpanFull(),

View File

@ -894,7 +894,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -815,7 +815,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -5,7 +5,6 @@
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -15,6 +14,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -175,29 +175,22 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
if (! $tenant instanceof Tenant) {
abort(404);
}
$initiator = $user;
if (! $user instanceof User) {
abort(403);
}
$result = $gate->start(
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
initiator: $user,
);
if ($result->status === 'scope_busy') {

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\ProviderConnectionResource;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -14,6 +13,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
@ -163,11 +163,11 @@ protected function getHeaderActions(): array
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $user instanceof User
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$user = auth()->user();
@ -185,18 +185,9 @@ protected function getHeaderActions(): array
$initiator = $user;
$result = $gate->start(
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
@ -242,9 +233,8 @@ protected function getHeaderActions(): array
->send();
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(

View File

@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
@ -219,7 +219,7 @@ public static function getWizardSteps(): array
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::currentOrFail()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -13,9 +15,7 @@ class ListRestoreRuns extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->disabled(fn (): bool => ! RestoreRunResource::canCreate())
->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'),
UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()),
];
}
}

View File

@ -9,6 +9,7 @@
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
@ -21,6 +22,7 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
@ -28,6 +30,8 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -68,7 +72,21 @@ public static function canCreate(): bool
return false;
}
return static::userCanManageAnyTenant($user);
if (static::userCanManageAnyTenant($user)) {
return true;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
}
public static function canEdit(Model $record): bool
@ -177,8 +195,15 @@ public static function getEloquentQuery(): Builder
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->pluck('tenants.id');
return parent::getEloquentQuery()
@ -262,95 +287,57 @@ public static function table(Table $table): Table
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (Tenant $record): bool {
if (! $record->isActive()) {
return false;
}
UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (Tenant $record): bool {
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (! $user instanceof User) {
return false;
}
return $user->canAccessTenant($record);
})
->disabled(function (Tenant $record): bool {
$user = auth()->user();
return $user->canAccessTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $user->canAccessTenant($record)) {
abort(404);
}
return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC);
})
->tooltip(function (Tenant $record): ?string {
$user = auth()->user();
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $user instanceof User) {
return null;
}
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
return $resolver->can($user, $record, Capabilities::TENANT_SYNC)
? null
: 'You do not have permission to sync this tenant.';
})
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$supportedTypes = config('tenantpilot.supported_policy_types', []);
$typeNames = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
$supportedTypes,
);
sort($typeNames);
$inputs = [
'scope' => 'full',
'types' => $typeNames,
];
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: auth()->user()
);
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
$opService->failStaleQueuedRun(
$opRun,
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
$supportedTypes = config('tenantpilot.supported_policy_types', []);
$typeNames = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
$supportedTypes,
);
sort($typeNames);
$inputs = [
'scope' => 'full',
'types' => $typeNames,
];
$opRun = $opService->ensureRun(
tenant: $record,
@ -358,295 +345,262 @@ public static function table(Table $table): Table
inputs: $inputs,
initiator: auth()->user()
);
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
$opService->failStaleQueuedRun(
$opRun,
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
);
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: auth()->user()
);
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
});
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
});
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
}),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_SYNC)
->apply(),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'),
Actions\Action::make('restore')
->label('Restore')
->color('success')
->successNotificationTitle('Tenant reactivated')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->trashed())
->disabled(function (Tenant $record): bool {
$user = auth()->user();
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->successNotificationTitle('Tenant reactivated')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
})
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
if (! $user instanceof User) {
abort(403);
}
$record->restore();
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->restore();
$auditLogger->log(
tenant: $record,
action: 'tenant.restored',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
}),
Actions\Action::make('admin_consent')
->label('Admin consent')
->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
->disabled(function (Tenant $record): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
})
->tooltip(function (Tenant $record): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $record, Capabilities::TENANT_MANAGE)
? null
: 'You do not have permission to manage tenant consent.';
})
->openUrlInNewTab(),
$auditLogger->log(
tenant: $record,
action: 'tenant.restored',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('admin_consent')
->label('Admin consent')
->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
->openUrlInNewTab(),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => static::entraUrl($record))
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
->openUrlInNewTab(),
Actions\Action::make('verify')
->label('Verify configuration')
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->disabled(function (Tenant $record): bool {
$user = auth()->user();
UiEnforcement::forAction(
Actions\Action::make('verify')
->label('Verify configuration')
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (
Tenant $record,
TenantConfigService $configService,
TenantPermissionService $permissionService,
RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
): void {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
})
->action(function (
Tenant $record,
TenantConfigService $configService,
TenantPermissionService $permissionService,
RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
) {
$user = auth()->user();
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
abort(403);
}
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
abort(403);
}
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
static::rbacAction(),
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->disabled(function (Tenant $record): bool {
$user = auth()->user();
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
})
->action(function (Tenant $record, AuditLogger $auditLogger) {
$user = auth()->user();
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
if (! $user instanceof User) {
abort(403);
}
$record->delete();
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
->disabled(function (?Tenant $record): bool {
if (! $record instanceof Tenant) {
return true;
}
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
})
->action(function (?Tenant $record, AuditLogger $auditLogger) {
if ($record === null) {
return;
}
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$tenant = Tenant::withTrashed()->find($record->id);
if (! $tenant?->trashed()) {
Notification::make()
->title('Tenant must be archived first')
->danger()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
->action(function (?Tenant $record, AuditLogger $auditLogger): void {
if ($record === null) {
return;
}
return;
}
$user = auth()->user();
$auditLogger->log(
tenant: $tenant,
action: 'tenant.force_deleted',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
);
if (! $user instanceof User) {
abort(403);
}
$tenant->forceDelete();
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
Notification::make()
->title('Tenant permanently deleted')
->success()
->send();
}),
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$tenant = Tenant::withTrashed()->find($record->id);
if (! $tenant?->trashed()) {
Notification::make()
->title('Tenant must be archived first')
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'tenant.force_deleted',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
);
$tenant->forceDelete();
Notification::make()
->title('Tenant permanently deleted')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
])
->bulkActions([
@ -655,27 +609,45 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (): bool {
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->disabled(function (Collection $records): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
return true;
}
return $user->tenants()
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
->exists();
if ($records->isEmpty()) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $records
->filter(fn ($record) => $record instanceof Tenant)
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
})
->authorize(function (): bool {
->tooltip(function (Collection $records): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return false;
return UiTooltips::insufficientPermission();
}
return $user->tenants()
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
->exists();
if ($records->isEmpty()) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$isDenied = $records
->filter(fn ($record) => $record instanceof Tenant)
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
return $isDenied ? UiTooltips::insufficientPermission() : null;
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
@ -982,9 +954,7 @@ public static function rbacAction(): Actions\Action
return;
}
$actor = auth()->user();
$result = $service->run($record, $data, $actor, $token);
$result = $service->run($record, $data, $user, $token);
Cache::forget($cacheKey);

View File

@ -4,12 +4,28 @@
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord
{
protected static string $resource = TenantResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
return $data;
}
protected function afterCreate(): void
{
$user = auth()->user();

View File

@ -3,11 +3,14 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
@ -16,11 +19,25 @@ class ViewTenant extends ViewRecord
{
protected static string $resource = TenantResource::class;
protected function getHeaderWidgets(): array
{
return [
TenantArchivedBanner::class,
];
}
protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
Actions\EditAction::make(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('admin_consent')
->label('Admin consent')
->icon('heroicon-o-clipboard-document')
@ -48,30 +65,40 @@ protected function getHeaderActions(): array
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
TenantResource::rbacAction(),
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record) => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger) {
$record->delete();
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'internal_tenant_id' => (int) $record->getKey(),
'tenant_guid' => (string) $record->tenant_id,
],
]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')

View File

@ -25,11 +25,22 @@ public function table(Table $table): Table
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.name')
Tables\Columns\TextColumn::make('user.email')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user.email')
->label(__('Email'))
Tables\Columns\TextColumn::make('user_domain')
->label(__('Domain'))
->getStateUsing(function (TenantMembership $record): ?string {
$email = $record->user?->email;
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
return null;
}
return (string) str($email)->after('@')->lower();
}),
Tables\Columns\TextColumn::make('user.name')
->label(__('Name'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
@ -49,7 +60,13 @@ public function table(Table $table): Table
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
->options(fn () => User::query()
->orderBy('email')
->get(['id', 'name', 'email'])
->mapWithKeys(fn (User $user): array => [
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
])
->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()

View File

@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateWorkspace extends CreateRecord
{
protected static string $resource = WorkspaceResource::class;
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
WorkspaceMembership::query()->firstOrCreate(
[
'workspace_id' => $this->record->getKey(),
'user_id' => $user->getKey(),
],
[
'role' => 'owner',
],
);
app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request());
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Resources\Pages\EditRecord;
class EditWorkspace extends EditRecord
{
protected static string $resource = WorkspaceResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWorkspaces extends ListRecords
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewWorkspace extends ViewRecord
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Filament\Resources\Workspaces\RelationManagers;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceMembershipManager;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use App\Support\Rbac\WorkspaceUiEnforcement;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WorkspaceMembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.email')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user_domain')
->label(__('Domain'))
->getStateUsing(function (WorkspaceMembership $record): ?string {
$email = $record->user?->email;
if (! is_string($email) || $email === '' || ! str_contains($email, '@')) {
return null;
}
return (string) str($email)->after('@')->lower();
}),
Tables\Columns\TextColumn::make('user.name')
->label(__('Name'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
WorkspaceUiEnforcement::forTableAction(
Action::make('add_member')
->label(__('Add member'))
->icon('heroicon-o-plus')
->form([
Forms\Components\Select::make('user_id')
->label(__('User'))
->required()
->searchable()
->options(fn () => User::query()
->orderBy('email')
->get(['id', 'name', 'email'])
->mapWithKeys(fn (User $user): array => [
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
])
->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
WorkspaceRole::Owner->value => __('Owner'),
WorkspaceRole::Manager->value => __('Manager'),
WorkspaceRole::Operator->value => __('Operator'),
WorkspaceRole::Readonly->value => __('Readonly'),
]),
])
->action(function (array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title(__('User not found'))->danger()->send();
return;
}
try {
$manager->addMember(
workspace: $workspace,
actor: $actor,
member: $member,
role: (string) $data['role'],
source: 'manual',
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to add member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->apply(),
])
->actions([
WorkspaceUiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
WorkspaceRole::Owner->value => __('Owner'),
WorkspaceRole::Manager->value => __('Manager'),
WorkspaceRole::Operator->value => __('Operator'),
WorkspaceRole::Readonly->value => __('Readonly'),
]),
])
->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->changeRole(
workspace: $workspace,
actor: $actor,
membership: $record,
newRole: (string) $data['role'],
);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to change role'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->apply(),
WorkspaceUiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
$workspace = $this->getOwnerRecord();
if (! $workspace instanceof Workspace) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
try {
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
} catch (\Throwable $throwable) {
Notification::make()
->title(__('Failed to remove member'))
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable();
}),
fn () => $this->getOwnerRecord(),
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->tooltip('You do not have permission to manage workspace memberships.')
->destructive()
->apply(),
])
->bulkActions([]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
use App\Models\Workspace;
use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class WorkspaceResource extends Resource
{
protected static ?string $model = Workspace::class;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->searchable()
->sortable(),
])
->actions([
Actions\ViewAction::make(),
Actions\EditAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListWorkspaces::route('/'),
'create' => Pages\CreateWorkspace::route('/create'),
'view' => Pages\ViewWorkspace::route('/{record}'),
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
WorkspaceMembershipsRelationManager::class,
];
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
final class VerificationReportViewer
{
/**
* @return array<string, mixed>|null
*/
public static function report(OperationRun $run): ?array
{
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
if (! is_array($report)) {
return null;
}
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
return null;
}
return $report;
}
public static function shouldRenderForRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];
if (array_key_exists('verification_report', $context)) {
return true;
}
return in_array((string) $run->type, ['provider.connection.check'], true);
}
}

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages;
use App\Models\PlatformUser;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Auth\WorkspaceRole;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class RepairWorkspaceOwners extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Repair workspace owners';
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
protected string $view = 'filament.system.pages.repair-workspace-owners';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$breakGlass = app(BreakGlassSession::class);
return [
Action::make('assign_owner')
->label('Assign owner (break-glass)')
->color('danger')
->requiresConfirmation()
->modalHeading('Assign workspace owner')
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
->form([
Select::make('workspace_id')
->label('Workspace')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Select::make('target_user_id')
->label('User')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return User::query()
->where('email', 'like', "%{$search}%")
->orderBy('email')
->limit(25)
->pluck('email', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return User::query()->whereKey((int) $value)->value('email');
}),
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
$platformUser = auth('platform')->user();
if (! $platformUser instanceof PlatformUser) {
abort(403);
}
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
abort(403);
}
if (! $breakGlass->isActive()) {
abort(403);
}
$workspaceId = (int) ($data['workspace_id'] ?? 0);
$targetUserId = (int) ($data['target_user_id'] ?? 0);
$reason = (string) ($data['reason'] ?? '');
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
$membership = WorkspaceMembership::query()->firstOrNew([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $targetUser->getKey(),
]);
$fromRole = $membership->exists ? (string) $membership->role : null;
$membership->forceFill([
'role' => WorkspaceRole::Owner->value,
])->save();
$auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
context: [
'metadata' => [
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $platformUser->getKey(),
'target_user_id' => (int) $targetUser->getKey(),
'attempted_role' => WorkspaceRole::Owner->value,
'from_role' => $fromRole,
'reason' => trim($reason),
'source' => 'break_glass',
],
],
actor: null,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
actorId: (int) $platformUser->getKey(),
actorEmail: $platformUser->email,
actorName: $platformUser->name,
);
Notification::make()
->title('Owner assigned')
->success()
->send();
})
->disabled(fn (): bool => ! $breakGlass->isActive()),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class TenantArchivedBanner extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.tenant-archived-banner';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
return [
'tenant' => $tenant instanceof Tenant ? $tenant : null,
];
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
final class SelectTenantController
{
public function __invoke(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId === null) {
return redirect()->to('/admin/choose-workspace');
}
$validated = $request->validate([
'tenant_id' => ['required', 'integer'],
]);
$tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $workspaceId)
->whereKey($validated['tenant_id'])
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
return;
}
if (! Schema::hasTable('user_tenant_preferences')) {
return;
}
UserTenantPreference::query()->updateOrCreate(
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
['last_used_at' => now()]
);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SwitchWorkspaceController
{
public function __invoke(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$validated = $request->validate([
'workspace_id' => ['required', 'integer'],
]);
$workspace = Workspace::query()->whereKey($validated['workspace_id'])->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! empty($workspace->archived_at)) {
abort(404);
}
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, $request);
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return redirect()->route('admin.workspace.managed-tenants.onboarding', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
}
return redirect()->to(ChooseTenant::getUrl());
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceMember
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
$workspaceParam = $request->route()?->parameter('workspace');
$workspace = $workspaceParam instanceof Workspace
? $workspaceParam
: (is_scalar($workspaceParam)
? app(WorkspaceResolver::class)->resolve((string) $workspaceParam)
: null);
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
if (! $context->isMember($user, $workspace)) {
abort(404);
}
$context->setCurrentWorkspace($workspace, $user, $request);
return $next($request);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$routeName = $request->route()?->getName();
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
return $next($request);
}
$path = '/'.ltrim($request->path(), '/');
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, $request);
if ($workspace !== null) {
return $next($request);
}
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
return new HttpResponse('', 302, ['Location' => $target]);
}
}

View File

@ -7,11 +7,14 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\MicrosoftProviderHealthCheck;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -83,17 +86,64 @@ public function handle(
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
$report = VerificationReportWriter::write(
run: $this->operationRun,
checks: [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => $result->healthy ? 'pass' : 'fail',
'severity' => $result->healthy ? 'info' : 'critical',
'blocking' => ! $result->healthy,
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
'evidence' => array_values(array_filter([
[
'kind' => 'provider_connection_id',
'value' => (int) $connection->getKey(),
],
[
'kind' => 'entra_tenant_id',
'value' => (string) $connection->entra_tenant_id,
],
is_numeric($result->meta['http_status'] ?? null) ? [
'kind' => 'http_status',
'value' => (int) $result->meta['http_status'],
] : null,
is_string($result->meta['organization_id'] ?? null) ? [
'kind' => 'organization_id',
'value' => (string) $result->meta['organization_id'],
] : null,
])),
'next_steps' => $result->healthy
? []
: [[
'label' => 'Review provider connection',
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
'record' => (int) $connection->getKey(),
], tenant: $tenant),
]],
],
],
identity: [
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => (string) $connection->entra_tenant_id,
],
);
if ($result->healthy) {
$runs->updateRun(
$run = $runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
return;
}
$runs->updateRun(
$run = $runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
@ -103,6 +153,8 @@ public function handle(
'message' => $result->message ?? 'Health check failed.',
]],
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
}
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
@ -145,4 +197,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
'last_error_message' => $result->healthy ? null : $result->message,
]);
}
/**
* @param array<string, mixed> $report
*/
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
{
$workspace = $tenant->workspace;
if (! $workspace) {
return;
}
$counts = $report['summary']['counts'] ?? [];
$counts = is_array($counts) ? $counts : [];
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::VerificationCompleted->value,
context: [
'metadata' => [
'operation_run_id' => (int) $run->getKey(),
'counts' => $counts,
],
],
actor: $actor,
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
}
}

View File

@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string
public function table(Table $table): Table
{
$backupSet = BackupSet::query()->find($this->backupSetId);
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
$tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey();
$existingPolicyIds = $backupSet
? $backupSet->items()->pluck('policy_id')->filter()->all()
: [];

View File

@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
@ -116,7 +117,7 @@ public function makeCurrent(): void
$this->forceFill(['is_current' => true]);
}
public static function current(): self
public static function current(): ?self
{
$filamentTenant = Filament::getTenant();
@ -145,6 +146,13 @@ public static function current(): self
->where('is_current', true)
->first();
return $tenant;
}
public static function currentOrFail(): self
{
$tenant = static::current();
if (! $tenant) {
throw new RuntimeException('No current tenant selected.');
}
@ -152,11 +160,29 @@ public static function current(): self
return $tenant;
}
public function resolveRouteBinding($value, $field = null): ?Model
{
$field ??= $this->getRouteKeyName();
$query = static::query();
if ($field === 'external_id') {
$query = $query->withTrashed();
}
return $query->where($field, $value)->first();
}
public function memberships(): HasMany
{
return $this->hasMany(TenantMembership::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function roleMappings(): HasMany
{
return $this->hasMany(TenantRoleMapping::class);

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantOnboardingSession extends Model
{
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
use HasFactory;
protected $table = 'managed_tenant_onboarding_sessions';
protected $guarded = [];
protected $casts = [
'state' => 'array',
'completed_at' => 'datetime',
];
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function startedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'started_by_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function updatedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by_user_id');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants;
@ -130,8 +131,8 @@ public function canAccessTenant(Model $tenant): bool
return false;
}
return $this->tenants()
->whereKey($tenant->getKey())
return $this->tenantMemberships()
->where('tenant_id', $tenant->getKey())
->exists();
}
@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection
return collect();
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
return $this->tenants()
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
->where('status', 'active')
->orderBy('name')
->get();
@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$tenantId = null;
if ($this->tenantPreferencesTableExists()) {
@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model
if ($tenantId !== null) {
$tenant = $this->tenants()
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
->where('status', 'active')
->whereKey($tenantId)
->first();
@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model
}
return $this->tenants()
->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId))
->where('status', 'active')
->orderBy('name')
->first();

43
app/Models/Workspace.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Workspace extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return HasMany<WorkspaceMembership, $this>
*/
public function memberships(): HasMany
{
return $this->hasMany(WorkspaceMembership::class);
}
/**
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'workspace_memberships')
->using(WorkspaceMembership::class)
->withPivot(['id', 'role'])
->withTimestamps();
}
/**
* @return HasMany<Tenant, $this>
*/
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkspaceMembership extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceMembershipFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
class WorkspaceMembershipPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, WorkspaceMembership $workspaceMembership): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, WorkspaceMembership $workspaceMembership): bool
{
if ($this->isLastOwner($workspaceMembership)) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, WorkspaceMembership $workspaceMembership): bool
{
if ($this->isLastOwner($workspaceMembership)) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, WorkspaceMembership $workspaceMembership): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool
{
return false;
}
public function manageForWorkspace(User $user, Workspace $workspace): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
private function isLastOwner(WorkspaceMembership $membership): bool
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return false;
}
$ownerCount = WorkspaceMembership::query()
->where('workspace_id', $membership->workspace_id)
->where('role', WorkspaceRole::Owner->value)
->count();
return $ownerCount <= 1;
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
class WorkspacePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Workspace $workspace): bool
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Workspace $workspace): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Workspace $workspace): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Workspace $workspace): bool
{
return false;
}
}

View File

@ -6,8 +6,10 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Policies\ProviderConnectionPolicy;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -23,15 +25,36 @@ public function boot(): void
{
$this->registerPolicies();
$resolver = app(CapabilityResolver::class);
$tenantResolver = app(CapabilityResolver::class);
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
$defineTenantCapability = function (string $capability) use ($resolver): void {
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
return $resolver->can($user, $tenant, $capability);
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($tenantResolver, $capability): bool {
if (! $tenant instanceof Tenant) {
return false;
}
return $tenantResolver->can($user, $tenant, $capability);
});
};
$defineWorkspaceCapability = function (string $capability) use ($workspaceResolver): void {
Gate::define($capability, function (User $user, ?Workspace $workspace = null) use ($workspaceResolver, $capability): bool {
if (! $workspace instanceof Workspace) {
return false;
}
return $workspaceResolver->can($user, $workspace, $capability);
});
};
foreach (Capabilities::all() as $capability) {
if (str_starts_with($capability, 'workspace')) {
$defineWorkspaceCapability($capability);
continue;
}
$defineTenantCapability($capability);
}

View File

@ -4,9 +4,10 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
@ -14,6 +15,7 @@
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@ -37,21 +39,36 @@ public function panel(Panel $panel): Panel
->path('admin')
->login(Login::class)
->authenticatedRoutes(function (Panel $panel): void {
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
WorkspaceResource::registerRoutes($panel);
})
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Workspaces')
->url(function (): string {
return route('filament.admin.resources.workspaces.index');
})
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10),
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
fn () => view('filament.partials.workspace-switcher')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
@ -79,6 +96,8 @@ public function panel(Panel $panel): Panel
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\Audit;
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable;
class WorkspaceAuditLogger
{
public function log(
Workspace $workspace,
string $action,
array $context = [],
?User $actor = null,
string $status = 'success',
?string $resourceType = null,
?string $resourceId = null,
?int $actorId = null,
?string $actorEmail = null,
?string $actorName = null,
): AuditLog {
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
$metadata = is_array($metadata) ? $metadata : [];
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
return AuditLog::create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'actor_id' => $actor?->getKey() ?? $actorId,
'actor_email' => $actor?->email ?? $actorEmail,
'actor_name' => $actor?->name ?? $actorName,
'action' => $action,
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $sanitizedMetadata,
'recorded_at' => CarbonImmutable::now(),
]);
}
}

View File

@ -4,39 +4,27 @@
namespace App\Services\Auth;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Collection;
use App\Models\WorkspaceMembership;
use Illuminate\Support\Facades\Schema;
class PostLoginRedirectResolver
{
public function resolve(User $user): string
{
$tenants = $this->getActiveTenants($user);
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
if ($tenants->isEmpty()) {
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
if (! $hasAnyActiveMembership) {
return '/admin/no-access';
}
if ($tenants->count() === 1) {
/** @var Tenant $tenant */
$tenant = $tenants->first();
return TenantDashboard::getUrl(tenant: $tenant);
}
return '/admin/choose-tenant';
}
/**
* @return Collection<int, Tenant>
*/
private function getActiveTenants(User $user): Collection
{
return $user->tenants()
->where('status', 'active')
->orderBy('name')
->get();
return '/admin';
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActionId;
use Illuminate\Support\Facades\DB;
class TenantDiagnosticsService
{
public function __construct(public AuditLogger $auditLogger) {}
public function tenantHasNoOwners(Tenant $tenant): bool
{
return ! TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('role', 'owner')
->exists();
}
public function userHasDuplicateMemberships(Tenant $tenant, User $user): bool
{
return TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $user->getKey())
->count() > 1;
}
public function mergeDuplicateMembershipsForUser(Tenant $tenant, User $actor, User $member): void
{
DB::transaction(function () use ($tenant, $actor, $member): void {
$memberships = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $member->getKey())
->orderBy('created_at')
->get();
if ($memberships->count() <= 1) {
return;
}
$roles = $memberships->pluck('role')->all();
$roleToKeep = $this->highestRole($roles);
$membershipToKeep = $memberships->firstWhere('role', $roleToKeep) ?? $memberships->first();
if (! $membershipToKeep instanceof TenantMembership) {
return;
}
$idsToDelete = $memberships
->reject(fn (TenantMembership $m): bool => $m->getKey() === $membershipToKeep->getKey())
->pluck($membershipToKeep->getKeyName())
->all();
$membershipToKeep->forceFill([
'role' => $roleToKeep,
])->save();
TenantMembership::query()
->whereIn($membershipToKeep->getKeyName(), $idsToDelete)
->delete();
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::TenantMembershipDuplicatesMerged->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'kept_membership_id' => (string) $membershipToKeep->getKey(),
'deleted_membership_ids' => array_values(array_map('strval', $idsToDelete)),
'result_role' => $roleToKeep,
'source_roles' => $roles,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
});
}
/**
* @param array<int, string|null> $roles
*/
private function highestRole(array $roles): string
{
$priority = [
'owner' => 3,
'manager' => 2,
'readonly' => 1,
];
$bestRole = 'readonly';
$bestScore = 0;
foreach ($roles as $role) {
$score = $priority[$role] ?? 0;
if ($score > $bestScore) {
$bestScore = $score;
$bestRole = (string) $role;
}
}
return $bestRole;
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use Illuminate\Support\Facades\Log;
/**
* Workspace Capability Resolver
*
* Resolves user memberships and capabilities for a given workspace.
* Caches results per request to avoid N+1 queries.
*/
class WorkspaceCapabilityResolver
{
private array $resolvedMemberships = [];
private array $loggedDenials = [];
public function getRole(User $user, Workspace $workspace): ?WorkspaceRole
{
$membership = $this->getMembership($user, $workspace);
if ($membership === null) {
return null;
}
return WorkspaceRole::tryFrom($membership['role']);
}
public function can(User $user, Workspace $workspace, string $capability): bool
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException("Unknown capability: {$capability}");
}
$role = $this->getRole($user, $workspace);
if ($role === null) {
$this->logDenial($user, $workspace, $capability);
return false;
}
$allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability);
if (! $allowed) {
$this->logDenial($user, $workspace, $capability);
}
return $allowed;
}
public function isMember(User $user, Workspace $workspace): bool
{
return $this->getMembership($user, $workspace) !== null;
}
public function clearCache(): void
{
$this->resolvedMemberships = [];
}
private function logDenial(User $user, Workspace $workspace, string $capability): void
{
$key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]);
if (isset($this->loggedDenials[$key])) {
return;
}
$this->loggedDenials[$key] = true;
Log::warning('rbac.workspace.denied', [
'capability' => $capability,
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $user->getKey(),
]);
}
private function getMembership(User $user, Workspace $workspace): ?array
{
$cacheKey = "workspace_membership_{$user->id}_{$workspace->id}";
if (! isset($this->resolvedMemberships[$cacheKey])) {
$membership = WorkspaceMembership::query()
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->first(['role']);
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
}
return $this->resolvedMemberships[$cacheKey];
}
}

View File

@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
use DomainException;
use Illuminate\Support\Facades\DB;
class WorkspaceMembershipManager
{
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
public function addMember(
Workspace $workspace,
User $actor,
User $member,
string $role,
string $source = 'manual',
): WorkspaceMembership {
$this->assertValidRole($role);
$this->assertActorCanManage($actor, $workspace);
try {
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
$existing = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('user_id', (int) $member->getKey())
->first();
if ($existing) {
if ($existing->role !== $role) {
$fromRole = (string) $existing->role;
$this->guardLastOwnerDemotion($workspace, $existing, $role);
$existing->forceFill([
'role' => $role,
])->save();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'from_role' => $fromRole,
'to_role' => $role,
'source' => $source,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
return $existing->refresh();
}
$membership = WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $member->getKey(),
'role' => $role,
]);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipAdd->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'role' => $role,
'source' => $source,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
return $membership;
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLastOwnerBlocked(
workspace: $workspace,
actor: $actor,
targetUserId: (int) $member->getKey(),
attemptedRole: $role,
currentRole: WorkspaceRole::Owner->value,
attemptedAction: 'role_change',
);
}
throw $exception;
}
}
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
{
$this->assertValidRole($newRole);
$this->assertActorCanManage($actor, $workspace);
try {
return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
$membership->refresh();
if ($membership->workspace_id !== (int) $workspace->getKey()) {
throw new DomainException('Membership belongs to a different workspace.');
}
$oldRole = (string) $membership->role;
if ($oldRole === $newRole) {
return $membership;
}
$this->guardLastOwnerDemotion($workspace, $membership, $newRole);
$membership->forceFill([
'role' => $newRole,
])->save();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
return $membership->refresh();
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLastOwnerBlocked(
workspace: $workspace,
actor: $actor,
targetUserId: (int) $membership->user_id,
attemptedRole: $newRole,
currentRole: (string) $membership->role,
attemptedAction: 'role_change',
);
}
throw $exception;
}
}
public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void
{
$this->assertActorCanManage($actor, $workspace);
try {
DB::transaction(function () use ($workspace, $actor, $membership): void {
$membership->refresh();
if ($membership->workspace_id !== (int) $workspace->getKey()) {
throw new DomainException('Membership belongs to a different workspace.');
}
$this->guardLastOwnerRemoval($workspace, $membership);
$memberUserId = (int) $membership->user_id;
$oldRole = (string) $membership->role;
$membership->delete();
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipRemove->value,
context: [
'metadata' => [
'member_user_id' => $memberUserId,
'role' => $oldRole,
],
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
$this->auditLastOwnerBlocked(
workspace: $workspace,
actor: $actor,
targetUserId: (int) $membership->user_id,
attemptedRole: (string) $membership->role,
currentRole: (string) $membership->role,
attemptedAction: 'remove',
);
}
throw $exception;
}
}
private function assertActorCanManage(User $actor, Workspace $workspace): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
throw new DomainException('Forbidden.');
}
}
private function assertValidRole(string $role): void
{
$valid = array_map(
static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value,
WorkspaceRole::cases(),
);
if (! in_array($role, $valid, true)) {
throw new DomainException('Invalid role.');
}
}
private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return;
}
if ($newRole === WorkspaceRole::Owner->value) {
return;
}
$owners = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('role', WorkspaceRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot demote the last remaining owner.');
}
}
private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void
{
if ($membership->role !== WorkspaceRole::Owner->value) {
return;
}
$owners = WorkspaceMembership::query()
->where('workspace_id', (int) $workspace->getKey())
->where('role', WorkspaceRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot remove the last remaining owner.');
}
}
private function auditLastOwnerBlocked(
Workspace $workspace,
User $actor,
int $targetUserId,
string $attemptedRole,
string $currentRole,
string $attemptedAction,
): void {
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'target_user_id' => $targetUserId,
'attempted_role' => $attemptedRole,
'current_role' => $currentRole,
'attempted_action' => $attemptedAction,
],
],
actor: $actor,
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Services\Auth;
use App\Support\Auth\Capabilities;
use App\Support\Auth\WorkspaceRole;
/**
* Workspace Role to Capability Mapping (Single Source of Truth)
*
* This class defines which capabilities each workspace role has.
* All capability strings MUST be references from the Capabilities registry.
*/
class WorkspaceRoleCapabilityMap
{
/**
* @var array<string, array<int, string>>
*/
private static array $roleCapabilities = [
WorkspaceRole::Owner->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MANAGE,
Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
],
WorkspaceRole::Manager->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
],
WorkspaceRole::Operator->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
],
WorkspaceRole::Readonly->value => [
Capabilities::WORKSPACE_VIEW,
],
];
/**
* @return array<string>
*/
public static function getCapabilities(WorkspaceRole|string $role): array
{
$roleValue = $role instanceof WorkspaceRole ? $role->value : $role;
return self::$roleCapabilities[$roleValue] ?? [];
}
/**
* @return array<string>
*/
public static function rolesWithCapability(string $capability): array
{
$roles = [];
foreach (self::$roleCapabilities as $role => $capabilities) {
if (in_array($capability, $capabilities, true)) {
$roles[] = $role;
}
}
return $roles;
}
public static function hasCapability(WorkspaceRole|string $role, string $capability): bool
{
return in_array($capability, self::getCapabilities($role), true);
}
}

View File

@ -4,6 +4,7 @@
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable;
class AuditLogger
@ -22,6 +23,10 @@ public function log(
$metadata = $context['metadata'] ?? [];
unset($context['metadata']);
$metadata = is_array($metadata) ? $metadata : [];
$sanitizedMetadata = AuditContextSanitizer::sanitize($metadata + $context);
return AuditLog::create([
'tenant_id' => $tenant->id,
'actor_id' => $actorId,
@ -31,7 +36,7 @@ public function log(
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $metadata + $context,
'metadata' => $sanitizedMetadata,
'recorded_at' => CarbonImmutable::now(),
]);
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\Verification;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class StartVerification
{
public function __construct(
private readonly ProviderOperationStartGate $providers,
) {}
/**
* Start (or dedupe) a provider-connection verification run.
*
* @param array<string, mixed> $extraContext
*/
public function providerConnectionCheck(
Tenant $tenant,
ProviderConnection $connection,
User $initiator,
array $extraContext = [],
): ProviderOperationStartResult {
if (! $initiator->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
return $this->providers->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
},
initiator: $initiator,
extraContext: $extraContext,
);
}
}

View File

@ -6,6 +6,12 @@
enum AuditActionId: string
{
case WorkspaceMembershipAdd = 'workspace_membership.add';
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
case WorkspaceMembershipRemove = 'workspace_membership.remove';
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner';
case TenantMembershipAdd = 'tenant_membership.add';
case TenantMembershipRoleChange = 'tenant_membership.role_change';
case TenantMembershipRemove = 'tenant_membership.remove';
@ -13,4 +19,14 @@ enum AuditActionId: string
// Not part of the v1 contract, but used in codebase.
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
// Diagnostics / repair actions.
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
// Managed tenant onboarding wizard.
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
case VerificationCompleted = 'verification.completed';
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support\Audit;
final class AuditContextSanitizer
{
private const REDACTED = '[REDACTED]';
public static function sanitize(mixed $value): mixed
{
if (is_array($value)) {
$sanitized = [];
foreach ($value as $key => $item) {
if (is_string($key) && self::shouldRedactKey($key)) {
$sanitized[$key] = self::REDACTED;
continue;
}
$sanitized[$key] = self::sanitize($item);
}
return $sanitized;
}
if (is_string($value)) {
return self::sanitizeString($value);
}
return $value;
}
private static function shouldRedactKey(string $key): bool
{
$key = strtolower(trim($key));
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_contains($key, 'private_key')
|| str_contains($key, 'client_secret');
}
private static function sanitizeString(string $value): string
{
$candidate = trim($value);
if ($candidate === '') {
return $value;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
return self::REDACTED;
}
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
return self::REDACTED;
}
return $value;
}
}

View File

@ -15,6 +15,21 @@ class Capabilities
*/
private static ?array $all = null;
// Workspaces
public const WORKSPACE_VIEW = 'workspace.view';
public const WORKSPACE_MANAGE = 'workspace.manage';
public const WORKSPACE_ARCHIVE = 'workspace.archive';
// Workspace memberships
public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view';
public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage';
// Managed tenant onboarding
public const WORKSPACE_MANAGED_TENANT_ONBOARD = 'workspace_managed_tenant.onboard';
// Tenants
public const TENANT_VIEW = 'tenant.view';

View File

@ -0,0 +1,526 @@
<?php
namespace App\Support\Auth;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\RoleCapabilityMap;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use LogicException;
class UiEnforcement
{
private const TENANT_RESOLVER_FILAMENT = 'filament';
private const TENANT_RESOLVER_RECORD = 'record';
private const TENANT_RESOLVER_CUSTOM = 'custom';
private const BULK_PREFLIGHT_CAPABILITY = 'capability';
private const BULK_PREFLIGHT_TENANT_MEMBERSHIP = 'tenant_membership';
private const BULK_PREFLIGHT_CUSTOM = 'custom';
private bool $preserveVisibility = false;
private ?\Closure $businessVisible = null;
private ?\Closure $businessHidden = null;
private string $tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
private ?\Closure $customTenantResolver = null;
private string $bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
/**
* @var \Closure(Collection<int, Model>): bool|null
*/
private ?\Closure $bulkPreflight = null;
public function __construct(private string $capability)
{
}
public static function for(string $capability): self
{
return new self($capability);
}
public function preserveVisibility(): self
{
if ($this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) {
throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.');
}
$this->preserveVisibility = true;
return $this;
}
public function andVisibleWhen(callable $businessVisible): self
{
$this->businessVisible = \Closure::fromCallable($businessVisible);
return $this;
}
public function andHiddenWhen(callable $businessHidden): self
{
$this->businessHidden = \Closure::fromCallable($businessHidden);
return $this;
}
public function tenantFromFilament(): self
{
$this->tenantResolverMode = self::TENANT_RESOLVER_FILAMENT;
$this->customTenantResolver = null;
return $this;
}
public function tenantFromRecord(): self
{
if ($this->preserveVisibility) {
throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.');
}
$this->tenantResolverMode = self::TENANT_RESOLVER_RECORD;
$this->customTenantResolver = null;
return $this;
}
public function tenantFrom(callable $resolver): self
{
if ($this->preserveVisibility) {
throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.');
}
$this->tenantResolverMode = self::TENANT_RESOLVER_CUSTOM;
$this->customTenantResolver = \Closure::fromCallable($resolver);
return $this;
}
/**
* Custom bulk authorization preflight for selection.
*
* Signature: fn (Collection<int, Model> $records): bool
*/
public function preflightSelection(callable $preflight): self
{
$this->bulkPreflightMode = self::BULK_PREFLIGHT_CUSTOM;
$this->bulkPreflight = \Closure::fromCallable($preflight);
return $this;
}
public function preflightByTenantMembership(): self
{
$this->bulkPreflightMode = self::BULK_PREFLIGHT_TENANT_MEMBERSHIP;
$this->bulkPreflight = null;
return $this;
}
public function preflightByCapability(): self
{
$this->bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY;
$this->bulkPreflight = null;
return $this;
}
public function apply(Action $action): Action
{
$this->assertMixedVisibilityConfigIsValid();
if (! $this->preserveVisibility) {
$this->applyVisibility($action);
}
if ($action->isBulk()) {
$action->disabled(function () use ($action): bool {
/** @var Collection<int, Model> $records */
$records = collect($action->getSelectedRecords());
return $this->bulkIsDisabled($records);
});
$action->tooltip(function () use ($action): ?string {
/** @var Collection<int, Model> $records */
$records = collect($action->getSelectedRecords());
return $this->bulkDisabledTooltip($records);
});
} else {
$action->disabled(fn (?Model $record = null): bool => $this->isDisabled($record));
$action->tooltip(fn (?Model $record = null): ?string => $this->disabledTooltip($record));
}
return $action;
}
public function isAllowed(?Model $record = null): bool
{
return ! $this->isDisabled($record);
}
public function authorizeOrAbort(?Model $record = null): void
{
$user = auth()->user();
abort_unless($user instanceof User, 403);
$tenant = $this->resolveTenant($record);
if (! ($tenant instanceof Tenant)) {
abort(404);
}
abort_unless($this->isMemberOfTenant($user, $tenant), 404);
abort_unless(Gate::forUser($user)->allows($this->capability, $tenant), 403);
}
/**
* Server-side enforcement for bulk selections.
*
* - If any selected tenant is not a membership: 404 (deny-as-not-found).
* - If all are memberships but any lacks capability: 403.
*
* @param Collection<int, Model> $records
*/
public function authorizeBulkSelectionOrAbort(Collection $records): void
{
$user = auth()->user();
abort_unless($user instanceof User, 403);
$tenantIds = $this->resolveTenantIdsForRecords($records);
if ($tenantIds === []) {
abort(403);
}
$membershipTenantIds = $this->membershipTenantIds($user, $tenantIds);
if (count($membershipTenantIds) !== count($tenantIds)) {
abort(404);
}
$allowedTenantIds = $this->capabilityTenantIds($user, $tenantIds);
if (count($allowedTenantIds) !== count($tenantIds)) {
abort(403);
}
}
/**
* Public helper for evaluating bulk selection authorization decisions.
*
* @param Collection<int, Model> $records
*/
public function bulkSelectionIsAuthorized(User $user, Collection $records): bool
{
return $this->bulkSelectionIsAuthorizedInternal($user, $records);
}
private function applyVisibility(Action $action): void
{
$canApplyMemberVisibility = ! ($action->isBulk() && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT);
$businessVisible = $this->businessVisible;
$businessHidden = $this->businessHidden;
if ($businessVisible instanceof \Closure) {
$action->visible(function () use ($action, $businessVisible, $canApplyMemberVisibility): bool {
if (! (bool) $action->evaluate($businessVisible)) {
return false;
}
if (! $canApplyMemberVisibility) {
return true;
}
$record = $action->getRecord();
return $this->isMember($record instanceof Model ? $record : null);
});
}
if ($businessHidden instanceof \Closure) {
$action->hidden(function () use ($action, $businessHidden, $canApplyMemberVisibility): bool {
if ($canApplyMemberVisibility) {
$record = $action->getRecord();
if (! $this->isMember($record instanceof Model ? $record : null)) {
return true;
}
}
return (bool) $action->evaluate($businessHidden);
});
return;
}
if (! $canApplyMemberVisibility) {
return;
}
if (! ($businessVisible instanceof \Closure)) {
$action->hidden(function () use ($action): bool {
$record = $action->getRecord();
return ! $this->isMember($record instanceof Model ? $record : null);
});
}
}
private function assertMixedVisibilityConfigIsValid(): void
{
if ($this->preserveVisibility && ($this->businessVisible instanceof \Closure || $this->businessHidden instanceof \Closure)) {
throw new LogicException('preserveVisibility() cannot be combined with andVisibleWhen()/andHiddenWhen().');
}
if ($this->preserveVisibility && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) {
throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.');
}
}
private function isDisabled(?Model $record = null): bool
{
$user = auth()->user();
if (! ($user instanceof User)) {
return true;
}
$tenant = $this->resolveTenant($record);
if (! ($tenant instanceof Tenant)) {
return true;
}
if (! $this->isMemberOfTenant($user, $tenant)) {
return true;
}
return ! Gate::forUser($user)->allows($this->capability, $tenant);
}
private function disabledTooltip(?Model $record = null): ?string
{
$user = auth()->user();
if (! ($user instanceof User)) {
return null;
}
$tenant = $this->resolveTenant($record);
if (! ($tenant instanceof Tenant)) {
return null;
}
if (! $this->isMemberOfTenant($user, $tenant)) {
return null;
}
if (Gate::forUser($user)->allows($this->capability, $tenant)) {
return null;
}
return UiTooltips::insufficientPermission();
}
private function bulkIsDisabled(Collection $records): bool
{
$user = auth()->user();
if (! ($user instanceof User)) {
return true;
}
return ! $this->bulkSelectionIsAuthorizedInternal($user, $records);
}
private function bulkDisabledTooltip(Collection $records): ?string
{
$user = auth()->user();
if (! ($user instanceof User)) {
return null;
}
if ($this->bulkSelectionIsAuthorizedInternal($user, $records)) {
return null;
}
return UiTooltips::insufficientPermission();
}
private function bulkSelectionIsAuthorizedInternal(User $user, Collection $records): bool
{
if ($this->bulkPreflightMode === self::BULK_PREFLIGHT_CUSTOM && $this->bulkPreflight instanceof \Closure) {
return (bool) ($this->bulkPreflight)($records);
}
$tenantIds = $this->resolveTenantIdsForRecords($records);
if ($tenantIds === []) {
return false;
}
return match ($this->bulkPreflightMode) {
self::BULK_PREFLIGHT_TENANT_MEMBERSHIP => count($this->membershipTenantIds($user, $tenantIds)) === count($tenantIds),
self::BULK_PREFLIGHT_CAPABILITY => count($this->capabilityTenantIds($user, $tenantIds)) === count($tenantIds),
default => false,
};
}
/**
* @param Collection<int, Model> $records
* @return array<int>
*/
private function resolveTenantIdsForRecords(Collection $records): array
{
if ($this->tenantResolverMode === self::TENANT_RESOLVER_FILAMENT) {
$tenant = Filament::getTenant();
return $tenant instanceof Tenant ? [(int) $tenant->getKey()] : [];
}
if ($this->tenantResolverMode === self::TENANT_RESOLVER_RECORD) {
$ids = $records
->filter(fn (Model $record): bool => $record instanceof Tenant)
->map(fn (Tenant $tenant): int => (int) $tenant->getKey())
->all();
return array_values(array_unique($ids));
}
if ($this->tenantResolverMode === self::TENANT_RESOLVER_CUSTOM && $this->customTenantResolver instanceof \Closure) {
$ids = [];
foreach ($records as $record) {
if (! ($record instanceof Model)) {
continue;
}
$resolved = ($this->customTenantResolver)($record);
if ($resolved instanceof Tenant) {
$ids[] = (int) $resolved->getKey();
continue;
}
if (is_int($resolved)) {
$ids[] = $resolved;
}
}
return array_values(array_unique($ids));
}
return [];
}
private function isMember(?Model $record = null): bool
{
$user = auth()->user();
if (! ($user instanceof User)) {
return false;
}
$tenant = $this->resolveTenant($record);
if (! ($tenant instanceof Tenant)) {
return false;
}
return $this->isMemberOfTenant($user, $tenant);
}
private function isMemberOfTenant(User $user, Tenant $tenant): bool
{
return Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
}
private function resolveTenant(?Model $record = null): ?Tenant
{
return match ($this->tenantResolverMode) {
self::TENANT_RESOLVER_FILAMENT => Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
self::TENANT_RESOLVER_RECORD => $record instanceof Tenant ? $record : null,
self::TENANT_RESOLVER_CUSTOM => $this->resolveTenantViaCustomResolver($record),
default => null,
};
}
private function resolveTenantViaCustomResolver(?Model $record): ?Tenant
{
if (! ($this->customTenantResolver instanceof \Closure)) {
return null;
}
if (! ($record instanceof Model)) {
return null;
}
$resolved = ($this->customTenantResolver)($record);
if ($resolved instanceof Tenant) {
return $resolved;
}
return null;
}
/**
* @param array<int> $tenantIds
* @return array<int>
*/
private function membershipTenantIds(User $user, array $tenantIds): array
{
/** @var array<int> $ids */
$ids = DB::table('tenant_memberships')
->where('user_id', (int) $user->getKey())
->whereIn('tenant_id', $tenantIds)
->pluck('tenant_id')
->map(fn ($id): int => (int) $id)
->all();
return array_values(array_unique($ids));
}
/**
* @param array<int> $tenantIds
* @return array<int>
*/
private function capabilityTenantIds(User $user, array $tenantIds): array
{
$roles = RoleCapabilityMap::rolesWithCapability($this->capability);
if ($roles === []) {
return [];
}
/** @var array<int> $ids */
$ids = DB::table('tenant_memberships')
->where('user_id', (int) $user->getKey())
->whereIn('tenant_id', $tenantIds)
->whereIn('role', $roles)
->pluck('tenant_id')
->map(fn ($id): int => (int) $id)
->all();
return array_values(array_unique($ids));
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Support\Auth;
class UiTooltips
{
public const INSUFFICIENT_PERMISSION_ASK_OWNER = 'Insufficient permission — ask a tenant Owner.';
public static function insufficientPermission(): string
{
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Support\Auth;
enum WorkspaceRole: string
{
case Owner = 'owner';
case Manager = 'manager';
case Operator = 'operator';
case Readonly = 'readonly';
}

View File

@ -36,6 +36,9 @@ final class BadgeCatalog
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
];
/**

View File

@ -28,4 +28,7 @@ enum BadgeDomain: string
case RestoreResultStatus = 'restore_result_status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity';
case VerificationReportOverall = 'verification_report_overall';
}

View File

@ -13,6 +13,7 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationCheckSeverity;
final class VerificationCheckSeverityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationCheckStatus;
final class VerificationCheckStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationReportOverall;
final class VerificationReportOverallBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Support\Middleware;
use App\Filament\Pages\ChooseWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasTenants;
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureFilamentTenantSelected
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$panel = Filament::getCurrentOrDefaultPanel();
$path = '/'.ltrim($request->path(), '/');
if ($request->route()?->hasParameter('tenant')) {
$user = $request->user();
if ($user === null) {
return $next($request);
}
if (! $user instanceof HasTenants) {
abort(404);
}
if (! $panel->hasTenancy()) {
return $next($request);
}
$tenantParameter = $request->route()->parameter('tenant');
$tenant = $panel->getTenant($tenantParameter);
if (! $tenant instanceof Tenant) {
abort(404);
}
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId($request);
if ($workspaceId === null) {
abort(404);
}
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
abort(404);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
Filament::setTenant($tenant, true);
$this->configureNavigationForRequest($panel);
return $next($request);
}
if (
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if (filled(Filament::getTenant())) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
$tenant = null;
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId !== null) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->first();
if (! $tenant) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->first();
}
}
if (! $tenant) {
try {
$tenant = Tenant::current();
} catch (\RuntimeException) {
$tenant = null;
}
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
$tenant = null;
}
}
if (! $tenant) {
$tenant = $user->tenants()
->where('status', 'active')
->first();
}
if (! $tenant) {
$tenant = $user->tenants()->first();
}
if (! $tenant) {
$tenant = $user->tenants()->withTrashed()->first();
}
if ($tenant) {
Filament::setTenant($tenant, true);
}
$this->configureNavigationForRequest($panel);
return $next($request);
}
private function configureNavigationForRequest(\Filament\Panel $panel): void
{
if (! $panel->hasTenancy()) {
return;
}
if (filled(Filament::getTenant())) {
$panel->navigation(true);
return;
}
$panel->navigation(function (): NavigationBuilder {
return app(NavigationBuilder::class)
->item(
NavigationItem::make('Workspaces')
->url(fn (): string => ChooseWorkspace::getUrl())
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10),
);
});
}
}

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
@ -282,7 +283,7 @@ private function applyDisabledState(): void
return;
}
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null) {
$context = $this->resolveContextWithRecord($record);

View File

@ -19,7 +19,7 @@ final class UiTooltips
* Tooltip shown when a member lacks the required capability.
* Intentionally vague to avoid leaking permission structure.
*/
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
public const INSUFFICIENT_PERMISSION = 'Insufficient permission — ask a tenant Owner.';
/**
* Modal heading for destructive action confirmation.
@ -30,4 +30,14 @@ final class UiTooltips
* Modal description for destructive action confirmation.
*/
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
/**
* Tooltip for actions that are unavailable because the tenant is archived.
*/
public const TENANT_ARCHIVED = 'This tenant is archived.';
/**
* Tooltip for actions that are unavailable because a tenant must always have an owner.
*/
public const TENANT_OWNER_REQUIRED = 'This tenant must have at least one Owner.';
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\User;
use App\Models\Workspace;
/**
* DTO representing the access context for a workspace-scoped UI action.
*/
final readonly class WorkspaceAccessContext
{
public function __construct(
public ?User $user,
public ?Workspace $workspace,
public bool $isMember,
public bool $hasCapability,
) {}
/**
* Non-members should receive 404 (deny-as-not-found).
*/
public function shouldDenyAsNotFound(): bool
{
return ! $this->isMember;
}
/**
* Members without capability should receive 403 (forbidden).
*/
public function shouldDenyAsForbidden(): bool
{
return $this->isMember && ! $this->hasCapability;
}
/**
* User is authorized to perform the action.
*/
public function isAuthorized(): bool
{
return $this->isMember && $this->hasCapability;
}
}

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Support\Rbac;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use Closure;
use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model;
use Throwable;
/**
* Central workspace-scoped RBAC UI Enforcement Helper for Filament Actions.
*
* Mirrors the tenant-scoped UiEnforcement semantics, but uses WorkspaceMembership
* + WorkspaceCapabilityResolver.
*
* Rules:
* - Non-member hidden UI + 404 server-side
* - Member without capability visible-but-disabled + tooltip + 403 server-side
* - Member with capability enabled
*/
final class WorkspaceUiEnforcement
{
private Action $action;
private bool $requireMembership = true;
private ?string $capability = null;
private bool $isDestructive = false;
private ?string $customTooltip = null;
private Model|Closure|null $record = null;
private function __construct(Action $action)
{
$this->action = $action;
}
/**
* Create enforcement for a table action.
*
* @param Action $action The Filament action to wrap
* @param Model|Closure $record The owner record or a closure that returns it
*/
public static function forTableAction(Action $action, Model|Closure $record): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
public function requireMembership(bool $require = true): self
{
$this->requireMembership = $require;
return $this;
}
/**
* @throws \InvalidArgumentException If capability is not in the canonical registry
*/
public function requireCapability(string $capability): self
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException(
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
);
}
$this->capability = $capability;
return $this;
}
public function destructive(): self
{
$this->isDestructive = true;
return $this;
}
public function tooltip(string $message): self
{
$this->customTooltip = $message;
return $this;
}
public function apply(): Action
{
$this->applyVisibility();
$this->applyDisabledState();
$this->applyDestructiveConfirmation();
$this->applyServerSideGuard();
return $this->action;
}
private function applyVisibility(): void
{
if (! $this->requireMembership) {
return;
}
$this->action->visible(function (?Model $record = null): bool {
$context = $this->resolveContextWithRecord($record);
return $context->isMember;
});
}
private function applyDisabledState(): void
{
if ($this->capability === null) {
return;
}
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null): bool {
$context = $this->resolveContextWithRecord($record);
if (! $context->isMember) {
return true;
}
return ! $context->hasCapability;
});
$this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string {
$context = $this->resolveContextWithRecord($record);
if ($context->isMember && ! $context->hasCapability) {
return $tooltip;
}
return null;
});
}
private function applyDestructiveConfirmation(): void
{
if (! $this->isDestructive) {
return;
}
$this->action->requiresConfirmation();
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
}
private function applyServerSideGuard(): void
{
$this->action->before(function (?Model $record = null): void {
$context = $this->resolveContextWithRecord($record);
if ($context->shouldDenyAsNotFound()) {
abort(404);
}
if ($context->shouldDenyAsForbidden()) {
abort(403);
}
});
}
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
{
$user = auth()->user();
$workspace = $this->resolveWorkspaceWithRecord($record);
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return new WorkspaceAccessContext(
user: null,
workspace: null,
isMember: false,
hasCapability: false,
);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
$isMember = $resolver->isMember($user, $workspace);
$hasCapability = true;
if ($this->capability !== null && $isMember) {
$hasCapability = $resolver->can($user, $workspace, $this->capability);
}
return new WorkspaceAccessContext(
user: $user,
workspace: $workspace,
isMember: $isMember,
hasCapability: $hasCapability,
);
}
private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace
{
if ($record instanceof Workspace) {
return $record;
}
if ($this->record !== null) {
try {
$resolved = $this->record instanceof Closure
? ($this->record)()
: $this->record;
if ($resolved instanceof Workspace) {
return $resolved;
}
} catch (Throwable) {
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Support\Verification;
enum VerificationCheckSeverity: string
{
case Info = 'info';
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case Critical = 'critical';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Support\Verification;
enum VerificationCheckStatus: string
{
case Pass = 'pass';
case Fail = 'fail';
case Warn = 'warn';
case Skip = 'skip';
case Running = 'running';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Support\Verification;
enum VerificationReportOverall: string
{
case Ready = 'ready';
case NeedsAttention = 'needs_attention';
case Blocked = 'blocked';
case Running = 'running';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,358 @@
<?php
namespace App\Support\Verification;
final class VerificationReportSanitizer
{
/**
* @var array<int, string>
*/
private const FORBIDDEN_KEY_SUBSTRINGS = [
'access_token',
'refresh_token',
'client_secret',
'authorization',
'password',
'cookie',
'set-cookie',
];
/**
* @return array<string, mixed>
*/
public static function sanitizeReport(array $report): array
{
$sanitized = [];
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
if ($schemaVersion !== null) {
$sanitized['schema_version'] = $schemaVersion;
}
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
if ($flow !== null) {
$sanitized['flow'] = $flow;
}
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
if ($generatedAt !== null) {
$sanitized['generated_at'] = $generatedAt;
}
if (is_array($report['identity'] ?? null)) {
$identity = self::sanitizeIdentity((array) $report['identity']);
if ($identity !== []) {
$sanitized['identity'] = $identity;
}
}
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
$summary = self::sanitizeSummary($summary);
if ($summary !== null) {
$sanitized['summary'] = $summary;
}
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
$checks = self::sanitizeChecks($checks);
if ($checks !== null) {
$sanitized['checks'] = $checks;
}
return $sanitized;
}
/**
* @param array<string, mixed> $identity
* @return array<string, int|string>
*/
private static function sanitizeIdentity(array $identity): array
{
$sanitized = [];
foreach ($identity as $key => $value) {
if (! is_string($key) || trim($key) === '') {
continue;
}
if (self::containsForbiddenKeySubstring($key)) {
continue;
}
if (is_int($value)) {
$sanitized[$key] = $value;
continue;
}
if (! is_string($value)) {
continue;
}
$value = self::sanitizeValueString($value);
if ($value !== null) {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* @param array<string, mixed> $summary
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
*/
private static function sanitizeSummary(array $summary): ?array
{
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return null;
}
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
return null;
}
}
return [
'overall' => $overall,
'counts' => [
'total' => $counts['total'],
'pass' => $counts['pass'],
'fail' => $counts['fail'],
'warn' => $counts['warn'],
'skip' => $counts['skip'],
'running' => $counts['running'],
],
];
}
/**
* @param array<int, mixed> $checks
* @return array<int, array<string, mixed>>|null
*/
private static function sanitizeChecks(array $checks): ?array
{
if ($checks === []) {
return [];
}
$sanitized = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
if ($key === null || $title === null || $reasonCode === null) {
continue;
}
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
continue;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
continue;
}
$messageRaw = $check['message'] ?? null;
if (! is_string($messageRaw) || trim($messageRaw) === '') {
continue;
}
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
$sanitized[] = [
'key' => $key,
'title' => $title,
'status' => $status,
'severity' => $severity,
'blocking' => $blocking,
'reason_code' => $reasonCode,
'message' => self::sanitizeMessage($messageRaw),
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
];
}
return $sanitized;
}
/**
* @param array<int, mixed> $evidence
* @return array<int, array{kind: string, value: int|string}>
*/
private static function sanitizeEvidence(array $evidence): array
{
$sanitized = [];
foreach ($evidence as $pointer) {
if (! is_array($pointer)) {
continue;
}
$kind = $pointer['kind'] ?? null;
if (! is_string($kind) || trim($kind) === '') {
continue;
}
if (self::containsForbiddenKeySubstring($kind)) {
continue;
}
$value = $pointer['value'] ?? null;
if (is_int($value)) {
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
continue;
}
if (! is_string($value)) {
continue;
}
$sanitizedValue = self::sanitizeValueString($value);
if ($sanitizedValue === null) {
continue;
}
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
}
return $sanitized;
}
/**
* @param array<int, mixed> $nextSteps
* @return array<int, array{label: string, url: string}>
*/
private static function sanitizeNextSteps(array $nextSteps): array
{
$sanitized = [];
foreach ($nextSteps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$sanitized[] = [
'label' => $label,
'url' => $url,
];
}
return $sanitized;
}
private static function sanitizeMessage(mixed $message): string
{
if (! is_string($message)) {
return '—';
}
$message = trim(str_replace(["\r", "\n"], ' ', $message));
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
$message = str_ireplace(
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
'[REDACTED]',
$message,
);
$message = trim($message);
return $message === '' ? '—' : substr($message, 0, 240);
}
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
{
if (! is_string($value)) {
return $fallback;
}
$value = trim($value);
if ($value === '') {
return $fallback;
}
if (self::containsForbiddenKeySubstring($value)) {
return $fallback;
}
return substr($value, 0, 200);
}
private static function sanitizeValueString(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
return null;
}
if (strlen($value) > 512) {
return null;
}
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
return null;
}
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return null;
}
}
return $value;
}
private static function containsForbiddenKeySubstring(string $value): bool
{
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,235 @@
<?php
namespace App\Support\Verification;
use DateTimeImmutable;
final class VerificationReportSchema
{
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
/**
* @return array<string, mixed>|null
*/
public static function normalizeReport(mixed $report): ?array
{
if (! is_array($report)) {
return null;
}
if (! self::isValidReport($report)) {
return null;
}
return $report;
}
/**
* @param array<string, mixed> $report
*/
public static function isValidReport(array $report): bool
{
$schemaVersion = self::schemaVersion($report);
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
return false;
}
if (! self::isNonEmptyString($report['flow'] ?? null)) {
return false;
}
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
return false;
}
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
return false;
}
$summary = $report['summary'] ?? null;
if (! is_array($summary)) {
return false;
}
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return false;
}
$counts = $summary['counts'] ?? null;
if (! is_array($counts)) {
return false;
}
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
return false;
}
}
$checks = $report['checks'] ?? null;
if (! is_array($checks)) {
return false;
}
foreach ($checks as $check) {
if (! is_array($check) || ! self::isValidCheckResult($check)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $report
*/
public static function schemaVersion(array $report): ?string
{
$candidate = $report['schema_version'] ?? null;
if (! is_string($candidate)) {
return null;
}
$candidate = trim($candidate);
if ($candidate === '') {
return null;
}
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
return null;
}
return $candidate;
}
public static function isSupportedSchemaVersion(string $schemaVersion): bool
{
$parts = explode('.', $schemaVersion, 3);
if (count($parts) !== 3) {
return false;
}
$major = (int) $parts[0];
return $major === 1;
}
/**
* @param array<string, mixed> $check
*/
private static function isValidCheckResult(array $check): bool
{
if (! self::isNonEmptyString($check['key'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['title'] ?? null)) {
return false;
}
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
return false;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
return false;
}
if (! is_bool($check['blocking'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['message'] ?? null)) {
return false;
}
$evidence = $check['evidence'] ?? null;
if (! is_array($evidence)) {
return false;
}
foreach ($evidence as $pointer) {
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
return false;
}
}
$nextSteps = $check['next_steps'] ?? null;
if (! is_array($nextSteps)) {
return false;
}
foreach ($nextSteps as $step) {
if (! is_array($step) || ! self::isValidNextStep($step)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $pointer
*/
private static function isValidEvidencePointer(array $pointer): bool
{
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
return false;
}
$value = $pointer['value'] ?? null;
return is_int($value) || self::isNonEmptyString($value);
}
/**
* @param array<string, mixed> $step
*/
private static function isValidNextStep(array $step): bool
{
if (! self::isNonEmptyString($step['label'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($step['url'] ?? null)) {
return false;
}
return true;
}
private static function isNonEmptyString(mixed $value): bool
{
return is_string($value) && trim($value) !== '';
}
private static function isNonNegativeInt(mixed $value): bool
{
return is_int($value) && $value >= 0;
}
private static function isIsoDateTimeString(mixed $value): bool
{
if (! self::isNonEmptyString($value)) {
return false;
}
try {
new DateTimeImmutable((string) $value);
return true;
} catch (\Throwable) {
return false;
}
}
}

View File

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\OperationRun;
final class VerificationReportWriter
{
/**
* Baseline reason code taxonomy (v1).
*
* @var array<int, string>
*/
private const array BASELINE_REASON_CODES = [
'ok',
'not_applicable',
'missing_configuration',
'permission_denied',
'authentication_failed',
'throttled',
'dependency_unreachable',
'invalid_state',
'unknown_error',
];
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
public static function write(OperationRun $run, array $checks, array $identity = []): array
{
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
$report = self::build($flow, $checks, $identity);
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
}
$context = is_array($run->context) ? $run->context : [];
$context['verification_report'] = $report;
$run->update(['context' => $context]);
return $report;
}
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
public static function build(string $flow, array $checks, array $identity = []): array
{
$flow = trim($flow);
$flow = $flow !== '' ? $flow : 'unknown';
$normalizedChecks = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$normalizedChecks[] = self::normalizeCheckResult($check);
}
$counts = self::deriveCounts($normalizedChecks);
$report = [
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow,
'generated_at' => now()->toISOString(),
'summary' => [
'overall' => self::deriveOverall($normalizedChecks, $counts),
'counts' => $counts,
],
'checks' => $normalizedChecks,
];
if ($identity !== []) {
$report['identity'] = $identity;
}
return $report;
}
/**
* @return array<string, mixed>
*/
private static function buildFallbackReport(string $flow): array
{
return [
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow !== '' ? $flow : 'unknown',
'generated_at' => now()->toISOString(),
'summary' => [
'overall' => VerificationReportOverall::NeedsAttention->value,
'counts' => [
'total' => 0,
'pass' => 0,
'fail' => 0,
'warn' => 0,
'skip' => 0,
'running' => 0,
],
],
'checks' => [],
];
}
/**
* @param array<string, mixed> $check
* @return array{
* key: string,
* title: string,
* status: string,
* severity: string,
* blocking: bool,
* reason_code: string,
* message: string,
* evidence: array<int, array{kind: string, value: int|string}>,
* next_steps: array<int, array{label: string, url: string}>
* }
*/
private static function normalizeCheckResult(array $check): array
{
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
return [
'key' => $key,
'title' => $title,
'status' => self::normalizeCheckStatus($check['status'] ?? null),
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
];
}
private static function normalizeCheckStatus(mixed $status): string
{
if (! is_string($status)) {
return VerificationCheckStatus::Fail->value;
}
$status = strtolower(trim($status));
return in_array($status, VerificationCheckStatus::values(), true)
? $status
: VerificationCheckStatus::Fail->value;
}
private static function normalizeCheckSeverity(mixed $severity): string
{
if (! is_string($severity)) {
return VerificationCheckSeverity::Info->value;
}
$severity = strtolower(trim($severity));
return in_array($severity, VerificationCheckSeverity::values(), true)
? $severity
: VerificationCheckSeverity::Info->value;
}
private static function normalizeReasonCode(mixed $reasonCode): string
{
if (! is_string($reasonCode)) {
return 'unknown_error';
}
$reasonCode = strtolower(trim($reasonCode));
if ($reasonCode === '') {
return 'unknown_error';
}
if (str_starts_with($reasonCode, 'ext.')) {
return $reasonCode;
}
$reasonCode = match ($reasonCode) {
'graph_throttled' => 'throttled',
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
'provider_auth_failed' => 'authentication_failed',
'validation_error', 'conflict_detected' => 'invalid_state',
'unknown' => 'unknown_error',
default => $reasonCode,
};
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
}
/**
* @return array<int, array{kind: string, value: int|string}>
*/
private static function normalizeEvidence(mixed $evidence): array
{
if (! is_array($evidence)) {
return [];
}
$normalized = [];
foreach ($evidence as $pointer) {
if (! is_array($pointer)) {
continue;
}
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
$value = $pointer['value'] ?? null;
if ($kind === null) {
continue;
}
if (! is_int($value) && ! is_string($value)) {
continue;
}
if (is_string($value) && trim($value) === '') {
continue;
}
$normalized[] = [
'kind' => $kind,
'value' => is_int($value) ? $value : trim($value),
];
}
return $normalized;
}
/**
* @return array<int, array{label: string, url: string}>
*/
private static function normalizeNextSteps(mixed $steps): array
{
if (! is_array($steps)) {
return [];
}
$normalized = [];
foreach ($steps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$normalized[] = [
'label' => $label,
'url' => $url,
];
}
return $normalized;
}
/**
* @param array<int, array{status: string, blocking: bool}> $checks
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
*/
private static function deriveCounts(array $checks): array
{
$counts = [
'total' => count($checks),
'pass' => 0,
'fail' => 0,
'warn' => 0,
'skip' => 0,
'running' => 0,
];
foreach ($checks as $check) {
$status = $check['status'] ?? null;
if (! is_string($status) || ! array_key_exists($status, $counts)) {
continue;
}
$counts[$status] += 1;
}
return $counts;
}
/**
* @param array<int, array{status: string, blocking: bool}> $checks
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
*/
private static function deriveOverall(array $checks, array $counts): string
{
if (($counts['running'] ?? 0) > 0) {
return VerificationReportOverall::Running->value;
}
if (($counts['total'] ?? 0) === 0) {
return VerificationReportOverall::NeedsAttention->value;
}
foreach ($checks as $check) {
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
return VerificationReportOverall::Blocked->value;
}
}
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
return VerificationReportOverall::NeedsAttention->value;
}
return VerificationReportOverall::Ready->value;
}
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
{
if (! is_string($value)) {
return $fallback;
}
$value = trim($value);
if ($value === '') {
return $fallback;
}
return $value;
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Support\Workspaces;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Http\Request;
final class WorkspaceContext
{
public const SESSION_KEY = 'current_workspace_id';
public function __construct(private WorkspaceResolver $resolver) {}
public function currentWorkspaceId(?Request $request = null): ?int
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$id = $session->get(self::SESSION_KEY);
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
}
public function currentWorkspace(?Request $request = null): ?Workspace
{
$id = $this->currentWorkspaceId($request);
if (! $id) {
return null;
}
$workspace = Workspace::query()->whereKey($id)->first();
if (! $workspace) {
return null;
}
if (! $this->isWorkspaceSelectable($workspace)) {
return null;
}
return $workspace;
}
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
if ($user !== null) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
}
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->forget(self::SESSION_KEY);
if ($user !== null && $user->last_workspace_id !== null) {
$user->forceFill(['last_workspace_id' => null])->save();
}
}
public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$currentId = $this->currentWorkspaceId($request);
if ($currentId) {
$current = Workspace::query()->whereKey($currentId)->first();
if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) {
$session->forget(self::SESSION_KEY);
if ((int) $user->last_workspace_id === (int) $currentId) {
$user->forceFill(['last_workspace_id' => null])->save();
}
} else {
return $current;
}
}
if ($user->last_workspace_id !== null) {
$workspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
$user->forceFill(['last_workspace_id' => null])->save();
}
}
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->with('workspace')
->get();
$selectableWorkspaces = $memberships
->map(fn (WorkspaceMembership $membership) => $membership->workspace)
->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace))
->values();
if ($selectableWorkspaces->count() === 1) {
/** @var Workspace $workspace */
$workspace = $selectableWorkspaces->first();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
return $workspace;
}
return null;
}
public function isMember(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
private function isWorkspaceSelectable(Workspace $workspace): bool
{
return empty($workspace->archived_at);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Workspaces;
use App\Models\Workspace;
final class WorkspaceResolver
{
public function resolve(string $value): ?Workspace
{
$workspace = Workspace::query()
->where('slug', $value)
->first();
if ($workspace !== null) {
return $workspace;
}
if (! ctype_digit($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->first();
}
}

View File

@ -14,12 +14,17 @@
$middleware->alias([
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class,
'ensure-filament-tenant-selected' => \App\Support\Middleware\EnsureFilamentTenantSelected::class,
]);
$middleware->prependToPriorityList(
\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
\App\Http\Middleware\EnsureCorrectGuard::class,
);
$middleware->redirectGuestsTo('/admin/login');
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Workspace>
*/
class WorkspaceFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = $this->faker->company();
return [
'name' => $name,
'slug' => Str::slug($name).'-'.$this->faker->unique()->randomNumber(5),
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceMembership>
*/
class WorkspaceMembershipFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => \App\Models\Workspace::factory(),
'user_id' => \App\Models\User::factory(),
'role' => 'operator',
];
}
}

View File

@ -0,0 +1,31 @@
<?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('workspaces', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->nullable()->unique();
$table->timestamps();
$table->index('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspaces');
}
};

View File

@ -0,0 +1,33 @@
<?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('workspace_memberships', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
$table->timestamps();
$table->unique(['workspace_id', 'user_id']);
$table->index(['workspace_id', 'role']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspace_memberships');
}
};

View File

@ -0,0 +1,32 @@
<?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::table('users', function (Blueprint $table) {
$table->foreignId('last_workspace_id')
->nullable()
->after('remember_token')
->constrained('workspaces')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('last_workspace_id');
});
}
};

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
Schema::table('tenants', function (Blueprint $table) use ($driver): void {
$column = $table->foreignId('workspace_id')->nullable();
if ($driver !== 'sqlite') {
$column
->after('id')
->constrained('workspaces')
->nullOnDelete();
}
$table->index('workspace_id');
});
if ($driver === 'sqlite') {
// SQLite table rebuilds can drop/flatten the partial index defined in
// 2025_12_11_192942_add_is_current_to_tenants.php. Recreate it here.
DB::statement('DROP INDEX IF EXISTS tenants_current_unique');
DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = 1 AND deleted_at IS NULL');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropConstrainedForeignId('workspace_id');
});
}
};

View File

@ -0,0 +1,164 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
Schema::disableForeignKeyConstraints();
Schema::rename('audit_logs', 'audit_logs_old');
foreach ([
'audit_logs_tenant_id_action_index',
'audit_logs_tenant_id_resource_type_index',
'audit_logs_recorded_at_index',
] as $indexName) {
DB::statement("DROP INDEX IF EXISTS {$indexName}");
}
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_email')->nullable();
$table->string('actor_name')->nullable();
$table->string('action');
$table->string('resource_type')->nullable();
$table->string('resource_id')->nullable();
$table->string('status')->default('success');
$table->json('metadata')->nullable();
$table->timestamp('recorded_at')->useCurrent();
$table->timestamps();
$table->index(['tenant_id', 'action']);
$table->index(['tenant_id', 'resource_type']);
$table->index(['workspace_id', 'action']);
$table->index(['workspace_id', 'resource_type']);
$table->index('recorded_at');
});
DB::table('audit_logs_old')->orderBy('id')->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
DB::table('audit_logs')->insert([
'id' => $row->id,
'tenant_id' => $row->tenant_id,
'workspace_id' => null,
'actor_id' => $row->actor_id,
'actor_email' => $row->actor_email,
'actor_name' => $row->actor_name,
'action' => $row->action,
'resource_type' => $row->resource_type,
'resource_id' => $row->resource_id,
'status' => $row->status,
'metadata' => $row->metadata,
'recorded_at' => $row->recorded_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
}, 'id');
Schema::drop('audit_logs_old');
Schema::enableForeignKeyConstraints();
return;
}
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id DROP NOT NULL');
Schema::table('audit_logs', function (Blueprint $table) {
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete()->after('tenant_id');
$table->index(['workspace_id', 'action']);
$table->index(['workspace_id', 'resource_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = Schema::getConnection()->getDriverName();
if ($driver === 'sqlite') {
Schema::disableForeignKeyConstraints();
Schema::rename('audit_logs', 'audit_logs_new');
foreach ([
'audit_logs_tenant_id_action_index',
'audit_logs_tenant_id_resource_type_index',
'audit_logs_recorded_at_index',
'audit_logs_workspace_id_action_index',
'audit_logs_workspace_id_resource_type_index',
] as $indexName) {
DB::statement("DROP INDEX IF EXISTS {$indexName}");
}
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('actor_email')->nullable();
$table->string('actor_name')->nullable();
$table->string('action');
$table->string('resource_type')->nullable();
$table->string('resource_id')->nullable();
$table->string('status')->default('success');
$table->json('metadata')->nullable();
$table->timestamp('recorded_at')->useCurrent();
$table->timestamps();
$table->index(['tenant_id', 'action']);
$table->index(['tenant_id', 'resource_type']);
$table->index('recorded_at');
});
DB::table('audit_logs_new')->whereNotNull('tenant_id')->orderBy('id')->chunkById(500, function ($rows): void {
foreach ($rows as $row) {
DB::table('audit_logs')->insert([
'id' => $row->id,
'tenant_id' => $row->tenant_id,
'actor_id' => $row->actor_id,
'actor_email' => $row->actor_email,
'actor_name' => $row->actor_name,
'action' => $row->action,
'resource_type' => $row->resource_type,
'resource_id' => $row->resource_id,
'status' => $row->status,
'metadata' => $row->metadata,
'recorded_at' => $row->recorded_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
}, 'id');
Schema::drop('audit_logs_new');
Schema::enableForeignKeyConstraints();
return;
}
Schema::table('audit_logs', function (Blueprint $table) {
$table->dropConstrainedForeignId('workspace_id');
$table->dropIndex(['workspace_id', 'action']);
$table->dropIndex(['workspace_id', 'resource_type']);
});
DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL');
}
};

View File

@ -0,0 +1,142 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('workspaces')) {
return;
}
$now = now();
$defaultWorkspaceId = DB::table('workspaces')
->where('slug', 'default')
->value('id');
if (! $defaultWorkspaceId) {
$defaultWorkspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Default Workspace',
'slug' => 'default',
'created_at' => $now,
'updated_at' => $now,
]);
}
if (Schema::hasTable('tenants') && Schema::hasColumn('tenants', 'workspace_id')) {
DB::table('tenants')
->whereNull('workspace_id')
->update([
'workspace_id' => $defaultWorkspaceId,
'updated_at' => $now,
]);
}
if (! Schema::hasTable('workspace_memberships')) {
return;
}
$roleRankToRole = [
4 => 'owner',
3 => 'manager',
2 => 'operator',
1 => 'readonly',
];
$userRoleRanks = collect();
if (Schema::hasTable('tenant_memberships')) {
$userRoleRanks = DB::table('tenant_memberships')
->select([
'user_id',
DB::raw("MAX(CASE role WHEN 'owner' THEN 4 WHEN 'manager' THEN 3 WHEN 'operator' THEN 2 WHEN 'readonly' THEN 1 ELSE 0 END) AS role_rank"),
])
->groupBy('user_id')
->get();
}
$rows = [];
$userIds = [];
foreach ($userRoleRanks as $row) {
$role = $roleRankToRole[(int) $row->role_rank] ?? null;
if (! $role) {
continue;
}
$rows[] = [
'workspace_id' => $defaultWorkspaceId,
'user_id' => $row->user_id,
'role' => $role,
'created_at' => $now,
'updated_at' => $now,
];
$userIds[] = $row->user_id;
}
if (empty($rows) && Schema::hasTable('users')) {
$firstUserId = DB::table('users')->orderBy('id')->value('id');
if ($firstUserId) {
$rows[] = [
'workspace_id' => $defaultWorkspaceId,
'user_id' => $firstUserId,
'role' => 'owner',
'created_at' => $now,
'updated_at' => $now,
];
$userIds[] = $firstUserId;
}
}
if (! empty($rows)) {
foreach (array_chunk($rows, 500) as $chunk) {
DB::table('workspace_memberships')->insertOrIgnore($chunk);
}
}
$ownerCount = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspaceId)
->where('role', 'owner')
->count();
if ($ownerCount === 0) {
$firstMembershipId = DB::table('workspace_memberships')
->where('workspace_id', $defaultWorkspaceId)
->orderBy('id')
->value('id');
if ($firstMembershipId) {
DB::table('workspace_memberships')
->where('id', $firstMembershipId)
->update([
'role' => 'owner',
'updated_at' => $now,
]);
}
}
if (Schema::hasTable('users') && ! empty($userIds) && Schema::hasColumn('users', 'last_workspace_id')) {
DB::table('users')
->whereIn('id', array_unique($userIds))
->whereNull('last_workspace_id')
->update([
'last_workspace_id' => $defaultWorkspaceId,
'updated_at' => $now,
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void {}
};

View File

@ -0,0 +1,30 @@
<?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::table('workspaces', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('slug');
$table->index('archived_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropIndex(['archived_at']);
$table->dropColumn('archived_at');
});
}
};

View File

@ -0,0 +1,43 @@
<?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
{
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
return;
}
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('current_step')->nullable();
$table->json('state')->nullable();
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['tenant_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('managed_tenant_onboarding_sessions');
}
};

View File

@ -0,0 +1,128 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return;
}
if (! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
DB::transaction(function (): void {
$tenantIds = DB::table('tenants')->whereNull('workspace_id')->pluck('id');
foreach ($tenantIds as $tenantId) {
$workspaceId = DB::table('tenant_memberships')
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
->where('tenant_memberships.tenant_id', $tenantId)
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
->value('workspace_memberships.workspace_id');
if ($workspaceId !== null) {
DB::table('tenants')
->where('id', $tenantId)
->update(['workspace_id' => (int) $workspaceId]);
}
}
$remaining = (int) DB::table('tenants')->whereNull('workspace_id')->count();
if ($remaining === 0) {
return;
}
$legacyWorkspaceId = DB::table('workspaces')->insertGetId([
'name' => 'Legacy Workspace',
'slug' => 'legacy',
'created_at' => now(),
'updated_at' => now(),
]);
$users = DB::table('tenant_memberships')
->join('tenants', 'tenants.id', '=', 'tenant_memberships.tenant_id')
->whereNull('tenants.workspace_id')
->select([
'tenant_memberships.user_id',
DB::raw("MIN(CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END) AS role_rank"),
])
->groupBy('tenant_memberships.user_id')
->get();
$roleFromRank = static fn (int $rank): string => match ($rank) {
0 => 'owner',
1 => 'manager',
2 => 'operator',
default => 'readonly',
};
$membershipRows = [];
foreach ($users as $user) {
$membershipRows[] = [
'workspace_id' => (int) $legacyWorkspaceId,
'user_id' => (int) $user->user_id,
'role' => $roleFromRank((int) $user->role_rank),
'created_at' => now(),
'updated_at' => now(),
];
}
if ($membershipRows !== []) {
DB::table('workspace_memberships')->insertOrIgnore($membershipRows);
}
DB::table('tenants')
->whereNull('workspace_id')
->update(['workspace_id' => (int) $legacyWorkspaceId]);
});
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id SET NOT NULL');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NOT NULL');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return;
}
if (! Schema::hasColumn('tenants', 'workspace_id')) {
return;
}
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE tenants ALTER COLUMN workspace_id DROP NOT NULL');
return;
}
if ($driver === 'mysql') {
DB::statement('ALTER TABLE tenants MODIFY workspace_id BIGINT UNSIGNED NULL');
}
}
};

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('managed_tenant_onboarding_sessions')) {
return;
}
Schema::create('managed_tenant_onboarding_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('current_step')->nullable();
$table->json('state')->nullable();
$table->foreignId('started_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['tenant_id']);
});
}
public function down(): void
{
Schema::dropIfExists('managed_tenant_onboarding_sessions');
}
};

View File

@ -0,0 +1,178 @@
@php
$report = isset($getState) ? $getState() : ($report ?? null);
$report = is_array($report) ? $report : null;
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = $summary['counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
@endphp
<div class="space-y-4">
@if ($report === null || $summary === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div class="font-medium text-gray-900 dark:text-white">
Verification report unavailable
</div>
<div class="mt-1">
This run doesnt have a report yet. If its still running, refresh in a moment. If it already completed, start verification again.
</div>
</div>
@else
@php
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
@endphp
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
</div>
@if ($checks === [])
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
No checks found in this report. Start verification again to generate a fresh report.
</div>
@else
<div class="space-y-3">
@foreach ($checks as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? $title : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? $message : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$evidence = $check['evidence'] ?? [];
$evidence = is_array($evidence) ? $evidence : [];
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
@endphp
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="flex cursor-pointer items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</summary>
@if ($evidence !== [] || $nextSteps !== [])
<div class="mt-4 space-y-4">
@if ($evidence !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Evidence
</div>
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@foreach ($evidence as $pointer)
@php
$pointer = is_array($pointer) ? $pointer : [];
$kind = $pointer['kind'] ?? null;
$value = $pointer['value'] ?? null;
@endphp
@if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value)))
<li>
<span class="font-medium">{{ $kind }}:</span>
<span>{{ is_int($value) ? $value : $value }}</span>
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if ($nextSteps !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($isExternal)
target="_blank" rel="noreferrer"
@endif
>
{{ $label }}
</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endif
</details>
@endforeach
</div>
@endif
@endif
</div>

View File

@ -1,37 +1,60 @@
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Select a tenant to continue.
</div>
@php
$tenants = $this->getTenants();
@endphp
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No tenants are available for your account.
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Select a tenant to continue.
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="button"
color="primary"
wire:click="selectTenant({{ (int) $tenant->id }})"
>
Continue
</x-filament::button>
</div>
@php
$tenants = $this->getTenants();
@endphp
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">No tenants are available for your account.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Switch workspaces, or contact an administrator.
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
tag="a"
href="{{ route('filament.admin.pages.choose-workspace') }}"
>
Change workspace
</x-filament::button>
</div>
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div
wire:key="tenant-{{ $tenant->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border border-gray-200 p-4 dark:border-gray-800"
>
<form x-ref="form" method="POST" action="{{ route('admin.select-tenant') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->id }}" />
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="submit"
color="primary"
class="w-full"
>
Continue
</x-filament::button>
</form>
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -0,0 +1,70 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Select a workspace to continue.
</div>
@php
$workspaces = $this->getWorkspaces();
$user = auth()->user();
$recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0;
if ($recommendedWorkspaceId > 0) {
[$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId);
$workspaces = $recommended->concat($other)->values();
}
@endphp
@if ($workspaces->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No active workspaces are available for your account.
You can create one using the button above.
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($workspaces as $workspace)
@php
$isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId;
@endphp
<div
wire:key="workspace-{{ $workspace->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border p-4 dark:border-gray-800 {{ $isRecommended ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30' : 'border-gray-200' }}"
>
<form x-ref="form" method="POST" action="{{ route('admin.switch-workspace') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="workspace_id" value="{{ (int) $workspace->id }}" />
<div class="flex flex-col gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $workspace->name }}
</div>
@if ($isRecommended)
<div>
<x-filament::badge color="warning" size="sm">
Last used
</x-filament::badge>
</div>
@endif
</div>
<x-filament::button
type="submit"
color="primary"
class="w-full"
>
Continue
</x-filament::button>
</form>
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -1,11 +1,13 @@
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
You dont have access to any tenants yet.
</div>
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
You dont have access to any tenants yet.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Ask an administrator to add you to a tenant, then sign in again.
<div class="text-sm text-gray-600 dark:text-gray-300">
Ask an administrator to add you to a tenant, then sign in again.
</div>
</div>
</div>
</x-filament::section>
</x-filament::section>
</x-filament-panels::page>

View File

@ -0,0 +1,31 @@
<x-filament-panels::page>
<div class="space-y-6">
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">Tenant diagnostics</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Identify common tenant configuration issues and apply safe repairs.
</p>
</div>
@if ($missingOwner)
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100">
<div class="font-semibold">Missing owner</div>
<div class="mt-1 text-sm">This tenant currently has no Owner members.</div>
</div>
@endif
@if ($hasDuplicateMembershipsForCurrentUser)
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100">
<div class="font-semibold">Duplicate memberships</div>
<div class="mt-1 text-sm">This tenant has duplicate membership rows for your user.</div>
</div>
@endif
@if (! $missingOwner && ! $hasDuplicateMembershipsForCurrentUser)
<div class="rounded-xl border border-gray-200 bg-white p-4 text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div class="font-semibold text-gray-950 dark:text-white">All good</div>
<div class="mt-1 text-sm">No known issues detected.</div>
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,170 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
</div>
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">
Managed tenant onboarding
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
This wizard will guide you through identifying a managed tenant and verifying access.
</div>
</div>
@if ($this->managedTenant)
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
</div>
</dl>
</div>
@endif
@php
$verificationSucceeded = $this->verificationSucceeded();
$hasTenant = (bool) $this->managedTenant;
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
@endphp
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 Identify managed tenant</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
</div>
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasTenant ? 'Done' : 'Pending' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
wire:click="mountAction('identifyManagedTenant')"
>
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 Provider connection</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
</div>
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
</div>
</div>
@if ($hasTenant)
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('createProviderConnection')"
>
Create connection
</x-filament::button>
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('selectProviderConnection')"
>
Select connection
</x-filament::button>
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 Verify access</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
:disabled="! $hasConnection"
wire:click="mountAction('startVerification')"
>
Run verification
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 Bootstrap (optional)</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
:disabled="! $verificationSucceeded"
wire:click="mountAction('startBootstrap')"
>
Start bootstrap
</x-filament::button>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 Complete onboarding</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="success"
:disabled="! $verificationSucceeded"
wire:click="mountAction('completeOnboarding')"
>
Complete onboarding
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

Some files were not shown because too many files have changed in this diff Show More