Compare commits

..

20 Commits

Author SHA1 Message Date
Ahmed Darrazi
2581b1407e fix: resolve onboarding wizard import conflict 2026-02-01 19:41:38 +01:00
Ahmed Darrazi
3b16b1b94c Merge remote-tracking branch 'origin/069-managed-tenant-onboarding-wizard-session-1769903080' into feat/999-merge-integration-session-1769990000
# Conflicts:
#	.github/agents/copilot-instructions.md
#	app/Filament/Resources/TenantResource/Pages/CreateTenant.php
#	app/Filament/Resources/TenantResource/Pages/ListTenants.php
#	app/Models/Tenant.php
#	app/Providers/Filament/AdminPanelProvider.php
#	routes/web.php
#	tests/Feature/BulkSyncPoliciesTest.php
#	tests/Feature/Filament/TenantSetupTest.php
#	tests/Feature/Rbac/TenantAdminAuthorizationTest.php
2026-02-01 19:31:16 +01:00
Ahmed Darrazi
da05b9f096 Merge remote-tracking branch 'origin/069-tenant-onboarding-wizard-v2-session-1769905221' into feat/999-merge-integration-session-1769990000
# Conflicts:
#	app/Filament/Resources/TenantResource/Pages/CreateTenant.php
#	app/Filament/Resources/TenantResource/Pages/ViewTenant.php
#	app/Providers/AuthServiceProvider.php
#	phpunit.xml
#	tests/Feature/BulkSyncPoliciesTest.php
2026-02-01 19:25:31 +01:00
Ahmed Darrazi
cffa4053c8 Merge remote-tracking branch 'origin/068-workspaces-v2' into feat/999-merge-integration-session-1769990000
# Conflicts:
#	app/Filament/Resources/TenantResource.php
#	app/Filament/Resources/TenantResource/Pages/CreateTenant.php
#	app/Filament/Resources/TenantResource/Pages/ListTenants.php
#	app/Providers/Filament/AdminPanelProvider.php
#	tests/Feature/Filament/TenantSetupTest.php
2026-02-01 19:23:28 +01:00
Ahmed Darrazi
14daee6964 Merge remote-tracking branch 'origin/068-workspace-foundation-v1' into feat/999-merge-integration-session-1769990000 2026-02-01 19:11:18 +01:00
Ahmed Darrazi
875aa1eed2 Merge remote-tracking branch 'origin/feat/066-rbac-ui-enforcement-helper-v2' into feat/999-merge-integration-session-1769990000 2026-02-01 19:11:11 +01:00
Ahmed Darrazi
21df2056f1 wip: save 069 onboarding wizard v2 worktree state 2026-02-01 12:20:18 +01:00
Ahmed Darrazi
31376c422e wip: save 069 onboarding wizard v1 worktree state 2026-02-01 12:20:09 +01:00
Ahmed Darrazi
6b8f076d4a wip: save 068-workspaces-v2 worktree state 2026-02-01 12:19:57 +01:00
Ahmed Darrazi
da610d5c5a merge: agent session work 2026-02-01 10:56:00 +01:00
Ahmed Darrazi
b8c75fc641 feat: unify managed tenant onboarding (spec 068) 2026-02-01 10:49:19 +01:00
Ahmed Darrazi
458a94c6e9 spec: finalize 069 v2 docs 2026-02-01 01:20:10 +01:00
Ahmed Darrazi
3185ba5791 spec: add 068 workspace foundation docs 2026-02-01 00:50:44 +01:00
Ahmed Darrazi
ccfd491260 Merge remote-tracking branch 'origin/dev' into feat/066-rbac-ui-enforcement-helper-v2 2026-01-30 18:27:29 +01:00
Ahmed Darrazi
d4148020bc fix: align tooltip + guard after dev merge 2026-01-30 18:18:52 +01:00
Ahmed Darrazi
ea72c34398 Merge remote-tracking branch 'origin/dev' into feat/066-rbac-ui-enforcement-helper-v2
# Conflicts:
#	app/Filament/Resources/BackupSetResource.php
#	app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php
#	app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php
#	app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php
#	app/Filament/Resources/InventoryItemResource.php
#	app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php
#	app/Filament/Resources/InventorySyncRunResource.php
#	app/Filament/Resources/ProviderConnectionResource.php
#	app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php
#	app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php
#	app/Filament/Resources/RestoreRunResource.php
#	app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php
#	app/Filament/Resources/TenantResource.php
#	app/Filament/Resources/TenantResource/Pages/EditTenant.php
#	specs/066-rbac-ui-enforcement-helper/checklists/requirements.md
#	specs/066-rbac-ui-enforcement-helper/plan.md
#	specs/066-rbac-ui-enforcement-helper/quickstart.md
#	specs/066-rbac-ui-enforcement-helper/spec.md
#	specs/066-rbac-ui-enforcement-helper/tasks.md
#	tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
2026-01-30 18:16:47 +01:00
Ahmed Darrazi
95ccc3008c feat: enforce Filament RBAC via UiEnforcement v2 2026-01-30 17:51:24 +01:00
Ahmed Darrazi
a53bb3f708 spec: add 066 UiEnforcement v2 spec/plan/tasks 2026-01-30 17:49:05 +01:00
Ahmed Darrazi
07dda36d6e spec: clarify 066 rbac ui enforcement defaults 2026-01-28 23:22:54 +01:00
Ahmed Darrazi
e5ad9b6cf8 spec: 066 rbac ui enforcement helper v1 2026-01-28 23:10:49 +01:00
394 changed files with 13483 additions and 13129 deletions

View File

@ -1,6 +1,5 @@
node_modules/
vendor/
coverage/
.git/
.DS_Store
Thumbs.db

View File

@ -14,8 +14,10 @@ ## Active Technologies
- 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 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4 (068-workspaces-v2)
- PostgreSQL (via Sail) (068-workspaces-v2)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (069-managed-tenant-onboarding-wizard)
- PostgreSQL (Sail) (069-managed-tenant-onboarding-wizard)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -35,7 +37,8 @@ ## 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
- 068-workspaces-v2: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Tailwind CSS v4
- 069-managed-tenant-onboarding-wizard: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 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

View File

@ -50,7 +50,8 @@ ### Tenant Isolation is Non-negotiable
### RBAC & UI Enforcement Standards (RBAC-UX)
RBAC Context — Planes, Roles, and Auditability
- The platform MUST maintain two strictly separated authorization planes:
- The platform MUST maintain strictly separated authorization planes:
- Workspace plane (`/admin/w/{workspace}`): authenticated Entra users (`users`), authorization is workspace-scoped.
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
@ -69,11 +70,11 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
- Any missing server-side authorization is a P0 security bug.
RBAC-UX-002 — Deny-as-not-found for non-members
- Tenant membership (and plane membership) is an isolation boundary.
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
action endpoints (Livewire calls included).
- Workspace membership and tenant membership (and plane membership) are isolation boundaries.
- If the current actor is not a member of the current workspace or tenant (or otherwise not entitled to the relevant scope), the system MUST
respond as 404 (deny-as-not-found) for scope-scoped routes/actions/resources.
- This applies to Filament resources/pages under workspace routing (`/admin/w/{workspace}/...`) and tenant routing (`/admin/t/{tenant}/...`),
Global Search results, and all action endpoints (Livewire calls included).
RBAC-UX-003 — Capability denial is 403 (after membership is established)
- Within an established tenant scope, missing permissions are authorization failures.
@ -174,4 +175,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-31

View File

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

View File

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

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
trait ScopesGlobalSearchToWorkspace
{
/**
* The Eloquent relationship name used to scope records to the current workspace.
*/
protected static string $globalSearchWorkspaceRelationship = 'workspace';
public static function getGlobalSearchEloquentQuery(): Builder
{
$query = static::getModel()::query();
if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel();
if ($panel?->hasTenancy()) {
$query->withoutGlobalScope($panel->getTenancyScopeName());
}
}
$user = auth()->user();
if (! $user instanceof User) {
return $query->whereRaw('1 = 0');
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->currentWorkspace();
if (! $workspace instanceof Workspace) {
return $query->whereRaw('1 = 0');
}
if (! $context->isMember($user, $workspace)) {
return $query->whereRaw('1 = 0');
}
return $query->whereBelongsTo($workspace, static::$globalSearchWorkspaceRelationship);
}
}

View File

@ -100,7 +100,7 @@ public function selectWorkspace(int $workspaceId): void
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
$this->redirect('/admin/tenants');
}
/**
@ -132,41 +132,6 @@ public function createWorkspace(array $data): void
->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();
$this->redirect('/admin/tenants');
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class ArchivedStatus extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/archived';
protected static ?string $title = 'Archived managed tenant';
protected string $view = 'filament.pages.managed-tenants.archived-status';
public ?Tenant $tenant = null;
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
$this->tenant = ManagedTenantContext::archivedTenant();
if (! $this->tenant instanceof Tenant) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('back_to_managed_tenants')
->label('Back to managed tenants')
->url(Index::getUrl()),
UiEnforcement::forTableAction(
Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$tenant = $this->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant->restore();
$tenant->refresh();
ManagedTenantContext::setCurrentTenant($tenant);
ManagedTenantContext::clearArchivedTenant();
Notification::make()
->title('Managed tenant restored')
->success()
->send();
$this->redirect(Current::getUrl());
}),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
->destructive()
->apply(),
UiEnforcement::forTableAction(
Action::make('force_delete')
->label('Force delete')
->icon('heroicon-o-trash')
->color('danger')
->action(function (): void {
$tenant = $this->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant->forceDelete();
ManagedTenantContext::clear();
Notification::make()
->title('Managed tenant deleted')
->success()
->send();
$this->redirect(Index::getUrl());
}),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
->destructive()
->apply(),
];
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use Filament\Actions;
use Filament\Pages\Page;
class Current extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/current';
protected static ?string $title = 'Current managed tenant';
protected string $view = 'filament.pages.managed-tenants.current';
public ?Tenant $tenant = null;
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
$currentTenantId = ManagedTenantContext::currentTenantId();
if (is_int($currentTenantId)) {
$selectedTenant = Tenant::withTrashed()->find($currentTenantId);
if (! $selectedTenant instanceof Tenant) {
ManagedTenantContext::clearCurrentTenant();
} elseif (! $selectedTenant->isActive()) {
ManagedTenantContext::clearCurrentTenant();
ManagedTenantContext::setArchivedTenant($selectedTenant);
$this->redirect(ArchivedStatus::getUrl());
return;
} else {
$this->tenant = $selectedTenant;
}
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$canViewAny = Tenant::query()
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
->cursor()
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
if (! $canViewAny) {
abort(403);
}
if (! $this->tenant instanceof Tenant) {
return;
}
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
// The active status is already verified above.
}
/**
* @return array<Actions\Action>
*/
protected function getHeaderActions(): array
{
return [
Actions\Action::make('back_to_managed_tenants')
->label('Back to managed tenants')
->url(Index::getUrl()),
];
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
class EditManagedTenant extends Page implements HasForms
{
use InteractsWithForms;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/{managedTenant}/edit';
protected static ?string $title = 'Edit managed tenant';
protected string $view = 'filament.pages.managed-tenants.edit';
public Tenant $tenant;
/**
* @var array<string, mixed>
*/
public array $data = [];
public function mount(string $managedTenant): void
{
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
abort(403);
}
if (! $this->tenant->isActive()) {
\App\Support\ManagedTenants\ManagedTenantContext::setArchivedTenant($this->tenant);
$this->redirect(ArchivedStatus::getUrl());
}
$this->form->fill([
'name' => $this->tenant->name,
'environment' => $this->tenant->environment,
'tenant_id' => $this->tenant->tenant_id,
'domain' => $this->tenant->domain,
'app_client_id' => $this->tenant->app_client_id,
'app_client_secret' => null,
'app_certificate_thumbprint' => $this->tenant->app_certificate_thumbprint,
'app_notes' => $this->tenant->app_notes,
]);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
])
->statePath('data');
}
public function save(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
abort(403);
}
$data = $this->form->getState();
$this->tenant->fill($data);
$this->tenant->save();
Notification::make()
->title('Managed tenant updated')
->success()
->send();
$this->redirect(ViewManagedTenant::getUrl(['tenant' => $this->tenant]));
}
}

View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class Index extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = true;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
protected static ?string $navigationLabel = 'Managed tenants';
protected static ?string $slug = 'managed-tenants';
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.managed-tenants.index';
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$canViewAny = Tenant::query()
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
->cursor()
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
if (! $canViewAny) {
abort(403);
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('add_managed_tenant')
->label('Add managed tenant')
->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding'),
];
}
public function table(Table $table): Table
{
return $table
->query($this->managedTenantsQuery())
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('tenant_id')->label('Tenant ID')->copyable()->searchable(),
TextColumn::make('environment')->badge()->sortable(),
TextColumn::make('status')->badge()->sortable(),
])
->actions([
UiEnforcement::forTableAction(
Action::make('open')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open"),
fn () => Filament::getTenant(),
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forTableAction(
Action::make('view')
->label('View')
->url(fn (Tenant $record): string => ViewManagedTenant::getUrl(['managedTenant' => $record])),
fn () => Filament::getTenant(),
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forTableAction(
Action::make('edit')
->label('Edit')
->url(fn (Tenant $record): string => EditManagedTenant::getUrl(['managedTenant' => $record])),
fn () => Filament::getTenant(),
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
->apply(),
])
->emptyStateHeading('No managed tenants')
->emptyStateDescription('Add your first managed tenant to begin onboarding.')
->emptyStateActions([
Action::make('empty_add_managed_tenant')
->label('Add managed tenant')
->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding'),
]);
}
private function managedTenantsQuery(): Builder
{
$user = auth()->user();
if (! $user instanceof User) {
return Tenant::query()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->pluck('tenants.id');
return Tenant::query()
->withTrashed()
->whereIn('id', $tenantIds);
}
}

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
class Onboarding extends Page implements HasForms
{
use InteractsWithForms;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'managed-tenants/onboarding';
protected static ?string $title = 'Add managed tenant';
protected string $view = 'filament.pages.managed-tenants.onboarding';
/**
* @var array<string, mixed>
*/
public array $data = [];
public function mount(): void
{
static::abortIfNonMember();
if (! static::canView()) {
abort(403);
}
$this->form->fill();
}
public static function canView(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = static::resolveCurrentWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
])
->statePath('data');
}
public function create(AuditLogger $auditLogger): void
{
static::abortIfNonMember();
if (! static::canView()) {
abort(403);
}
$data = $this->form->getState();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspace = static::resolveCurrentWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
abort(403);
}
$data['workspace_id'] = (int) $workspace->getKey();
$tenant = Tenant::query()->create($data);
if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => [
'role' => 'owner',
'source' => 'manual',
'created_by_user_id' => $user->getKey(),
],
]);
$auditLogger->log(
tenant: $tenant,
action: 'managed_tenant.onboarding.created',
context: [
'metadata' => [
'internal_tenant_id' => (int) $tenant->getKey(),
'tenant_guid' => (string) $tenant->tenant_id,
],
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
Notification::make()
->title('Managed tenant added')
->success()
->send();
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
}
private static function abortIfNonMember(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! static::resolveCurrentWorkspaceFor($user) instanceof Workspace) {
abort(404);
}
}
private static function resolveCurrentWorkspaceFor(User $user): ?Workspace
{
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, request());
if (! $workspace instanceof Workspace) {
return null;
}
return $context->isMember($user, $workspace) ? $workspace : null;
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use UnitEnum;
class ViewManagedTenant extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/{managedTenant}';
protected static ?string $title = 'Managed tenant';
protected string $view = 'filament.pages.managed-tenants.view';
public Tenant $tenant;
public function mount(string $managedTenant): void
{
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
if (! $this->tenant->isActive()) {
ManagedTenantContext::setArchivedTenant($this->tenant);
$this->redirect(ArchivedStatus::getUrl());
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
UiEnforcement::forTableAction(
Action::make('open')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): string => "/admin/managed-tenants/{$this->tenant->getKey()}/open"),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forTableAction(
Action::make('edit')
->label('Edit')
->url(fn (): string => EditManagedTenant::getUrl(['managedTenant' => $this->tenant])),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
->apply(),
];
}
}

View File

@ -80,6 +80,6 @@ public function createWorkspace(array $data): void
->success()
->send();
$this->redirect(ChooseTenant::getUrl());
$this->redirect('/admin/tenants');
}
}

View File

@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Onboarding;
use App\Jobs\Onboarding\OnboardingConnectionDiagnosticsJob;
use App\Jobs\Onboarding\OnboardingConsentStatusJob;
use App\Jobs\Onboarding\OnboardingInitialSyncJob;
use App\Jobs\Onboarding\OnboardingVerifyPermissionsJob;
use App\Models\OnboardingEvidence;
use App\Models\OnboardingSession;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Onboarding\OnboardingFixHints;
use App\Support\Onboarding\OnboardingTaskCatalog;
use App\Support\Onboarding\OnboardingTaskType;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class TenantOnboardingTaskBoard extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'onboarding/tasks';
protected static ?string $title = 'Onboarding task board';
protected string $view = 'filament.pages.onboarding.tenant-onboarding-task-board';
public ?OnboardingSession $session = null;
public bool $canStartProviderTasks = false;
/**
* @var array<string, string>
*/
public array $runUrls = [];
public function mount(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
$activeSession = OnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->whereIn('status', ['draft', 'in_progress'])
->latest('id')
->first();
if (! $activeSession instanceof OnboardingSession) {
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
return;
}
$this->session = $activeSession;
if ($activeSession->current_step < 4) {
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
}
}
/**
* @return array<string, string>
*/
public function latestEvidenceStatusByTaskType(): array
{
$tenant = Tenant::current();
$evidence = OnboardingEvidence::query()
->where('tenant_id', $tenant->getKey())
->whereIn('task_type', OnboardingTaskType::all())
->orderByDesc('recorded_at')
->get();
$byTask = [];
foreach ($evidence as $row) {
if (! isset($byTask[$row->task_type])) {
$byTask[$row->task_type] = $row->status;
}
}
return $byTask;
}
/**
* @return array<string, OnboardingEvidence>
*/
public function latestEvidenceByTaskType(): array
{
$tenant = Tenant::current();
$evidence = OnboardingEvidence::query()
->where('tenant_id', $tenant->getKey())
->whereIn('task_type', OnboardingTaskType::all())
->orderByDesc('recorded_at')
->get();
$byTask = [];
foreach ($evidence as $row) {
if (! isset($byTask[$row->task_type])) {
$byTask[$row->task_type] = $row;
}
}
return $byTask;
}
/**
* @return array<int, array{
* task_type: string,
* title: string,
* step: int,
* prerequisites: array<int, string>,
* status: string,
* badge: \App\Support\Badges\BadgeSpec,
* evidence: OnboardingEvidence|null,
* prerequisites_met: bool,
* unmet_prerequisites: array<int, string>,
* }>
*/
public function taskRows(): array
{
$statuses = $this->latestEvidenceStatusByTaskType();
$evidenceByTask = $this->latestEvidenceByTaskType();
return collect(OnboardingTaskCatalog::all())
->map(function (array $task) use ($statuses, $evidenceByTask): array {
$taskType = $task['task_type'];
$status = $statuses[$taskType] ?? 'unknown';
$unmet = OnboardingTaskCatalog::unmetPrerequisites($taskType, $statuses);
return [
'task_type' => $taskType,
'title' => $task['title'],
'step' => $task['step'],
'prerequisites' => $task['prerequisites'],
'status' => $status,
'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $status),
'evidence' => $evidenceByTask[$taskType] ?? null,
'prerequisites_met' => count($unmet) === 0,
'unmet_prerequisites' => $unmet,
];
})
->values()
->all();
}
/**
* @return array<int, string>
*/
public function fixHintsFor(?string $reasonCode): array
{
return OnboardingFixHints::forReason($reasonCode);
}
/**
* @return array<int, array{
* recorded_at: string,
* task_type: string,
* status: string,
* badge: \App\Support\Badges\BadgeSpec,
* reason_code: string|null,
* message: string|null,
* run_url: string|null,
* }>
*/
public function recentEvidenceRows(int $limit = 20): array
{
$tenant = Tenant::current();
$evidence = OnboardingEvidence::query()
->where('tenant_id', $tenant->getKey())
->orderByDesc('recorded_at')
->limit($limit)
->with('operationRun')
->get();
return $evidence
->map(function (OnboardingEvidence $row) use ($tenant): array {
$runUrl = null;
if ($row->operationRun) {
$runUrl = OperationRunLinks::view($row->operationRun, $tenant);
}
return [
'recorded_at' => $row->recorded_at?->toDateTimeString() ?? '',
'task_type' => $row->task_type,
'status' => $row->status,
'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $row->status),
'reason_code' => $row->reason_code,
'message' => $row->message,
'run_url' => $runUrl,
];
})
->values()
->all();
}
public function startTask(string $taskType): void
{
if (! $this->canStartProviderTasks) {
abort(403);
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->session instanceof OnboardingSession) {
Notification::make()
->title('No onboarding session')
->danger()
->send();
return;
}
if ($this->session->current_step < 4) {
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
return;
}
if (! in_array($taskType, OnboardingTaskType::all(), true)) {
Notification::make()
->title('Unknown task')
->danger()
->send();
return;
}
$latestStatuses = $this->latestEvidenceStatusByTaskType();
if (! OnboardingTaskCatalog::prerequisitesMet($taskType, $latestStatuses)) {
Notification::make()
->title('Prerequisites not met')
->body('Complete required tasks first.')
->warning()
->send();
return;
}
$connectionId = $this->session->provider_connection_id;
if (! is_int($connectionId)) {
Notification::make()
->title('Select a provider connection first')
->warning()
->send();
return;
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->whereKey($connectionId)
->first();
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('Selected provider connection not found')
->danger()
->send();
return;
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$run = $runs->ensureRunWithIdentity(
tenant: $tenant,
type: $taskType,
identityInputs: ['task_type' => $taskType],
context: [
'task_type' => $taskType,
'onboarding_session_id' => (int) $this->session->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
initiator: $user,
);
$this->runUrls[$taskType] = OperationRunLinks::view($run, $tenant);
if (! $run->wasRecentlyCreated) {
Notification::make()
->title('Task already queued')
->body('A run is already queued or running. Use the link to monitor progress.')
->warning()
->send();
return;
}
match ($taskType) {
OnboardingTaskType::VerifyPermissions => OnboardingVerifyPermissionsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
onboardingSessionId: (int) $this->session->getKey(),
operationRun: $run,
),
OnboardingTaskType::ConsentStatus => OnboardingConsentStatusJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
onboardingSessionId: (int) $this->session->getKey(),
operationRun: $run,
),
OnboardingTaskType::ConnectionDiagnostics => OnboardingConnectionDiagnosticsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
onboardingSessionId: (int) $this->session->getKey(),
operationRun: $run,
),
OnboardingTaskType::InitialSync => OnboardingInitialSyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
onboardingSessionId: (int) $this->session->getKey(),
operationRun: $run,
),
default => null,
};
Notification::make()
->title('Task queued')
->success()
->send();
}
}

View File

@ -0,0 +1,863 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Onboarding;
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
use App\Jobs\Onboarding\OnboardingConsentStatusJob;
use App\Jobs\Onboarding\OnboardingVerifyPermissionsJob;
use App\Models\OnboardingEvidence;
use App\Models\OnboardingSession;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Onboarding\LegacyTenantCredentialMigrator;
use App\Services\Onboarding\OnboardingLockService;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Onboarding\OnboardingTaskCatalog;
use App\Support\Onboarding\OnboardingTaskType;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Arr;
class TenantOnboardingWizard extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'onboarding';
protected static ?string $title = 'Onboarding';
protected string $view = 'filament.pages.onboarding.tenant-onboarding-wizard';
public ?OnboardingSession $session = null;
public bool $canStartProviderTasks = false;
public bool $canManageProviderConnections = false;
public bool $canManageTenant = false;
public bool $hasSessionLock = false;
public bool $sessionLockedByOther = false;
public ?string $sessionLockedByLabel = null;
public ?string $sessionLockedUntil = null;
public ?int $selectedProviderConnectionId = null;
public ?string $verifyPermissionsRunUrl = null;
public ?string $consentStatusRunUrl = null;
/**
* @var array<int, string>
*/
public array $handoffUserOptions = [];
public function mount(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
$this->canManageProviderConnections = $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
$this->canManageTenant = $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
$activeSession = OnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->whereIn('status', ['draft', 'in_progress'])
->latest('id')
->first();
if (! $activeSession instanceof OnboardingSession && $this->canStartProviderTasks) {
$activeSession = OnboardingSession::query()->create([
'tenant_id' => $tenant->getKey(),
'status' => 'draft',
'current_step' => 1,
'assigned_to_user_id' => $user->getKey(),
'metadata' => [],
]);
}
$this->session = $activeSession;
$defaultConnectionId = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->where('is_default', true)
->value('id');
$this->selectedProviderConnectionId = $this->session?->provider_connection_id
?? (is_int($defaultConnectionId) ? $defaultConnectionId : null);
$this->refreshCollaborationState(attemptAcquire: $this->canStartProviderTasks);
if ($this->session instanceof OnboardingSession
&& $this->hasSessionLock
&& $this->session->provider_connection_id === null
&& is_int($this->selectedProviderConnectionId)
&& $this->tenantHasLegacyCredentials($tenant)
) {
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->whereKey($this->selectedProviderConnectionId)
->first();
if ($connection instanceof ProviderConnection) {
$this->session->update(['provider_connection_id' => $connection->getKey()]);
$this->session->refresh();
}
}
}
private function tenantHasLegacyCredentials(Tenant $tenant): bool
{
return trim((string) ($tenant->app_client_id ?? '')) !== ''
&& trim((string) ($tenant->app_client_secret ?? '')) !== '';
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
return [
UiEnforcement::forAction(
Action::make('takeover_onboarding_session')
->label('Take over')
->color('warning')
->requiresConfirmation()
->modalHeading('Take over onboarding session')
->modalDescription('This will take over the onboarding session lock. Use when the current lock holder is unavailable.')
->action(function (): void {
$this->takeoverSession();
}),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->sessionLockedByOther),
UiEnforcement::forAction(
Action::make('handoff_onboarding_session')
->label('Handoff')
->color('gray')
->requiresConfirmation()
->modalHeading('Handoff onboarding session')
->modalDescription('Assign onboarding to another tenant member and release your lock.')
->form([
Select::make('assigned_to_user_id')
->label('Assign to')
->options(fn (): array => $this->handoffUserOptions)
->searchable()
->required(),
])
->action(function (array $data): void {
$assignedToUserId = (int) ($data['assigned_to_user_id'] ?? 0);
$this->handoffSession($assignedToUserId);
}),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock),
Action::make('release_onboarding_lock')
->label('Release lock')
->color('gray')
->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock)
->requiresConfirmation()
->modalHeading('Release onboarding lock')
->modalDescription('This will release your lock so another user can take over onboarding.')
->action(function (): void {
$this->releaseSessionLock();
}),
UiEnforcement::forAction(
Action::make('migrate_legacy_credentials')
->label('Migrate legacy credentials')
->color('warning')
->requiresConfirmation()
->modalHeading('Migrate legacy tenant credentials')
->modalDescription('This will copy the tenant\'s legacy app client secret into the selected provider connection credentials. The secret is never displayed.')
->action(function (): void {
$this->migrateLegacyCredentials();
}),
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply()
->visible(fn (): bool => $this->canOfferLegacyCredentialMigration()),
];
}
private function refreshCollaborationState(bool $attemptAcquire = false): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
$this->hasSessionLock = false;
$this->sessionLockedByOther = false;
$this->sessionLockedByLabel = null;
$this->sessionLockedUntil = null;
$this->handoffUserOptions = [];
if (! $this->session instanceof OnboardingSession) {
return;
}
if ($this->canManageTenant) {
$this->handoffUserOptions = $tenant->users()
->orderBy('name')
->orderBy('email')
->get(['users.id', 'users.name', 'users.email'])
->mapWithKeys(function (User $member): array {
$label = trim((string) $member->name) !== ''
? (string) $member->name
: (string) $member->email;
if (trim((string) $member->email) !== '') {
$label .= ' <'.$member->email.'>';
}
return [(int) $member->getKey() => $label];
})
->all();
}
if ($attemptAcquire) {
app(OnboardingLockService::class)->acquire($this->session, $user);
}
$this->session->refresh();
$this->session->loadMissing(['lockedBy']);
$this->hasSessionLock = (int) ($this->session->locked_by_user_id ?? 0) === (int) $user->getKey()
&& $this->session->locked_until?->isFuture();
$this->sessionLockedByOther = $this->isLockedByOther($this->session, $user);
if ($this->sessionLockedByOther) {
$lockedBy = $this->session->lockedBy;
$this->sessionLockedByLabel = $lockedBy instanceof User
? (trim((string) $lockedBy->name) !== '' ? (string) $lockedBy->name : (string) $lockedBy->email)
: 'another user';
}
$this->sessionLockedUntil = $this->session->locked_until?->diffForHumans();
}
private function ensureLockForMutation(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->session instanceof OnboardingSession) {
return false;
}
$acquired = app(OnboardingLockService::class)->acquire($this->session, $user);
$this->refreshCollaborationState(attemptAcquire: false);
if ($acquired) {
return true;
}
Notification::make()
->title('Session is locked')
->body('Another user is currently editing onboarding. Take over the lock to make changes.')
->warning()
->send();
return false;
}
private function isLockedByOther(OnboardingSession $session, User $user): bool
{
if ($session->locked_by_user_id === null || $session->locked_until === null) {
return false;
}
if ($session->locked_until->isPast()) {
return false;
}
return (int) $session->locked_by_user_id !== (int) $user->getKey();
}
private function takeoverSession(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->canManageTenant) {
abort(403);
}
if (! $this->session instanceof OnboardingSession) {
return;
}
$previousLockHolderId = $this->session->locked_by_user_id;
app(OnboardingLockService::class)->takeover($this->session, $user);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'onboarding.takeover',
context: [
'onboarding_session_id' => (int) $this->session->getKey(),
'previous_locked_by_user_id' => is_int($previousLockHolderId) ? $previousLockHolderId : null,
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'onboarding_session',
resourceId: (string) $this->session->getKey(),
);
$this->refreshCollaborationState(attemptAcquire: false);
Notification::make()
->title('Lock taken over')
->success()
->send();
}
private function handoffSession(int $assignedToUserId): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->canManageTenant) {
abort(403);
}
if (! $this->session instanceof OnboardingSession) {
return;
}
if (! $this->ensureLockForMutation()) {
return;
}
$assignee = $tenant->users()->whereKey($assignedToUserId)->first();
if (! $assignee instanceof User) {
Notification::make()
->title('Assignee not found')
->danger()
->send();
return;
}
$this->session->update(['assigned_to_user_id' => (int) $assignee->getKey()]);
app(OnboardingLockService::class)->release($this->session, $user);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'onboarding.handoff',
context: [
'onboarding_session_id' => (int) $this->session->getKey(),
'assigned_to_user_id' => (int) $assignee->getKey(),
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'onboarding_session',
resourceId: (string) $this->session->getKey(),
);
$this->refreshCollaborationState(attemptAcquire: false);
Notification::make()
->title('Onboarding handed off')
->success()
->send();
}
private function releaseSessionLock(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->session instanceof OnboardingSession) {
return;
}
app(OnboardingLockService::class)->release($this->session, $user);
$this->refreshCollaborationState(attemptAcquire: false);
Notification::make()
->title('Lock released')
->success()
->send();
}
private function canOfferLegacyCredentialMigration(): bool
{
if (! $this->session instanceof OnboardingSession) {
return false;
}
$tenant = Tenant::current();
if (! $this->tenantHasLegacyCredentials($tenant)) {
return false;
}
$connectionId = $this->session->provider_connection_id;
if (! is_int($connectionId)) {
return false;
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->with('credential')
->whereKey($connectionId)
->first();
if (! $connection instanceof ProviderConnection) {
return false;
}
$credential = $connection->credential;
if ($credential === null) {
return true;
}
if ($credential->type !== 'client_secret') {
return false;
}
$payload = $credential->payload;
if (! is_array($payload)) {
return true;
}
$clientId = trim((string) Arr::get($payload, 'client_id'));
$clientSecret = trim((string) Arr::get($payload, 'client_secret'));
return $clientId === '' || $clientSecret === '';
}
private function migrateLegacyCredentials(): void
{
if (! $this->canManageProviderConnections) {
abort(403);
}
if (! $this->session instanceof OnboardingSession) {
return;
}
if (! $this->ensureLockForMutation()) {
return;
}
$tenant = Tenant::current();
$connectionId = $this->session->provider_connection_id;
if (! is_int($connectionId)) {
Notification::make()
->title('Select a provider connection first')
->warning()
->send();
return;
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->whereKey($connectionId)
->first();
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('Selected provider connection not found')
->danger()
->send();
return;
}
$outcome = app(LegacyTenantCredentialMigrator::class)->migrate($tenant, $connection);
$this->refreshCollaborationState(attemptAcquire: false);
Notification::make()
->title($outcome['migrated'] ? 'Credentials migrated' : 'Migration not needed')
->body($outcome['message'])
->color($outcome['migrated'] ? 'success' : 'gray')
->send();
}
/**
* @return array<int, array{id: int, label: string}>
*/
public function providerConnections(): array
{
$tenant = Tenant::current();
return ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->orderByDesc('is_default')
->orderBy('provider')
->orderBy('display_name')
->get()
->map(function (ProviderConnection $connection): array {
$entraName = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name');
$entraSuffix = is_string($entraName) && trim($entraName) !== '' ? ' — '.trim($entraName) : '';
$label = ($connection->display_name ?: ucfirst($connection->provider))
.$entraSuffix
.($connection->is_default ? ' (default)' : '');
return [
'id' => (int) $connection->getKey(),
'label' => $label,
];
})
->values()
->all();
}
public function updatedSelectedProviderConnectionId(): void
{
if (! $this->canStartProviderTasks) {
abort(403);
}
$tenant = Tenant::current();
if (! $this->session instanceof OnboardingSession) {
return;
}
if (! $this->ensureLockForMutation()) {
return;
}
if (! is_int($this->selectedProviderConnectionId)) {
$this->session->update(['provider_connection_id' => null]);
$this->session->refresh();
return;
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->whereKey($this->selectedProviderConnectionId)
->first();
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('Connection not found')
->danger()
->send();
return;
}
$this->session->update([
'provider_connection_id' => $connection->getKey(),
]);
$this->session->refresh();
$this->refreshCollaborationState(attemptAcquire: false);
Notification::make()
->title('Provider connection selected')
->success()
->send();
}
/**
* @return array<int, array{task_type: string, title: string, step: int, prerequisites: array<int, string>}>
*/
public function planTasks(): array
{
return OnboardingTaskCatalog::all();
}
/**
* @return array<string, string>
*/
public function latestEvidenceStatusByTaskType(): array
{
$tenant = Tenant::current();
$evidence = OnboardingEvidence::query()
->where('tenant_id', $tenant->getKey())
->whereIn('task_type', OnboardingTaskType::all())
->orderByDesc('recorded_at')
->get();
$byTask = [];
foreach ($evidence as $row) {
if (! isset($byTask[$row->task_type])) {
$byTask[$row->task_type] = $row->status;
}
}
return $byTask;
}
public function startVerifyPermissions(): void
{
if (! $this->canStartProviderTasks) {
abort(403);
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->session instanceof OnboardingSession) {
Notification::make()
->title('No onboarding session')
->danger()
->send();
return;
}
if (! $this->ensureLockForMutation()) {
return;
}
$connectionId = $this->session->provider_connection_id;
if (! is_int($connectionId)) {
Notification::make()
->title('Select a provider connection first')
->warning()
->send();
return;
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->whereKey($connectionId)
->first();
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('Selected provider connection not found')
->danger()
->send();
return;
}
if ($this->session->current_step < 4) {
$this->session->update(['current_step' => 4]);
$this->session->refresh();
}
$taskType = OnboardingTaskType::VerifyPermissions;
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$run = $runs->ensureRunWithIdentity(
tenant: $tenant,
type: $taskType,
identityInputs: [
'task_type' => $taskType,
],
context: [
'task_type' => $taskType,
'onboarding_session_id' => (int) $this->session->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
initiator: $user,
);
$this->verifyPermissionsRunUrl = OperationRunLinks::view($run, $tenant);
if ($run->wasRecentlyCreated) {
OnboardingVerifyPermissionsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
onboardingSessionId: (int) $this->session->getKey(),
operationRun: $run,
);
Notification::make()
->title('Verify permissions queued')
->body('Run queued. Use the link below to monitor progress.')
->success()
->send();
return;
}
Notification::make()
->title('Verify permissions already queued')
->body('A run is already queued or running. Use the link below to monitor progress.')
->warning()
->send();
}
public function startConsentStatus(): void
{
if (! $this->canStartProviderTasks) {
abort(403);
}
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $this->session instanceof OnboardingSession) {
return;
}
if (! $this->ensureLockForMutation()) {
return;
}
if ($this->session->current_step < 4) {
$this->session->update(['current_step' => 4]);
$this->session->refresh();
}
if (! OnboardingTaskCatalog::prerequisitesMet(
taskType: OnboardingTaskType::ConsentStatus,
latestEvidenceStatusByTaskType: $this->latestEvidenceStatusByTaskType(),
)) {
Notification::make()
->title('Prerequisites not met')
->body('Run “Verify permissions” first.')
->warning()
->send();
return;
}
$connectionId = $this->session->provider_connection_id;
if (! is_int($connectionId)) {
Notification::make()
->title('Select a provider connection first')
->warning()
->send();
return;
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->whereKey($connectionId)
->first();
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('Selected provider connection not found')
->danger()
->send();
return;
}
$taskType = OnboardingTaskType::ConsentStatus;
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$run = $runs->ensureRunWithIdentity(
tenant: $tenant,
type: $taskType,
identityInputs: [
'task_type' => $taskType,
],
context: [
'task_type' => $taskType,
'onboarding_session_id' => (int) $this->session->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
initiator: $user,
);
$this->consentStatusRunUrl = OperationRunLinks::view($run, $tenant);
if ($run->wasRecentlyCreated) {
OnboardingConsentStatusJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
onboardingSessionId: (int) $this->session->getKey(),
operationRun: $run,
);
Notification::make()
->title('Consent status queued')
->success()
->send();
return;
}
Notification::make()
->title('Consent status already queued')
->warning()
->send();
}
public function createProviderConnectionUrl(): string
{
return CreateProviderConnection::getUrl(tenant: Tenant::current());
}
}

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Operations;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\WorkspaceMembership;
use Filament\Actions\Action;
use Filament\Pages\Page;
class TenantlessOperationRunViewer extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Operation run';
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
public OperationRun $run;
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->color('gray')
->url(fn (): string => url()->current()),
];
}
public function mount(OperationRun $run): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = (int) ($run->workspace_id ?? 0);
if ($workspaceId <= 0) {
abort(404);
}
$isMember = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isMember) {
abort(404);
}
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
}
}

View File

@ -4,11 +4,9 @@
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;
@ -23,44 +21,14 @@ public static function getLabel(): string
public static function canView(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
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()) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
}
return false;
}
public function mount(): void
{
abort(404);
}
public function form(Schema $schema): Schema
{
return $schema
@ -111,12 +79,6 @@ 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,820 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Jobs\TenantOnboardingVerifyJob;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\TenantOnboardingAuditService;
use App\Services\TenantOnboardingSessionService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunStatus;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Illuminate\Database\QueryException;
use Illuminate\Support\Str;
class TenantOnboardingWizard extends Page implements HasForms
{
use InteractsWithForms;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'tenant-onboarding';
protected static ?string $title = 'Tenant onboarding';
protected string $view = 'filament.pages.tenant-onboarding-wizard';
/**
* @var array<string, mixed>
*/
public array $data = [];
public ?string $sessionId = null;
public ?int $tenantId = null;
public ?string $currentStep = null;
public ?int $verificationRunId = null;
public function mount(): void
{
$this->authorizeAccess();
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->resolveTenantFromRequest();
$sessionService = app(TenantOnboardingSessionService::class);
if (filled(request()->query('session'))) {
$session = $sessionService->resumeById($user, (string) request()->query('session'));
} else {
$session = $sessionService->startOrResume($user, $tenant);
}
$this->sessionId = (string) $session->getKey();
$this->tenantId = $session->tenant_id;
$this->currentStep = (string) $session->current_step;
$this->data = array_merge($this->data, $session->payload ?? []);
if ($tenant instanceof Tenant) {
$this->data = array_merge($this->data, [
'name' => $tenant->name,
'tenant_id' => $tenant->tenant_id,
'domain' => $tenant->domain,
'environment' => $tenant->environment,
'app_client_id' => $tenant->app_client_id,
'app_certificate_thumbprint' => $tenant->app_certificate_thumbprint,
'app_notes' => $tenant->app_notes,
]);
}
$this->form->fill($this->data);
}
public function enqueueVerification(): void
{
$this->authorizeAccess();
$this->requireCapability(Capabilities::PROVIDER_RUN);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->requireTenant();
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$run = $runs->ensureRunWithIdentity(
tenant: $tenant,
type: 'tenant.rbac.verify',
identityInputs: [
'purpose' => 'tenant_rbac_verify',
],
context: [
'operation' => [
'type' => 'tenant.rbac.verify',
],
'target_scope' => [
'tenant_id' => $tenant->getKey(),
'entra_tenant_id' => $tenant->tenant_id,
],
],
initiator: $user,
);
$this->verificationRunId = (int) $run->getKey();
if ($run->wasRecentlyCreated) {
$runs->dispatchOrFail($run, function (OperationRun $run) use ($tenant, $user): void {
TenantOnboardingVerifyJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
operationRun: $run,
);
});
Notification::make()->title('Verification queued')->success()->send();
return;
}
Notification::make()->title('Verification already in progress')->info()->send();
}
public function enqueueConnectionCheck(): void
{
$this->authorizeAccess();
$this->requireCapability(Capabilities::PROVIDER_RUN);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->requireTenant();
$connection = $this->ensureDefaultMicrosoftProviderConnection($tenant);
/** @var ProviderOperationStartGate $gate */
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $operationRun) use ($tenant, $user, $connection): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $operationRun,
);
},
initiator: $user,
);
if ($result->status === 'scope_busy') {
Notification::make()->title('Scope busy')->warning()->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()->title('Connection check already queued')->info()->send();
return;
}
Notification::make()->title('Connection check queued')->success()->send();
}
public function form(Schema $schema): Schema
{
return $schema
->statePath('data')
->components([
Wizard::make($this->getSteps())
->startOnStep(fn (): int => $this->getStartStep())
->submitAction('')
->cancelAction(''),
]);
}
/**
* @return array<int, Step>
*/
private function getSteps(): array
{
$steps = [
Step::make('Welcome')
->id('welcome')
->description('Requirements and what to expect.')
->schema([
\Filament\Forms\Components\Placeholder::make('welcome_copy')
->label('')
->content('This wizard will create or update a tenant record without making any outbound calls. You can resume at any time.'),
])
->afterValidation(fn (): mixed => $this->persistStep('tenant_details')),
Step::make('Tenant Details')
->id('tenant_details')
->description('Basic tenant metadata')
->schema([
TextInput::make('name')
->label('Display name')
->required()
->maxLength(255),
Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->rule('uuid')
->maxLength(255),
TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
])
->afterValidation(fn (): mixed => $this->handleTenantDetailsCompleted()),
];
$steps = array_merge($steps, $this->credentialsRequired()
? [$this->credentialsStep()]
: []);
$steps[] = Step::make('Admin Consent & Permissions')
->id('permissions')
->description('Grant permissions and verify access')
->schema([
\Filament\Forms\Components\Placeholder::make('permissions_copy')
->label('')
->content('Next, you will grant admin consent and verify permissions. (Verification runs are implemented in the next phase.)'),
])
->afterValidation(fn (): mixed => $this->persistStep('verification'));
$steps[] = Step::make('Verification / First Run')
->id('verification')
->description('Finish setup and validate readiness')
->schema([
\Filament\Forms\Components\Placeholder::make('verification_copy')
->label('')
->content('Verification checks are enqueue-only and will appear here once implemented.'),
])
->afterValidation(fn (): mixed => $this->persistStep('verification'));
return $steps;
}
private function credentialsStep(): Step
{
return Step::make('App / Credentials')
->id('credentials')
->description('Set credentials (if required)')
->schema([
TextInput::make('app_client_id')
->label('App Client ID')
->required()
->maxLength(255),
TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->required()
->maxLength(255),
TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Textarea::make('app_notes')
->label('Notes')
->rows(3),
Checkbox::make('acknowledge_credentials')
->label('I understand this will store credentials encrypted and they cannot be shown again.')
->accepted()
->required(),
])
->afterValidation(fn (): mixed => $this->handleCredentialsCompleted());
}
private function handleTenantDetailsCompleted(): void
{
$this->authorizeAccess();
$this->requireCapability(Capabilities::TENANT_MANAGE);
$tenant = $this->upsertTenantFromData();
$this->ensureDefaultMicrosoftProviderConnection($tenant);
$this->tenantId = (int) $tenant->getKey();
$nextStep = $this->credentialsRequired() ? 'credentials' : 'permissions';
$this->persistStep($nextStep, $tenant);
Notification::make()->title('Tenant details saved')->success()->send();
}
private function handleCredentialsCompleted(): void
{
$this->authorizeAccess();
$this->requireCapability(Capabilities::TENANT_MANAGE);
$tenant = $this->requireTenant();
$secret = (string) ($this->data['app_client_secret'] ?? '');
$tenant->forceFill([
'app_client_id' => $this->data['app_client_id'] ?? null,
'app_certificate_thumbprint' => $this->data['app_certificate_thumbprint'] ?? null,
'app_notes' => $this->data['app_notes'] ?? null,
]);
if (filled($secret)) {
$tenant->forceFill(['app_client_secret' => $secret]);
}
$tenant->save();
if (filled($secret) && filled($tenant->app_client_id) && filled($tenant->tenant_id)) {
$connection = $this->ensureDefaultMicrosoftProviderConnection($tenant);
app(CredentialManager::class)->upsertClientSecretCredential(
connection: $connection,
clientId: (string) $tenant->app_client_id,
clientSecret: (string) $secret,
);
}
if (filled($secret)) {
$actor = auth()->user();
app(TenantOnboardingAuditService::class)->credentialsUpdated(
tenant: $tenant,
actor: $actor instanceof User ? $actor : null,
context: [
'app_client_id_set' => filled($tenant->app_client_id),
'app_client_secret_set' => true,
],
);
}
$this->data['app_client_secret'] = null;
$this->form->fill($this->data);
$this->persistStep('permissions', $tenant);
Notification::make()->title('Credentials saved')->success()->send();
}
private function persistStep(string $currentStep, ?Tenant $tenant = null): void
{
$this->authorizeAccess();
$this->requireCapability(Capabilities::TENANT_MANAGE);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$session = $this->requireSession();
$service = app(TenantOnboardingSessionService::class);
$updated = $service->persistProgress(
session: $session,
currentStep: $currentStep,
payload: $this->data,
tenant: $tenant,
);
$this->sessionId = (string) $updated->getKey();
$this->tenantId = $updated->tenant_id;
$this->currentStep = (string) $updated->current_step;
}
/**
* DB-only: uses config + stored tenant_permissions.
*
* @return array<int, array{key:string,type:string,description:?string,features:array<int,string>,status:string}>
*/
public function permissionRows(): array
{
if (! $this->tenantId) {
return [];
}
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
if (! $tenant instanceof Tenant) {
return [];
}
$required = config('intune_permissions.permissions', []);
$required = is_array($required) ? $required : [];
$granted = TenantPermission::query()
->where('tenant_id', $tenant->getKey())
->get()
->keyBy('permission_key');
$rows = [];
foreach ($required as $permission) {
if (! is_array($permission)) {
continue;
}
$key = (string) ($permission['key'] ?? '');
if ($key === '') {
continue;
}
$stored = $granted->get($key);
$status = $stored instanceof TenantPermission
? (string) $stored->status
: 'missing';
$rows[] = [
'key' => $key,
'type' => (string) ($permission['type'] ?? 'application'),
'description' => isset($permission['description']) && is_string($permission['description']) ? $permission['description'] : null,
'features' => is_array($permission['features'] ?? null) ? $permission['features'] : [],
'status' => $status,
];
}
return $rows;
}
public function latestVerificationRunStatus(): ?string
{
if (! $this->tenantId) {
return null;
}
$run = OperationRun::query()
->where('tenant_id', $this->tenantId)
->where('type', 'tenant.rbac.verify')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
return (string) $run->status;
}
public function latestConnectionCheckRunStatus(): ?string
{
if (! $this->tenantId) {
return null;
}
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
if (! $tenant instanceof Tenant) {
return null;
}
$connection = $tenant->providerConnections()
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenant->tenant_id)
->orderByDesc('is_default')
->orderByDesc('id')
->first();
if (! $connection instanceof ProviderConnection) {
return null;
}
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->where('context->provider_connection_id', (int) $connection->getKey())
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
return (string) $run->status;
}
public function isReadyToCompleteOnboarding(): bool
{
if (! $this->tenantId) {
return false;
}
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
if (! $tenant instanceof Tenant) {
return false;
}
$connection = $tenant->providerConnections()
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenant->tenant_id)
->orderByDesc('is_default')
->orderByDesc('id')
->first();
$connectionOk = $connection instanceof ProviderConnection
&& (string) $connection->health_status === 'ok'
&& (string) $connection->status === 'connected';
$permissionsOk = collect($this->permissionRows())
->every(fn (array $row): bool => (string) ($row['status'] ?? 'missing') === 'granted');
$verifyRunOk = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'tenant.rbac.verify')
->where('status', OperationRunStatus::Completed->value)
->where('outcome', 'succeeded')
->exists();
return $connectionOk && $permissionsOk && $verifyRunOk;
}
private function ensureDefaultMicrosoftProviderConnection(Tenant $tenant): ProviderConnection
{
$existing = $tenant->providerConnections()
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenant->tenant_id)
->orderByDesc('is_default')
->orderByDesc('id')
->first();
if ($existing instanceof ProviderConnection) {
if (! $existing->is_default) {
$existing->makeDefault();
}
return $existing;
}
return ProviderConnection::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Microsoft Graph',
'is_default' => true,
'status' => 'needs_consent',
'health_status' => 'unknown',
'scopes_granted' => [],
'metadata' => [],
]);
}
private function upsertTenantFromData(): Tenant
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenantGuid = Str::lower((string) ($this->data['tenant_id'] ?? ''));
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->first();
$isNewTenant = ! $tenant instanceof Tenant;
if (! $tenant instanceof Tenant) {
$tenant = new Tenant();
$tenant->forceFill([
'status' => 'active',
]);
}
$tenant->forceFill([
'name' => $this->data['name'] ?? null,
'tenant_id' => $tenantGuid,
'domain' => $this->data['domain'] ?? null,
'environment' => $this->data['environment'] ?? 'other',
'onboarding_status' => 'in_progress',
'onboarding_completed_at' => null,
]);
try {
$tenant->save();
} catch (QueryException $exception) {
throw $exception;
}
$alreadyMember = $user->tenants()->whereKey($tenant->getKey())->exists();
if (! $alreadyMember) {
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => [
'role' => 'owner',
'source' => 'manual',
'created_by_user_id' => $user->getKey(),
],
]);
}
if ($isNewTenant && ! $alreadyMember) {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'tenant_membership.bootstrap_assign',
context: [
'metadata' => [
'user_id' => (int) $user->getKey(),
'role' => 'owner',
'source' => 'manual',
],
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
return $tenant;
}
private function authorizeAccess(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->resolveTenantFromRequest();
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if ($tenant instanceof Tenant) {
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
abort(403);
}
return;
}
// For creating a new tenant (not yet in scope), require that the user can manage at least one tenant.
$canManageAny = $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
if (! $canManageAny) {
abort(403);
}
}
private function resolveTenantFromRequest(): ?Tenant
{
$tenantExternalId = request()->query('tenant');
if (! is_string($tenantExternalId) || blank($tenantExternalId)) {
return null;
}
return Tenant::query()
->where('external_id', $tenantExternalId)
->where('status', 'active')
->first();
}
private function credentialsRequired(): bool
{
return (bool) config('tenantpilot.onboarding.credentials_required', false);
}
private function getStartStep(): int
{
$session = $this->requireSession();
$keys = $this->getStepKeys();
$index = array_search((string) $session->current_step, $keys, true);
if ($index === false) {
return 1;
}
return $index + 1;
}
/**
* @return array<int, string>
*/
private function getStepKeys(): array
{
$keys = ['welcome', 'tenant_details'];
if ($this->credentialsRequired()) {
$keys[] = 'credentials';
}
$keys[] = 'permissions';
$keys[] = 'verification';
return $keys;
}
private function requireSession(): TenantOnboardingSession
{
if (! filled($this->sessionId)) {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->sessionId = (string) app(TenantOnboardingSessionService::class)->startOrResume($user)->getKey();
}
return TenantOnboardingSession::query()->whereKey($this->sessionId)->firstOrFail();
}
private function requireTenant(): Tenant
{
if (! $this->tenantId) {
abort(400, 'Tenant not initialized');
}
return Tenant::query()->whereKey($this->tenantId)->firstOrFail();
}
public function tenantHasClientSecret(): bool
{
if (! $this->tenantId) {
return false;
}
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
return $tenant instanceof Tenant && filled($tenant->getRawOriginal('app_client_secret'));
}
public function canRunProviderOperations(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = $this->resolveTenantForAuthorization();
return $tenant instanceof Tenant
&& app(CapabilityResolver::class)->can($user, $tenant, Capabilities::PROVIDER_RUN);
}
private function requireCapability(string $capability): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = $this->resolveTenantForAuthorization();
if (! $tenant instanceof Tenant || ! app(CapabilityResolver::class)->can($user, $tenant, $capability)) {
abort(403);
}
}
private function resolveTenantForAuthorization(): ?Tenant
{
if ($this->tenantId) {
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
if ($tenant instanceof Tenant) {
return $tenant;
}
}
return $this->resolveTenantFromRequest();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +0,0 @@
<?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::currentOrFail()->getKey();
$tenantId = Tenant::current()->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::currentOrFail()->getKey();
$data['tenant_id'] = Tenant::current()->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::currentOrFail()->getKey())->with('backupSet'))
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')

View File

@ -3,7 +3,6 @@
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;
@ -137,35 +136,12 @@ 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(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;
})
->state(fn (OperationRun $record): array => $record->context ?? [])
->columnSpanFull(),
])
->columnSpanFull(),

View File

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

View File

@ -5,6 +5,7 @@
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;
@ -14,13 +15,11 @@
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;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
@ -100,16 +99,9 @@ public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(function (Builder $query): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantId = Tenant::current()?->getKey();
if ($workspaceId === null) {
return $query->whereRaw('1 = 0');
}
return $query
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->defaultSort('display_name')
->columns([
@ -183,22 +175,29 @@ 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, StartVerification $verification): void {
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
if (! $user instanceof User) {
abort(403);
}
$initiator = $user;
$result = $verification->providerConnectionCheck(
$result = $gate->start(
tenant: $tenant,
connection: $record,
initiator: $user,
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,
);
if ($result->status === 'scope_busy') {
@ -641,17 +640,9 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantId = Tenant::current()?->getKey();
$query = parent::getEloquentQuery();
if ($workspaceId === null) {
return $query->whereRaw('1 = 0');
}
return $query
->where('workspace_id', (int) $workspaceId)
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('id');
}

View File

@ -22,7 +22,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
return [
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'],

View File

@ -2,8 +2,10 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
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;
@ -13,7 +15,6 @@
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;
@ -116,6 +117,18 @@ protected function getHeaderActions(): array
->visible(false),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Action::make('resume_onboarding')
->label('Resume onboarding')
->icon('heroicon-o-play')
->color('gray')
->url(fn (): string => TenantOnboardingWizard::getUrl(tenant: Tenant::current()))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->tooltip('You do not have permission to view provider onboarding.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('view_last_check_run')
->label('View last check run')
@ -167,7 +180,7 @@ protected function getHeaderActions(): array
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, StartVerification $verification): void {
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
$user = auth()->user();
@ -185,9 +198,18 @@ protected function getHeaderActions(): array
$initiator = $user;
$result = $verification->providerConnectionCheck(
$result = $gate->start(
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,
);

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::currentOrFail()->getKey();
$tenantId = Tenant::current()->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::currentOrFail()->getKey();
$tenantId = Tenant::current()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToWorkspace;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController;
@ -9,9 +10,10 @@
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
@ -55,6 +57,8 @@
class TenantResource extends Resource
{
use ScopesGlobalSearchToWorkspace;
// ... [Properties Omitted for Brevity] ...
protected static ?string $model = Tenant::class;
@ -62,7 +66,9 @@ class TenantResource extends Resource
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
protected static ?string $navigationLabel = 'Managed tenants';
public static function canCreate(): bool
{
@ -72,21 +78,7 @@ public static function canCreate(): bool
return false;
}
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();
return static::userCanManageTenantsInCurrentWorkspace($user);
}
public static function canEdit(Model $record): bool
@ -97,11 +89,12 @@ public static function canEdit(Model $record): bool
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$workspace = static::resolveCurrentWorkspaceFor($user);
return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
&& $workspace instanceof Workspace
&& (int) $record->workspace_id === (int) $workspace->getKey()
&& static::userCanManageTenantsInCurrentWorkspace($user);
}
public static function canDelete(Model $record): bool
@ -112,11 +105,12 @@ public static function canDelete(Model $record): bool
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$workspace = static::resolveCurrentWorkspaceFor($user);
return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
&& $workspace instanceof Workspace
&& (int) $record->workspace_id === (int) $workspace->getKey()
&& static::userCanDeleteTenantsInCurrentWorkspace($user);
}
public static function canDeleteAny(): bool
@ -127,21 +121,49 @@ public static function canDeleteAny(): bool
return false;
}
return static::userCanDeleteAnyTenant($user);
return static::userCanDeleteTenantsInCurrentWorkspace($user);
}
private static function userCanManageAnyTenant(User $user): bool
private static function userCanDeleteTenantsInCurrentWorkspace(User $user): bool
{
return $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
$workspace = static::resolveCurrentWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
}
private static function userCanDeleteAnyTenant(User $user): bool
private static function userCanManageTenantsInCurrentWorkspace(User $user): bool
{
return $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
$workspace = static::resolveCurrentWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
}
private static function resolveCurrentWorkspaceFor(User $user): ?Workspace
{
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user);
if (! $workspace instanceof Workspace) {
return null;
}
return $context->isMember($user, $workspace) ? $workspace : null;
}
public static function form(Schema $schema): Schema
@ -188,27 +210,21 @@ public static function form(Schema $schema): Schema
public static function getEloquentQuery(): Builder
{
// ... [Query Omitted - No Change] ...
$user = auth()->user();
if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$workspace = static::resolveCurrentWorkspaceFor($user);
if ($workspaceId === null) {
if (! $workspace instanceof Workspace) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->pluck('tenants.id');
return parent::getEloquentQuery()
->withTrashed()
->whereIn('id', $tenantIds)
->where('workspace_id', (int) $workspace->getKey())
->withCount('policies')
->withMax('policies as last_policy_sync_at', 'last_synced_at');
}
@ -283,6 +299,11 @@ public static function table(Table $table): Table
])
->actions([
ActionGroup::make([
Actions\Action::make('open')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW),
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
@ -413,7 +434,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
@ -433,7 +454,7 @@ public static function table(Table $table): Table
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_RESTORE)) {
abort(403);
}
@ -450,7 +471,7 @@ public static function table(Table $table): Table
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('admin_consent')
@ -520,7 +541,7 @@ public static function table(Table $table): Table
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)) {
abort(403);
}
@ -543,7 +564,7 @@ public static function table(Table $table): Table
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
@ -566,7 +587,7 @@ public static function table(Table $table): Table
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)) {
abort(403);
}
@ -599,7 +620,7 @@ public static function table(Table $table): Table
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
->apply(),
]),
])
@ -896,8 +917,12 @@ public static function rbacAction(): Actions\Action
->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'),
])
->visible(fn (Tenant $record): bool => $record->isActive())
->disabled(function (Tenant $record): bool {
->visible(fn (?Tenant $record): bool => (bool) $record?->isActive())
->disabled(function (?Tenant $record): bool {
if ($record === null) {
return true;
}
$user = auth()->user();
if (! $user instanceof User) {

View File

@ -2,8 +2,10 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
@ -12,17 +14,38 @@ class CreateTenant extends CreateRecord
protected static string $resource = TenantResource::class;
/**
* Prevent setting legacy tenant credentials during create.
* Credential setup should happen via the onboarding flow.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
unset(
$data['app_client_id'],
$data['app_client_secret'],
$data['app_certificate_thumbprint'],
$data['app_notes'],
);
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
$workspace = $context->resolveInitialWorkspaceFor($user, request());
if (! $workspace instanceof Workspace) {
abort(403);
}
$data['workspace_id'] = (int) $workspace->getKey();
return $data;
}
@ -38,4 +61,9 @@ protected function afterCreate(): void
$this->record->getKey() => ['role' => 'owner'],
]);
}
protected function getRedirectUrl(): string
{
return TenantOnboardingWizard::getUrl(tenant: $this->record);
}
}

View File

@ -28,8 +28,8 @@ protected function getHeaderActions(): array
$record->delete();
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to archive tenants.')
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
->tooltip('You do not have permission to archive managed tenants.')
->preserveVisibility()
->destructive()
->apply(),

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -10,12 +11,26 @@ class ListTenants extends ListRecords
{
protected static string $resource = TenantResource::class;
public function mount(): void
{
parent::mount();
$user = auth()->user();
if ($user instanceof User && ! $user->tenantMemberships()->exists()) {
abort(404);
}
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
Actions\Action::make('add_managed_tenant')
->label('Add managed tenant')
->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding')
->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to add managed tenants.'),
];
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantResource\Pages;
use Filament\Pages\Page;
class OnboardingManagedTenant extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Onboard managed tenant';
protected string $view = 'filament.pages.onboarding-managed-tenant';
public function mount(): void
{
$this->redirect('/admin/tenants/create');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Models\Tenant;
@ -30,6 +31,25 @@ protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('open_managed_tenant')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('resume_onboarding')
->label('Resume onboarding')
->icon('heroicon-o-play')
->color('gray')
->url(fn (Tenant $record): string => TenantOnboardingWizard::getUrl(tenant: $record))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->tooltip('You do not have permission to view provider onboarding.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')

View File

@ -25,22 +25,11 @@ public function table(Table $table): Table
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.email')
Tables\Columns\TextColumn::make('user.name')
->label(__('User'))
->searchable(),
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'))
Tables\Columns\TextColumn::make('user.email')
->label(__('Email'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
@ -60,13 +49,7 @@ public function table(Table $table): Table
->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()),
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()

View File

@ -3,33 +3,9 @@
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

@ -3,9 +3,19 @@
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditWorkspace extends EditRecord
{
protected static string $resource = WorkspaceResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
}

View File

@ -3,7 +3,7 @@
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWorkspaces extends ListRecords
@ -13,7 +13,7 @@ class ListWorkspaces extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
CreateAction::make(),
];
}
}

View File

@ -3,17 +3,27 @@
namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ViewWorkspace extends ViewRecord
{
protected static string $resource = WorkspaceResource::class;
public function mount(int|string $record): void
{
try {
parent::mount($record);
} catch (ModelNotFoundException) {
abort(404);
}
}
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
EditAction::make(),
];
}
}

View File

@ -0,0 +1,209 @@
<?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\Rbac\UiEnforcement;
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 MembershipsRelationManager extends RelationManager
{
protected static string $relationship = 'memberships';
public function table(Table $table): Table
{
$workspaceRecord = fn () => $this->getOwnerRecord();
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([
Tables\Columns\TextColumn::make('user.name')
->label(__('User'))
->searchable(),
Tables\Columns\TextColumn::make('user.email')
->label(__('Email'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([
//
])
->headerActions([
UiEnforcement::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('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('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();
}),
$workspaceRecord,
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->apply(),
])
->recordActions([
UiEnforcement::forTableAction(
Action::make('change_role')
->label(__('Change role'))
->icon('heroicon-o-pencil')
->requiresConfirmation()
->form([
Forms\Components\Select::make('role')
->label(__('Role'))
->required()
->options([
'owner' => __('Owner'),
'manager' => __('Manager'),
'operator' => __('Operator'),
'readonly' => __('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();
}),
$workspaceRecord,
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->apply(),
UiEnforcement::forTableAction(
Action::make('remove')
->label(__('Remove'))
->color('danger')
->icon('heroicon-o-x-mark')
->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();
}),
$workspaceRecord,
)
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
->destructive()
->apply(),
])
->toolbarActions([])
->bulkActions([]);
}
}

View File

@ -1,221 +0,0 @@
<?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,19 @@
<?php
namespace App\Filament\Resources\Workspaces\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WorkspaceForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required(),
TextInput::make('slug'),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\Workspaces\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class WorkspaceInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('name'),
TextEntry::make('slug')
->placeholder('-'),
TextEntry::make('created_at')
->dateTime()
->placeholder('-'),
TextEntry::make('updated_at')
->dateTime()
->placeholder('-'),
]);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Filament\Resources\Workspaces\Tables;
use App\Models\Workspace;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class WorkspacesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
EditAction::make(),
UiEnforcement::forAction(
Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box')
->visible(fn (Workspace $record): bool => empty($record->archived_at))
->action(function (Workspace $record): void {
$record->forceFill(['archived_at' => now()])->save();
Notification::make()
->title('Workspace archived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::WORKSPACE_MANAGE)
->destructive()
->tooltip('You do not have permission to archive this workspace.')
->apply(),
UiEnforcement::forAction(
Action::make('unarchive')
->label('Unarchive')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->visible(fn (Workspace $record): bool => ! empty($record->archived_at))
->action(function (Workspace $record): void {
$record->forceFill(['archived_at' => null])->save();
Notification::make()
->title('Workspace unarchived')
->success()
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::WORKSPACE_MANAGE)
->tooltip('You do not have permission to unarchive this workspace.')
->apply(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@ -2,78 +2,78 @@
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
use App\Filament\Resources\Workspaces\Pages\CreateWorkspace;
use App\Filament\Resources\Workspaces\Pages\EditWorkspace;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Filament\Resources\Workspaces\RelationManagers\MembershipsRelationManager;
use App\Filament\Resources\Workspaces\Schemas\WorkspaceForm;
use App\Filament\Resources\Workspaces\Schemas\WorkspaceInfolist;
use App\Filament\Resources\Workspaces\Tables\WorkspacesTable;
use App\Models\Workspace;
use App\Models\User;
use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
use Illuminate\Database\Eloquent\Builder;
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';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
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),
]);
return WorkspaceForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return WorkspaceInfolist::configure($schema);
}
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(),
]);
return WorkspacesTable::configure($table);
}
public static function getPages(): array
public static function getEloquentQuery(): Builder
{
return [
'index' => Pages\ListWorkspaces::route('/'),
'create' => Pages\CreateWorkspace::route('/create'),
'view' => Pages\ViewWorkspace::route('/{record}'),
'edit' => Pages\EditWorkspace::route('/{record}/edit'),
];
$user = auth()->user();
if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$workspaceIds = $user->newQuery()
->join('workspace_memberships', 'users.id', '=', 'workspace_memberships.user_id')
->where('users.id', $user->getKey())
->pluck('workspace_memberships.workspace_id');
return parent::getEloquentQuery()->whereIn('id', $workspaceIds);
}
public static function getRelations(): array
{
return [
WorkspaceMembershipsRelationManager::class,
MembershipsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListWorkspaces::route('/'),
'create' => CreateWorkspace::route('/create'),
'view' => ViewWorkspace::route('/{record}'),
'edit' => EditWorkspace::route('/{record}/edit'),
];
}
}

View File

@ -1,44 +0,0 @@
<?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

@ -1,169 +0,0 @@
<?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

@ -1,72 +0,0 @@
<?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

@ -1,67 +0,0 @@
<?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.onboarding');
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
}
return redirect()->to(ChooseTenant::getUrl());
}
}

View File

@ -32,19 +32,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/livewire/update') {
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
return $next($request);
}
}
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
return $next($request);
}
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
return $next($request);
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Onboarding;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Onboarding\OnboardingEvidenceWriter;
use App\Services\OperationRunService;
use App\Support\Onboarding\OnboardingTaskType;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class OnboardingConnectionDiagnosticsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
public int $onboardingSessionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$session = OnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->find($this->onboardingSessionId);
if (! $session instanceof OnboardingSession) {
throw new RuntimeException('OnboardingSession not found.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
$status = (string) ($connection->status ?? 'unknown');
$health = (string) ($connection->health_status ?? 'unknown');
$evidenceStatus = 'unknown';
$reasonCode = null;
$message = 'No health check data available yet.';
if ($status !== 'connected') {
$evidenceStatus = 'blocked';
$reasonCode = 'provider.needs_consent';
$message = 'Provider connection is not connected. Admin consent may be required.';
} elseif ($health === 'healthy') {
$evidenceStatus = 'ok';
$message = 'Provider connection appears healthy.';
} elseif ($health === 'unhealthy') {
$evidenceStatus = 'error';
$reasonCode = is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : 'provider.outage';
$message = is_string($connection->last_error_message) && trim($connection->last_error_message) !== ''
? $connection->last_error_message
: 'Provider connection health check indicates an error.';
}
$evidence->record(
tenant: $tenant,
taskType: OnboardingTaskType::ConnectionDiagnostics,
status: $evidenceStatus,
reasonCode: $reasonCode,
message: $message,
payload: [
'status' => $status,
'health_status' => $health,
'last_health_check_at' => $connection->last_health_check_at?->toIso8601String(),
'last_error_reason_code' => $connection->last_error_reason_code,
],
session: $session,
providerConnection: $connection,
operationRun: $this->operationRun,
recordedBy: $user,
);
if (! $this->operationRun instanceof OperationRun) {
return;
}
if ($evidenceStatus === 'ok') {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'onboarding.connection.diagnostics.failed',
'reason_code' => $reasonCode ?? 'connection.diagnostics.unknown',
'message' => $message,
]],
);
}
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Onboarding;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Onboarding\OnboardingEvidenceWriter;
use App\Services\OperationRunService;
use App\Support\Onboarding\OnboardingTaskType;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class OnboardingConsentStatusJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
public int $onboardingSessionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
OnboardingEvidenceWriter $evidence,
OperationRunService $runs,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$session = OnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->find($this->onboardingSessionId);
if (! $session instanceof OnboardingSession) {
throw new RuntimeException('OnboardingSession not found.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
$status = (string) ($connection->status ?? 'unknown');
$evidenceStatus = match ($status) {
'connected' => 'ok',
'needs_consent' => 'blocked',
default => 'error',
};
$message = match ($status) {
'connected' => 'Consent appears granted (connection is connected).',
'needs_consent' => 'Consent is missing or credentials are not authorized yet.',
default => 'Unable to determine consent status.',
};
$evidence->record(
tenant: $tenant,
taskType: OnboardingTaskType::ConsentStatus,
status: $evidenceStatus,
reasonCode: $status === 'needs_consent' ? 'consent.missing' : null,
message: $message,
payload: [
'provider_connection_status' => $status,
'provider_connection_health_status' => $connection->health_status,
],
session: $session,
providerConnection: $connection,
operationRun: $this->operationRun,
recordedBy: $user,
);
if (! $this->operationRun instanceof OperationRun) {
return;
}
if ($evidenceStatus === 'ok') {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'onboarding.consent.status.failed',
'reason_code' => $status === 'needs_consent' ? 'consent.missing' : 'consent.status.error',
'message' => $message,
]],
);
}
}

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Onboarding;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Onboarding\OnboardingEvidenceWriter;
use App\Services\OperationRunService;
use App\Support\Onboarding\OnboardingTaskType;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class OnboardingInitialSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
public int $onboardingSessionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void
{
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$session = OnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->find($this->onboardingSessionId);
if (! $session instanceof OnboardingSession) {
throw new RuntimeException('OnboardingSession not found.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
$connected = (string) ($connection->status ?? 'unknown') === 'connected';
$evidenceStatus = $connected ? 'ok' : 'blocked';
$reasonCode = $connected ? null : 'provider.not_connected';
$message = $connected
? 'Prerequisites for initial sync look good.'
: 'Provider connection is not connected. Resolve consent/credentials first.';
$evidence->record(
tenant: $tenant,
taskType: OnboardingTaskType::InitialSync,
status: $evidenceStatus,
reasonCode: $reasonCode,
message: $message,
payload: [
'provider_connection_status' => $connection->status,
],
session: $session,
providerConnection: $connection,
operationRun: $this->operationRun,
recordedBy: $user,
);
if (! $this->operationRun instanceof OperationRun) {
return;
}
if ($evidenceStatus === 'ok') {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'onboarding.initial_sync.blocked',
'reason_code' => $reasonCode ?? 'initial_sync.unknown',
'message' => $message,
]],
);
}
}

View File

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Onboarding;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\TenantPermissionService;
use App\Services\Onboarding\OnboardingEvidenceWriter;
use App\Services\OperationRunService;
use App\Support\Onboarding\OnboardingTaskType;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class OnboardingVerifyPermissionsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
public int $onboardingSessionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
TenantPermissionService $permissions,
OnboardingEvidenceWriter $evidence,
OperationRunService $runs,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$session = OnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->find($this->onboardingSessionId);
if (! $session instanceof OnboardingSession) {
throw new RuntimeException('OnboardingSession not found.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
// For onboarding, we default to a safe, non-live permission comparison.
// Live Graph calls can be enabled later as a deliberate UX and contract decision.
$result = $permissions->compare($tenant, persist: true, liveCheck: false, useConfiguredStub: true);
$overall = $result['overall_status'] ?? 'error';
$evidenceStatus = match ($overall) {
'granted' => 'ok',
'missing' => 'blocked',
default => 'error',
};
$message = match ($overall) {
'granted' => 'All required permissions appear granted.',
'missing' => 'Some required permissions are missing.',
default => 'Unable to verify permissions.',
};
$evidence->record(
tenant: $tenant,
taskType: OnboardingTaskType::VerifyPermissions,
status: $evidenceStatus,
reasonCode: $overall === 'missing' ? 'permissions.missing' : null,
message: $message,
payload: [
'overall_status' => $overall,
'permissions' => $result['permissions'] ?? [],
],
session: $session,
providerConnection: $connection,
operationRun: $this->operationRun,
recordedBy: $user,
);
if (! $this->operationRun instanceof OperationRun) {
return;
}
if ($evidenceStatus === 'ok') {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'onboarding.permissions.verify.failed',
'reason_code' => $overall === 'missing' ? 'permissions.missing' : 'permissions.verify.error',
'message' => $message,
]],
);
}
}

View File

@ -7,14 +7,11 @@
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;
@ -86,64 +83,17 @@ 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) {
$run = $runs->updateRun(
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
return;
}
$run = $runs->updateRun(
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
@ -153,8 +103,6 @@ public function handle(
'message' => $result->message ?? 'Health check failed.',
]],
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
}
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
@ -197,34 +145,4 @@ 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

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Services\TenantOnboardingAuditService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class TenantOnboardingVerifyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
TenantPermissionService $permissions,
OperationRunService $runs,
TenantOnboardingAuditService $audit,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$user = User::query()->find($this->userId);
if (! $user instanceof User) {
throw new RuntimeException('User not found.');
}
$result = $permissions->compare(
tenant: $tenant,
grantedStatuses: null,
persist: true,
liveCheck: true,
useConfiguredStub: false,
);
$overall = (string) ($result['overall_status'] ?? 'error');
$tenant->forceFill([
'rbac_last_checked_at' => now(),
'rbac_last_warnings' => $overall === 'granted' ? [] : ['permissions_not_granted'],
])->save();
if (! $this->operationRun instanceof OperationRun) {
return;
}
if ($overall === 'granted') {
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$tenant->forceFill([
'onboarding_status' => 'completed',
'onboarding_completed_at' => now(),
])->save();
TenantOnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->where('status', 'active')
->update([
'status' => 'completed',
'current_step' => 'verification',
'completed_at' => now(),
]);
$audit->onboardingCompleted(
tenant: $tenant,
actor: $user,
context: [
'operation_run_id' => (int) $this->operationRun->getKey(),
],
);
return;
}
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'tenant.rbac.verify.not_granted',
'message' => 'Permissions are missing or could not be verified.',
]],
);
}
}

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::currentOrFail()->getKey();
$tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey();
$existingPolicyIds = $backupSet
? $backupSet->items()->pluck('policy_id')->filter()->all()
: [];

View File

@ -21,4 +21,9 @@ public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OnboardingEvidence extends Model
{
use HasFactory;
protected $table = 'onboarding_evidence';
protected $guarded = [];
protected $casts = [
'payload' => 'array',
'recorded_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function onboardingSession(): BelongsTo
{
return $this->belongsTo(OnboardingSession::class, 'onboarding_session_id');
}
public function providerConnection(): BelongsTo
{
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
}
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class, 'operation_run_id');
}
public function recordedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by_user_id');
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class OnboardingSession extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'current_step' => 'integer',
'locked_until' => 'datetime',
'completed_at' => 'datetime',
'metadata' => 'array',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function providerConnection(): BelongsTo
{
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
}
public function assignedTo(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to_user_id');
}
public function lockedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'locked_by_user_id');
}
public function evidence(): HasMany
{
return $this->hasMany(OnboardingEvidence::class, 'onboarding_session_id');
}
}

View File

@ -21,41 +21,11 @@ class OperationRun extends Model
'completed_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (self $operationRun): void {
if ($operationRun->workspace_id !== null) {
return;
}
if ($operationRun->tenant_id === null) {
return;
}
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
if (! $tenant instanceof Tenant) {
return;
}
if ($tenant->workspace_id === null) {
return;
}
$operationRun->workspace_id = (int) $tenant->workspace_id;
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -26,11 +26,6 @@ public function tenant(): BelongsTo
return $this->belongsTo(Tenant::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function credential(): HasOne
{
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');

View File

@ -21,20 +21,13 @@ class Tenant extends Model implements HasName
use HasFactory;
use SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_ONBOARDING = 'onboarding';
public const STATUS_ACTIVE = 'active';
public const STATUS_ARCHIVED = 'archived';
protected $guarded = [];
protected $casts = [
'metadata' => 'array',
'app_client_secret' => 'encrypted',
'is_current' => 'boolean',
'onboarding_completed_at' => 'datetime',
'rbac_last_checked_at' => 'datetime',
'rbac_last_setup_at' => 'datetime',
'rbac_canary_results' => 'array',
@ -77,16 +70,7 @@ protected static function booted(): void
}
if (empty($tenant->status)) {
$tenant->status = self::STATUS_ACTIVE;
}
if ($tenant->workspace_id === null && app()->runningUnitTests()) {
$workspace = Workspace::query()->create([
'name' => 'Test Workspace',
'slug' => 'test-'.Str::lower(Str::random(10)),
]);
$tenant->workspace_id = (int) $workspace->getKey();
$tenant->status = 'active';
}
});
@ -101,12 +85,12 @@ protected static function booted(): void
return;
}
$tenant->status = self::STATUS_ARCHIVED;
$tenant->status = 'archived';
$tenant->saveQuietly();
});
static::restored(function (Tenant $tenant) {
$tenant->forceFill(['status' => self::STATUS_ACTIVE])->saveQuietly();
$tenant->forceFill(['status' => 'active'])->saveQuietly();
});
}
@ -114,12 +98,12 @@ public static function activeQuery(): Builder
{
return static::query()
->whereNull('deleted_at')
->where('status', self::STATUS_ACTIVE);
->where('status', 'active');
}
public function makeCurrent(): void
{
if ($this->trashed() || $this->status !== self::STATUS_ACTIVE) {
if ($this->trashed() || $this->status !== 'active') {
throw new RuntimeException('Only active tenants can be made current.');
}
@ -134,7 +118,7 @@ public function makeCurrent(): void
$this->forceFill(['is_current' => true]);
}
public static function current(): ?self
public static function current(): self
{
$filamentTenant = Filament::getTenant();
@ -163,13 +147,6 @@ 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.');
}
@ -200,6 +177,11 @@ public function workspace(): BelongsTo
return $this->belongsTo(Workspace::class);
}
public function onboardingSessions(): HasMany
{
return $this->hasMany(TenantOnboardingSession::class);
}
public function roleMappings(): HasMany
{
return $this->hasMany(TenantRoleMapping::class);

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -11,81 +12,29 @@ class TenantOnboardingSession extends Model
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
use HasFactory;
protected $table = 'managed_tenant_onboarding_sessions';
use HasUuids;
/**
* @var array<int, string>
*/
public const STATE_ALLOWED_KEYS = [
'entra_tenant_id',
'tenant_id',
'tenant_name',
'environment',
'primary_domain',
'notes',
'provider_connection_id',
'selected_provider_connection_id',
'verification_operation_run_id',
'verification_run_id',
'bootstrap_operation_types',
'bootstrap_operation_runs',
'bootstrap_run_ids',
];
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = [
'state' => 'array',
'payload' => 'array',
'completed_at' => 'datetime',
'abandoned_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* @param array<string, mixed>|null $value
*/
public function setStateAttribute(?array $value): void
{
if ($value === null) {
$this->attributes['state'] = null;
return;
}
$allowed = array_intersect_key($value, array_flip(self::STATE_ALLOWED_KEYS));
$encoded = json_encode($allowed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->attributes['state'] = $encoded !== false ? $encoded : json_encode([], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* @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
public function createdByUser(): 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');
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

View File

@ -3,7 +3,6 @@
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;
@ -142,10 +141,7 @@ 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();
@ -157,8 +153,6 @@ public function getDefaultTenant(Panel $panel): ?Model
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$tenantId = null;
if ($this->tenantPreferencesTableExists()) {
@ -170,7 +164,6 @@ 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();
@ -181,7 +174,6 @@ 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();

View File

@ -33,20 +33,8 @@ public function toDatabase(object $notifiable): array
{
$tenant = $this->run->tenant;
$context = is_array($this->run->context) ? $this->run->context : [];
$wizard = $context['wizard'] ?? null;
$isManagedTenantOnboardingWizardRun = is_array($wizard)
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
$operationLabel = OperationCatalog::label((string) $this->run->type);
$runUrl = match (true) {
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
default => null,
};
return FilamentNotification::make()
->title("{$operationLabel} queued")
->body('Queued. Monitor progress in Monitoring → Operations.')
@ -54,7 +42,7 @@ public function toDatabase(object $notifiable): array
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
])
->getDatabaseMessage();
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Policies;
use App\Models\OnboardingEvidence;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
class OnboardingEvidencePolicy
{
use HandlesAuthorization;
public function viewAny(User $user): Response|bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
? true
: Response::deny();
}
public function view(User $user, OnboardingEvidence $evidence): Response|bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if ((int) $evidence->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
? true
: Response::deny();
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Policies;
use App\Models\OnboardingSession;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
class OnboardingSessionPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): Response|bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
? true
: Response::deny();
}
public function view(User $user, OnboardingSession $session): Response|bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if ((int) $session->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
? true
: Response::deny();
}
public function create(User $user): Response|bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)
? true
: Response::deny();
}
public function update(User $user, OnboardingSession $session): Response|bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if ((int) $session->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)
? true
: Response::deny();
}
}

View File

@ -3,9 +3,8 @@
namespace App\Policies;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
@ -15,31 +14,31 @@ class OperationRunPolicy
public function viewAny(User $user): bool
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$tenant = Tenant::current();
if ($workspaceId === null) {
if (! $tenant) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
return $user->canAccessTenant($tenant);
}
public function view(User $user, OperationRun $run): Response|bool
{
$workspaceId = (int) ($run->workspace_id ?? 0);
$tenant = Tenant::current();
if ($workspaceId <= 0) {
if (! $tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
$isMember = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
return $isMember ? true : Response::denyAsNotFound();
return true;
}
}

View File

@ -5,8 +5,6 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;
@ -17,31 +15,15 @@ class ProviderConnectionPolicy
public function viewAny(User $user): bool
{
$workspace = $this->currentWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
&& Gate::forUser($user)->allows('provider.view', $tenant);
return Gate::forUser($user)->allows('provider.view', $tenant);
}
public function view(User $user, ProviderConnection $connection): Response|bool
{
$workspace = $this->currentWorkspace();
if (! $workspace instanceof Workspace) {
return Response::denyAsNotFound();
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
}
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
return false;
}
@ -50,40 +32,20 @@ public function view(User $user, ProviderConnection $connection): Response|bool
return Response::denyAsNotFound();
}
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
}
return true;
}
public function create(User $user): bool
{
$workspace = $this->currentWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
&& Gate::forUser($user)->allows('provider.manage', $tenant);
return Gate::forUser($user)->allows('provider.manage', $tenant);
}
public function update(User $user, ProviderConnection $connection): Response|bool
{
$workspace = $this->currentWorkspace();
if (! $workspace instanceof Workspace) {
return Response::denyAsNotFound();
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
}
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
return false;
}
@ -92,26 +54,13 @@ public function update(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
}
return true;
}
public function delete(User $user, ProviderConnection $connection): Response|bool
{
$workspace = $this->currentWorkspace();
if (! $workspace instanceof Workspace) {
return Response::denyAsNotFound();
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
}
if (! Gate::forUser($user)->allows('provider.manage', $tenant)) {
return false;
}
@ -120,19 +69,6 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
if ((int) $connection->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
}
return false;
}
private function currentWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_int($workspaceId)
? Workspace::query()->whereKey($workspaceId)->first()
: null;
}
}

View File

@ -33,6 +33,9 @@
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action as FilamentAction;
use Filament\Actions\BulkAction as FilamentBulkAction;
use Filament\Events\TenantSet;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@ -84,6 +87,28 @@ public function register(): void
*/
public function boot(): void
{
if (! FilamentAction::hasMacro('requireCapability')) {
FilamentAction::macro('requireCapability', function (string $capability): FilamentAction {
UiEnforcement::forAction($this)
->preserveVisibility()
->requireCapability($capability)
->apply();
return $this;
});
}
if (! FilamentBulkAction::hasMacro('requireCapability')) {
FilamentBulkAction::macro('requireCapability', function (string $capability): FilamentBulkAction {
UiEnforcement::forBulkAction($this)
->preserveVisibility()
->requireCapability($capability)
->apply();
return $this;
});
}
RateLimiter::for('entra-callback', function (Request $request) {
return Limit::perMinute(20)->by((string) $request->ip());
});

View File

@ -2,12 +2,19 @@
namespace App\Providers;
use App\Models\OnboardingEvidence;
use App\Models\OnboardingSession;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Policies\OnboardingEvidencePolicy;
use App\Policies\OnboardingSessionPolicy;
use App\Policies\ProviderConnectionPolicy;
use App\Policies\WorkspaceMembershipPolicy;
use App\Policies\WorkspacePolicy;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
@ -19,6 +26,10 @@ class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
ProviderConnection::class => ProviderConnectionPolicy::class,
Workspace::class => WorkspacePolicy::class,
WorkspaceMembership::class => WorkspaceMembershipPolicy::class,
OnboardingSession::class => OnboardingSessionPolicy::class,
OnboardingEvidence::class => OnboardingEvidencePolicy::class,
];
public function boot(): void
@ -28,28 +39,20 @@ public function boot(): void
$tenantResolver = app(CapabilityResolver::class);
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
$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;
}
Gate::define($capability, function (User $user, Workspace $workspace) use ($workspaceResolver, $capability): bool {
return $workspaceResolver->can($user, $workspace, $capability);
});
};
$defineTenantCapability = function (string $capability) use ($tenantResolver): void {
Gate::define($capability, function (User $user, Tenant $tenant) use ($tenantResolver, $capability): bool {
return $tenantResolver->can($user, $tenant, $capability);
});
};
foreach (Capabilities::all() as $capability) {
if (str_starts_with($capability, 'workspace')) {
if (str_starts_with($capability, 'workspace.') || str_starts_with($capability, 'workspace_membership.')) {
$defineWorkspaceCapability($capability);
continue;

View File

@ -5,17 +5,29 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\ManagedTenants\ArchivedStatus;
use App\Filament\Pages\ManagedTenants\Current;
use App\Filament\Pages\ManagedTenants\EditManagedTenant;
use App\Filament\Pages\ManagedTenants\Index as ManagedTenantsIndex;
use App\Filament\Pages\ManagedTenants\Onboarding;
use App\Filament\Pages\ManagedTenants\ViewManagedTenant;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\TenantOnboardingWizard;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Middleware\EnsureFilamentTenantSelected;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
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;
@ -25,6 +37,7 @@
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
@ -39,11 +52,64 @@ public function panel(Panel $panel): Panel
->path('admin')
->login(Login::class)
->authenticatedRoutes(function (Panel $panel): void {
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
ChooseWorkspace::registerRoutes($panel);
NoAccess::registerRoutes($panel);
WorkspaceResource::registerRoutes($panel);
TenantOnboardingWizard::registerRoutes($panel);
if ($panel->hasTenantRegistration()) {
$tenantRegistrationPage = $panel->getTenantRegistrationPage();
Route::get($tenantRegistrationPage::getRoutePath($panel), $tenantRegistrationPage)
->middleware($tenantRegistrationPage::getRouteMiddleware($panel))
->withoutMiddleware($tenantRegistrationPage::getWithoutRouteMiddleware($panel))
->name('tenant.registration');
}
ManagedTenantsIndex::registerRoutes($panel);
Onboarding::registerRoutes($panel);
Current::registerRoutes($panel);
ArchivedStatus::registerRoutes($panel);
ViewManagedTenant::registerRoutes($panel);
EditManagedTenant::registerRoutes($panel);
Route::get('managed-tenants/{managedTenant}/open', function (string $managedTenant) {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
$managedTenant = Tenant::withTrashed()->findOrFail($managedTenant);
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $managedTenant)) {
abort(404);
}
if (! $resolver->can($user, $managedTenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
if ($managedTenant->isActive()) {
ManagedTenantContext::setCurrentTenant($managedTenant);
ManagedTenantContext::clearArchivedTenant();
return redirect('/admin/managed-tenants/current');
}
ManagedTenantContext::setArchivedTenant($managedTenant);
return redirect('/admin/managed-tenants/archived');
});
})
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
@ -52,23 +118,10 @@ public function panel(Panel $panel): Panel
->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)
@ -87,6 +140,24 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class,
])
->databaseNotifications()
->userMenuItems([
Action::make('switch-workspace')
->label('Switch workspace')
->icon('heroicon-o-squares-2x2')
->url('/admin/choose-workspace')
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->count() > 1;
})
->sort(0),
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
@ -96,14 +167,14 @@ public function panel(Panel $panel): Panel
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
EnsureFilamentTenantSelected::class,
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
'ensure-workspace-selected',
]);
if (! app()->runningUnitTests()) {

View File

@ -7,7 +7,6 @@
use App\Models\AuditLog;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable;
class WorkspaceAuditLogger
@ -20,28 +19,21 @@ public function log(
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,
'actor_id' => $actor?->getKey(),
'actor_email' => $actor?->email,
'actor_name' => $actor?->name,
'action' => $action,
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $sanitizedMetadata,
'metadata' => $metadata + $context,
'recorded_at' => CarbonImmutable::now(),
]);
}

View File

@ -4,27 +4,39 @@
namespace App\Services\Auth;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Collection;
class PostLoginRedirectResolver
{
public function resolve(User $user): string
{
$membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey());
$tenants = $this->getActiveTenants($user);
$hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at')
? $membershipQuery
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->exists()
: $membershipQuery->exists();
if (! $hasAnyActiveMembership) {
if ($tenants->isEmpty()) {
return '/admin/no-access';
}
return '/admin';
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();
}
}

View File

@ -19,6 +19,13 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_MANAGED_TENANTS_CREATE,
Capabilities::TENANT_MANAGED_TENANTS_MANAGE,
Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE,
Capabilities::TENANT_MANAGED_TENANTS_RESTORE,
Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
@ -42,6 +49,12 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_MANAGED_TENANTS_CREATE,
Capabilities::TENANT_MANAGED_TENANTS_MANAGE,
Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE,
Capabilities::TENANT_MANAGED_TENANTS_RESTORE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
@ -62,6 +75,8 @@ class RoleCapabilityMap
TenantRole::Operator->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
@ -79,6 +94,8 @@ class RoleCapabilityMap
TenantRole::Readonly->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,

View File

@ -16,7 +16,9 @@
class WorkspaceMembershipManager
{
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
public function __construct(public WorkspaceAuditLogger $auditLogger)
{
}
public function addMember(
Workspace $workspace,
@ -28,82 +30,65 @@ public function addMember(
$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();
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;
if ($existing) {
if ($existing->role !== $role) {
$fromRole = (string) $existing->role;
$this->guardLastOwnerDemotion($workspace, $existing, $role);
$existing->forceFill([
'role' => $role,
])->save();
$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,
],
$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();
],
actor: $actor,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
$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',
);
return $existing->refresh();
}
throw $exception;
}
$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;
});
}
public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership
@ -151,13 +136,20 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLastOwnerBlocked(
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => (string) $membership->role,
'attempted_to_role' => $newRole,
],
],
actor: $actor,
targetUserId: (int) $membership->user_id,
attemptedRole: $newRole,
currentRole: (string) $membership->role,
attemptedAction: 'role_change',
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
@ -201,13 +193,20 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
$this->auditLastOwnerBlocked(
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'role' => (string) $membership->role,
'attempted_action' => 'remove',
],
],
actor: $actor,
targetUserId: (int) $membership->user_id,
attemptedRole: (string) $membership->role,
currentRole: (string) $membership->role,
attemptedAction: 'remove',
status: 'blocked',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
@ -272,32 +271,4 @@ private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership
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

@ -23,39 +23,17 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_ARCHIVE,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE,
],
WorkspaceRole::Manager->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
],
WorkspaceRole::Operator->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_MEMBERSHIP_VIEW,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
],
WorkspaceRole::Readonly->value => [

View File

@ -6,25 +6,6 @@
class GraphContractRegistry
{
public function probePath(string $key, array $replacements = []): ?string
{
$path = config("graph_contracts.probes.$key.path");
if (! is_string($path) || $path === '') {
return null;
}
foreach ($replacements as $placeholder => $value) {
if (! is_string($placeholder) || $placeholder === '') {
continue;
}
$path = str_replace($placeholder, urlencode((string) $value), $path);
}
return '/'.ltrim($path, '/');
}
public function directoryGroupsPolicyType(): string
{
return 'directoryGroups';

View File

@ -409,20 +409,7 @@ private function shouldApplySelectFallback(GraphResponse $graphResponse, array $
public function getOrganization(array $options = []): GraphResponse
{
$context = $this->resolveContext($options);
$endpoint = $this->contracts->probePath('organization');
if (! is_string($endpoint) || $endpoint === '') {
return new GraphResponse(
success: false,
data: [],
status: 500,
errors: [[
'message' => 'Graph contract missing for probe: organization',
]],
);
}
$endpoint = ltrim($endpoint, '/');
$endpoint = 'organization';
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
$fullPath = $this->buildFullPath($endpoint);
@ -492,27 +479,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
// First, get the service principal object by clientId (appId)
$endpoint = $this->contracts->probePath('service_principal_by_app_id', ['{appId}' => $clientId]);
if (! is_string($endpoint) || $endpoint === '') {
return new GraphResponse(
success: false,
data: [],
status: 500,
errors: [[
'message' => 'Graph contract missing for probe: service_principal_by_app_id',
]],
);
}
$endpoint = ltrim($endpoint, '/');
$endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
$this->logger->logRequest('get_service_principal', [
'endpoint' => $endpoint,
'client_id' => $clientId,
'tenant' => $context['tenant'],
'method' => 'GET',
'full_path' => $this->buildFullPath($endpoint),
'full_path' => $endpoint,
'client_request_id' => $clientRequestId,
]);
@ -554,30 +528,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
}
// Now get the app role assignments (application permissions)
$assignmentsEndpoint = $this->contracts->probePath(
'service_principal_app_role_assignments',
['{servicePrincipalId}' => $servicePrincipalId],
);
if (! is_string($assignmentsEndpoint) || $assignmentsEndpoint === '') {
return new GraphResponse(
success: false,
data: [],
status: 500,
errors: [[
'message' => 'Graph contract missing for probe: service_principal_app_role_assignments',
]],
);
}
$assignmentsEndpoint = ltrim($assignmentsEndpoint, '/');
$assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
$this->logger->logRequest('get_app_role_assignments', [
'endpoint' => $assignmentsEndpoint,
'service_principal_id' => $servicePrincipalId,
'tenant' => $context['tenant'],
'method' => 'GET',
'full_path' => $this->buildFullPath($assignmentsEndpoint),
'full_path' => $assignmentsEndpoint,
'client_request_id' => $clientRequestId,
]);
@ -591,20 +549,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
$permissions = [];
// Get Microsoft Graph service principal to map role IDs to permission names
$graphSpEndpoint = $this->contracts->probePath(
'service_principal_by_app_id',
['{appId}' => '00000003-0000-0000-c000-000000000000'],
);
$graphSpResponse = null;
if (is_string($graphSpEndpoint) && $graphSpEndpoint !== '') {
$graphSpResponse = $this->send('GET', ltrim($graphSpEndpoint, '/'), [], $context);
}
$graphSps = $graphSpResponse instanceof Response
? $graphSpResponse->json('value', [])
: [];
$graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
$graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
$graphSps = $graphSpResponse->json('value', []);
$appRoles = $graphSps[0]['appRoles'] ?? [];
// Map role IDs to permission names

View File

@ -43,18 +43,18 @@ public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
private function fetchAllScopeTags(?Tenant $tenant = null): array
{
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
try {
$options = ['query' => ['$select' => 'id,displayName']];
// Add tenant credentials if provided
if ($tenant) {
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
$options['client_id'] = $tenant->app_client_id;
$options['client_secret'] = $tenant->app_client_secret;
}
$graphResponse = $this->graphClient->request(
'GET',
'/deviceManagement/roleScopeTags',

View File

@ -4,7 +4,6 @@
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Support\Audit\AuditContextSanitizer;
use Carbon\CarbonImmutable;
class AuditLogger
@ -23,10 +22,6 @@ 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,
@ -36,7 +31,7 @@ public function log(
'resource_type' => $resourceType,
'resource_id' => $resourceId,
'status' => $status,
'metadata' => $sanitizedMetadata,
'metadata' => $metadata + $context,
'recorded_at' => CarbonImmutable::now(),
]);
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Onboarding;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Providers\CredentialManager;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use RuntimeException;
final class LegacyTenantCredentialMigrator
{
public function __construct(private readonly CredentialManager $credentials) {}
/**
* @return array{migrated: bool, message: string}
*/
public function migrate(Tenant $tenant, ProviderConnection $connection): array
{
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
throw new InvalidArgumentException('Provider connection does not belong to the tenant.');
}
$clientId = trim((string) ($tenant->app_client_id ?? ''));
$clientSecret = trim((string) ($tenant->app_client_secret ?? ''));
if ($clientId === '' || $clientSecret === '') {
return [
'migrated' => false,
'message' => 'No legacy tenant credentials found to migrate.',
];
}
$existing = $connection->credential;
if ($existing instanceof ProviderCredential) {
if ($existing->type !== 'client_secret') {
throw new RuntimeException('Provider connection has unsupported credential type.');
}
$payload = $existing->payload;
$existingClientId = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_id'));
$existingClientSecret = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_secret'));
if ($existingClientId !== '' && $existingClientSecret !== '') {
return [
'migrated' => false,
'message' => 'Provider credentials already exist for this connection.',
];
}
}
$this->credentials->upsertClientSecretCredential(
connection: $connection,
clientId: $clientId,
clientSecret: $clientSecret,
);
return [
'migrated' => true,
'message' => 'Legacy tenant credentials migrated to the provider connection.',
];
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services\Onboarding;
use App\Models\OnboardingEvidence;
use App\Models\OnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OpsUx\RunFailureSanitizer;
class OnboardingEvidenceWriter
{
/**
* @param array<string, mixed> $payload
*/
public function record(
Tenant $tenant,
string $taskType,
string $status,
?string $reasonCode = null,
?string $message = null,
array $payload = [],
?OnboardingSession $session = null,
?ProviderConnection $providerConnection = null,
?OperationRun $operationRun = null,
?User $recordedBy = null,
): OnboardingEvidence {
$reasonCode = $reasonCode === null ? null : RunFailureSanitizer::normalizeReasonCode($reasonCode);
$message = $message === null ? null : RunFailureSanitizer::sanitizeMessage($message);
/** @var array<string, mixed> $payload */
$payload = $this->sanitizePayload($payload);
return OnboardingEvidence::query()->create([
'tenant_id' => $tenant->getKey(),
'onboarding_session_id' => $session?->getKey(),
'provider_connection_id' => $providerConnection?->getKey(),
'task_type' => $taskType,
'status' => $status,
'reason_code' => $reasonCode,
'message' => $message,
'payload' => $payload,
'operation_run_id' => $operationRun?->getKey(),
'recorded_at' => now(),
'recorded_by_user_id' => $recordedBy?->getKey(),
]);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function sanitizePayload(array $payload): array
{
$redactedKeys = ['access_token', 'refresh_token', 'client_secret', 'password', 'authorization', 'bearer'];
$sanitize = function (mixed $value) use (&$sanitize, $redactedKeys): mixed {
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$key = is_string($k) ? strtolower($k) : null;
if ($key !== null) {
foreach ($redactedKeys as $needle) {
if (str_contains($key, $needle)) {
$out[$k] = '[REDACTED]';
continue 2;
}
}
}
$out[$k] = $sanitize($v);
}
return $out;
}
if (is_string($value)) {
return RunFailureSanitizer::sanitizeMessage($value);
}
return $value;
};
/** @var array<string, mixed> $sanitized */
$sanitized = $sanitize($payload);
return $sanitized;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Services\Onboarding;
use App\Models\OnboardingSession;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class OnboardingLockService
{
public function acquire(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool
{
return DB::transaction(function () use ($session, $user, $ttlSeconds): bool {
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
if ($this->isLockedByOther($session, $user)) {
return false;
}
$session->forceFill([
'locked_by_user_id' => $user->getKey(),
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
])->save();
return true;
});
}
public function renew(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool
{
return DB::transaction(function () use ($session, $user, $ttlSeconds): bool {
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
if ((int) $session->locked_by_user_id !== (int) $user->getKey()) {
return false;
}
$session->forceFill([
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
])->save();
return true;
});
}
public function release(OnboardingSession $session, User $user): bool
{
return DB::transaction(function () use ($session, $user): bool {
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
if ((int) $session->locked_by_user_id !== (int) $user->getKey()) {
return false;
}
$session->forceFill([
'locked_by_user_id' => null,
'locked_until' => null,
])->save();
return true;
});
}
public function takeover(OnboardingSession $session, User $newOwner, int $ttlSeconds = 600): void
{
DB::transaction(function () use ($session, $newOwner, $ttlSeconds): void {
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
$session->forceFill([
'locked_by_user_id' => $newOwner->getKey(),
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
])->save();
});
}
private function isLockedByOther(OnboardingSession $session, User $user): bool
{
if ($session->locked_by_user_id === null || $session->locked_until === null) {
return false;
}
if ($session->locked_until->isPast()) {
return false;
}
return (int) $session->locked_by_user_id !== (int) $user->getKey();
}
}

View File

@ -5,7 +5,6 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
use App\Services\Operations\BulkIdempotencyFingerprint;
@ -61,19 +60,12 @@ public function ensureRun(
array $inputs,
?User $initiator = null
): OperationRun {
$workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) {
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
}
$hash = $this->calculateHash($tenant->id, $type, $inputs);
// Idempotency Check (Fast Path)
// We check specific status to match the partial unique index
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('workspace_id', $workspaceId)
->where('run_identity_hash', $hash)
->whereIn('status', OperationRunStatus::values())
->where('status', '!=', OperationRunStatus::Completed->value)
@ -86,7 +78,6 @@ public function ensureRun(
// Create new run (race-safe via partial unique index)
try {
return OperationRun::create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->id,
'user_id' => $initiator?->id,
'initiator_name' => $initiator?->name ?? 'System',
@ -106,7 +97,6 @@ public function ensureRun(
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('workspace_id', $workspaceId)
->where('run_identity_hash', $hash)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->first();
@ -126,19 +116,12 @@ public function ensureRunWithIdentity(
array $context,
?User $initiator = null
): OperationRun {
$workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) {
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
}
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
// Idempotency Check (Fast Path)
// We check specific status to match the partial unique index
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('workspace_id', $workspaceId)
->where('run_identity_hash', $hash)
->whereIn('status', OperationRunStatus::values())
->where('status', '!=', OperationRunStatus::Completed->value)
@ -151,7 +134,6 @@ public function ensureRunWithIdentity(
// Create new run (race-safe via partial unique index)
try {
return OperationRun::create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->id,
'user_id' => $initiator?->id,
'initiator_name' => $initiator?->name ?? 'System',
@ -171,7 +153,6 @@ public function ensureRunWithIdentity(
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('workspace_id', $workspaceId)
->where('run_identity_hash', $hash)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->first();
@ -246,59 +227,6 @@ public function enqueueBulkOperation(
return $run;
}
public function ensureWorkspaceRunWithIdentity(
Workspace $workspace,
string $type,
array $identityInputs,
array $context,
?User $initiator = null,
): OperationRun {
$hash = $this->calculateWorkspaceHash((int) $workspace->getKey(), $type, $identityInputs);
$existing = OperationRun::query()
->where('workspace_id', (int) $workspace->getKey())
->whereNull('tenant_id')
->where('run_identity_hash', $hash)
->whereIn('status', OperationRunStatus::values())
->where('status', '!=', OperationRunStatus::Completed->value)
->first();
if ($existing) {
return $existing;
}
try {
return OperationRun::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => null,
'user_id' => $initiator?->id,
'initiator_name' => $initiator?->name ?? 'System',
'type' => $type,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => $hash,
'context' => $context,
]);
} catch (QueryException $e) {
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
throw $e;
}
$existing = OperationRun::query()
->where('workspace_id', (int) $workspace->getKey())
->whereNull('tenant_id')
->where('run_identity_hash', $hash)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->first();
if ($existing) {
return $existing;
}
throw $e;
}
}
public function updateRun(
OperationRun $run,
string $status,
@ -590,15 +518,6 @@ protected function calculateHash(int $tenantId, string $type, array $inputs): st
return hash('sha256', $tenantId.'|'.$type.'|'.$json);
}
protected function calculateWorkspaceHash(int $workspaceId, string $type, array $inputs): string
{
$normalizedInputs = $this->normalizeInputs($inputs);
$json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return hash('sha256', 'workspace|'.$workspaceId.'|'.$type.'|'.$json);
}
/**
* Normalize inputs for stable identity hashing.
*

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActions;
use Illuminate\Support\Arr;
class TenantOnboardingAuditService
{
public function __construct(public AuditLogger $auditLogger)
{
}
public function credentialsUpdated(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
{
$context = $this->sanitizeContext($context);
return $this->auditLogger->log(
tenant: $tenant,
action: AuditActions::TENANT_ONBOARDING_CREDENTIALS_UPDATED,
context: $context,
actorId: $actor?->id,
actorEmail: $actor?->email,
actorName: $actor?->name,
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
public function onboardingCompleted(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
{
$context = $this->sanitizeContext($context);
return $this->auditLogger->log(
tenant: $tenant,
action: AuditActions::TENANT_ONBOARDING_COMPLETED,
context: $context,
actorId: $actor?->id,
actorEmail: $actor?->email,
actorName: $actor?->name,
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function sanitizeContext(array $context): array
{
$keysToStrip = [
'secret',
'client_secret',
'app_client_secret',
'app_secret',
'token',
'access_token',
'refresh_token',
];
return Arr::except($context, $keysToStrip);
}
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantOnboardingSessionService
{
/**
* Start a new onboarding session, or resume an existing active session.
*/
public function startOrResume(User $user, ?Tenant $tenant = null): TenantOnboardingSession
{
if ($tenant instanceof Tenant) {
$existing = TenantOnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->where('status', 'active')
->first();
if ($existing instanceof TenantOnboardingSession) {
return $existing;
}
}
return TenantOnboardingSession::query()->create([
'tenant_id' => $tenant?->getKey(),
'created_by_user_id' => $user->getKey(),
'status' => 'active',
'current_step' => 'welcome',
'payload' => [],
]);
}
public function resumeById(User $user, string $sessionId): TenantOnboardingSession
{
$session = TenantOnboardingSession::query()->whereKey($sessionId)->firstOrFail();
if ((int) $session->created_by_user_id !== (int) $user->getKey()) {
abort(404);
}
return $session;
}
/**
* Persist wizard progress + non-secret payload.
*
* @param array<string, mixed> $payload
*/
public function persistProgress(TenantOnboardingSession $session, string $currentStep, array $payload, ?Tenant $tenant = null): TenantOnboardingSession
{
$payload = $this->sanitizePayload($payload);
return DB::transaction(function () use ($session, $currentStep, $payload, $tenant): TenantOnboardingSession {
$session->forceFill([
'current_step' => $currentStep,
'payload' => array_merge($session->payload ?? [], $payload),
]);
if ($tenant instanceof Tenant) {
$session->tenant()->associate($tenant);
}
try {
$session->save();
} catch (QueryException $exception) {
// If another active session already exists for the tenant, resume it.
if (($tenant instanceof Tenant) && $this->isActiveSessionUniqueViolation($exception)) {
$existing = TenantOnboardingSession::query()
->where('tenant_id', $tenant->getKey())
->where('status', 'active')
->first();
if ($existing instanceof TenantOnboardingSession) {
return $existing;
}
}
throw $exception;
}
return $session;
});
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function sanitizePayload(array $payload): array
{
$forbiddenKeys = [
'app_client_secret',
'client_secret',
'secret',
'token',
'access_token',
'refresh_token',
'password',
];
return $this->forgetKeysRecursive($payload, $forbiddenKeys);
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $forbiddenKeys
* @return array<string, mixed>
*/
private function forgetKeysRecursive(array $payload, array $forbiddenKeys): array
{
foreach ($forbiddenKeys as $key) {
Arr::forget($payload, $key);
}
foreach ($payload as $key => $value) {
if (! is_array($value)) {
continue;
}
$payload[$key] = $this->forgetKeysRecursive($value, $forbiddenKeys);
}
return $payload;
}
private function isActiveSessionUniqueViolation(QueryException $exception): bool
{
$message = Str::lower($exception->getMessage());
return str_contains($message, 'tenant_onboarding_sessions_active_unique')
|| str_contains($message, 'unique') && str_contains($message, 'tenant_onboarding_sessions');
}
}

View File

@ -1,57 +0,0 @@
<?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,12 +6,6 @@
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';
@ -23,10 +17,8 @@ enum AuditActionId: string
// 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 ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
case VerificationCompleted = 'verification.completed';
case WorkspaceMembershipAdd = 'workspace_membership.add';
case WorkspaceMembershipRoleChange = 'workspace_membership.role_change';
case WorkspaceMembershipRemove = 'workspace_membership.remove';
case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked';
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Support\Audit;
final class AuditActions
{
public const TENANT_ONBOARDING_CREDENTIALS_UPDATED = 'tenant.onboarding.credentials.updated';
public const TENANT_ONBOARDING_COMPLETED = 'tenant.onboarding.completed';
}

View File

@ -1,66 +0,0 @@
<?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,37 +15,6 @@ 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';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY = 'workspace_managed_tenant.onboard.identify';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW = 'workspace_managed_tenant.onboard.connection.view';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE = 'workspace_managed_tenant.onboard.connection.manage';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START = 'workspace_managed_tenant.onboard.verification.start';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.inventory_sync';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC = 'workspace_managed_tenant.onboard.bootstrap.policy_sync';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP = 'workspace_managed_tenant.onboard.bootstrap.backup_bootstrap';
public const WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE = 'workspace_managed_tenant.onboard.activate';
// Tenants
public const TENANT_VIEW = 'tenant.view';
@ -55,6 +24,19 @@ class Capabilities
public const TENANT_SYNC = 'tenant.sync';
// Managed tenants (tenantless CRUD + onboarding)
public const TENANT_MANAGED_TENANTS_VIEW = 'tenant_managed_tenants.view';
public const TENANT_MANAGED_TENANTS_CREATE = 'tenant_managed_tenants.create';
public const TENANT_MANAGED_TENANTS_MANAGE = 'tenant_managed_tenants.manage';
public const TENANT_MANAGED_TENANTS_ARCHIVE = 'tenant_managed_tenants.archive';
public const TENANT_MANAGED_TENANTS_RESTORE = 'tenant_managed_tenants.restore';
public const TENANT_MANAGED_TENANTS_FORCE_DELETE = 'tenant_managed_tenants.force_delete';
// Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
@ -86,6 +68,18 @@ class Capabilities
// Audit
public const AUDIT_VIEW = 'audit.view';
// 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';
/**
* Get all capability constants
*

View File

@ -44,9 +44,7 @@ class UiEnforcement
*/
private ?\Closure $bulkPreflight = null;
public function __construct(private string $capability)
{
}
public function __construct(private string $capability) {}
public static function for(string $capability): self
{
@ -418,6 +416,7 @@ private function resolveTenantIdsForRecords(Collection $records): array
if ($resolved instanceof Tenant) {
$ids[] = (int) $resolved->getKey();
continue;
}

View File

@ -11,4 +11,3 @@ public static function insufficientPermission(): string
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
}
}

View File

@ -34,12 +34,9 @@ final class BadgeCatalog
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::OnboardingTaskStatus->value => Domains\OnboardingTaskStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
];
/**

View File

@ -26,10 +26,7 @@ enum BadgeDomain: string
case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status';
case OnboardingTaskStatus = 'onboarding_task.status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity';
case VerificationReportOverall = 'verification_report_overall';
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ManagedTenantOnboardingVerificationStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'not_started' => new BadgeSpec('Not started', 'gray', 'heroicon-m-minus-circle'),
'in_progress' => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
'needs_attention' => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
'ready' => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(),
};
}
}

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