Compare commits

...

2 Commits

Author SHA1 Message Date
eef85af990 062-tenant-rbac-v1 (#74)
Kurzbeschreibung

Implementiert Tenant RBAC v1 (specs/062-tenant-rbac-v1): tenant_memberships, Capability registry/resolver, gates, Filament RelationManager für Tenant→Members, Last‑Owner‑Guard, bootstrap assign/recover (break‑glass), Audit-Logging.
Wichtige Änderungen

Migration: create_tenant_memberships_table (T004) — ausgeführt
Models/Services: TenantMembership, Capabilities, RoleCapabilityMap, CapabilityResolver (T008–T013)
Auth: Gates registriert in AuthServiceProvider.php (T011)
Filament: RelationManager unter Settings → Tenants (Members CRUD + Last‑Owner‑Guard) (T017–T018)
Break‑glass: lokale platform superadmin + persistent banner + bootstrap_recover action (T024–T026)
Audit: Audit‑Einträge für membership actions mit canonical action_ids (T022)
Tests: neue/aktualisierte Feature- und Unit‑Tests (siehe Test‑Abschnitt)
Migrations / Deploy

Run migrations: vendor/bin/sail artisan migrate
Keine neuen Panel‑Assets registriert (kein php artisan filament:assets nötig)
Wenn Frontend nicht sichtbar: vendor/bin/sail npm run dev oder vendor/bin/sail npm run build
Tests (geprüft / neu)

Fokus-Suite ausgeführt für Tenant RBAC (T031).
Neu / aktualisiert:
CapabilitiesRegistryTest
CapabilityResolverTest
TenantSwitcherScopeTest
TenantRouteDenyAsNotFoundTest
TenantMembershipCrudTest
LastOwnerGuardTest
TenantBootstrapAssignTest
MembershipAuditLogTest
BreakGlassRecoveryTest
Befehl zum lokalen Ausführen (minimal): vendor/bin/sail artisan test tests/Feature/TenantRBAC --stop-on-failure
Filament / Sicherheits‑Contract (erforderliche Punkte)

Livewire v4.0+ compliance: bestätigt (Filament v5 target).
Provider registration: keine neue Panel‑Provider-Änderung; falls nötig: providers.php (Laravel 11+).
Globale Suche: keine neuen Ressourcen für Global Search hinzugefügt; vorhandene Ressourcen behalten Edit/View‑Pages unverändert.
Destructive actions: tenant_membership.remove und role‑demote sind destruktive — implemented via Action::make(...)->action(...)->requiresConfirmation() + policy checks.
Asset strategy: keine globalen Assets; on‑demand/load as before. Deployment: filament:assets nicht erforderlich für diese PR.
Testing plan: Livewire/Filament Komponenten + actions abgedeckt — RelationManager CRUD, Last‑Owner‑Guard, BreakGlassRecovery, CapabilityResolver/Registry, Tenant switcher + deny‑as‑not‑found route tests.
Offene/optionale Punkte

T005/T028/T029 (tenant_role_mappings migration + UI + Tests) sind optional und noch nicht umgesetzt.
Checklist (aus tasks.md)

 T001–T003 Discovery
 T004, T006–T007 Migrations (T005 optional)
 T008–T013 Models/Capabilities/Gates
 T014–T016 Tenant isolation & route enforcement
 T017–T021 Membership UI + bootstrap flows
 T022–T023 Audit logging + tests
 T024–T027 Break‑glass flows & tests
 T005, T028, T029 Optional mappings
 T030–T031 Formatting + focused tests
Migration / Test commands to run locally

vendor/bin/sail up -d
vendor/bin/sail artisan migrate
vendor/bin/sail artisan tinker (falls manuell Benutzer/Flags setzen)
vendor/bin/sail artisan test tests/Feature/TenantRBAC --stop-on-failure
Wenn du einen PR‑Titel und Labels willst, schlage ich vor:

Title: feat(062): Tenant RBAC v1 — memberships, capability resolver, break‑glass recovery
Labels: feature, tests, migration

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #74
2026-01-25 15:27:39 +00:00
a0ed9e24c5 feat: unify provider connection actions and notifications (#73)
## Summary
- introduce the Provider Connection Filament resource (list/create/edit) with DB-only controls, grouped action dropdowns, and badge-driven status/health rendering
- wire up the provider foundation stack (migrations, models, policies, providers, operations, badges, and audits) plus the required spec docs/checklists
- standardize Inventory Sync notifications so the job no longer writes its own DB rows; terminal notifications now flow exclusively through OperationRunCompleted while the start surface still shows the queued toast

## Testing
- ./vendor/bin/sail php ./vendor/bin/pint --dirty
- ./vendor/bin/sail artisan test tests/Unit/Badges/ProviderConnectionBadgesTest.php
- ./vendor/bin/sail artisan test tests/Feature/ProviderConnections tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
- ./vendor/bin/sail artisan test tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/Inventory/InventorySyncStartSurfaceTest.php

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #73
2026-01-25 01:01:37 +00:00
108 changed files with 7318 additions and 71 deletions

View File

@ -910,9 +910,8 @@ ### Replaced Utilities
</laravel-boost-guidelines> </laravel-boost-guidelines>
## Recent Changes ## Recent Changes
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3 - 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 054-unify-runs-suitewide: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] - 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3 - 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
## Active Technologies ## Active Technologies
- PostgreSQL (`operation_runs` table + JSONB) (054-unify-runs-suitewide)

View File

@ -0,0 +1,97 @@
<?php
namespace App\Filament\Pages;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BreakGlassRecovery extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Break-glass recovery';
protected static ?int $navigationSort = 999;
protected string $view = 'filament.pages.break-glass-recovery';
public static function canAccess(): bool
{
$user = auth()->user();
return $user instanceof User && $user->isPlatformSuperadmin();
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('bootstrap_recover')
->label('Assign owner (recovery)')
->color('danger')
->requiresConfirmation()
->modalHeading('Break-glass: assign owner')
->modalDescription('This grants Owner access to a tenant. Use for recovery only. This action is audited.')
->form([
Select::make('tenant_id')
->label('Tenant')
->required()
->searchable()
->options(fn (): array => Tenant::query()
->where('status', 'active')
->orderBy('name')
->pluck('name', 'id')
->all()),
Select::make('user_id')
->label('User')
->required()
->searchable()
->options(fn (): array => User::query()
->orderBy('name')
->pluck('name', 'id')
->all()),
])
->action(function (array $data, TenantMembershipManager $manager): void {
$actor = auth()->user();
if (! $actor instanceof User || ! $actor->isPlatformSuperadmin()) {
abort(403);
}
$tenant = Tenant::query()
->where('status', 'active')
->whereKey((int) $data['tenant_id'])
->first();
if (! $tenant instanceof Tenant) {
Notification::make()->title('Tenant not found')->danger()->send();
return;
}
$member = User::query()->whereKey((int) $data['user_id'])->first();
if (! $member instanceof User) {
Notification::make()->title('User not found')->danger()->send();
return;
}
$manager->bootstrapRecover($tenant, $actor, $member);
Notification::make()->title('Owner assigned')->success()->send();
}),
];
}
}

View File

@ -4,6 +4,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Support\TenantRole; use App\Support\TenantRole;
use Filament\Forms; use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
@ -74,8 +75,30 @@ protected function handleRegistration(array $data): Model
if ($user instanceof User) { if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => TenantRole::Owner->value], $tenant->getKey() => [
'role' => TenantRole::Owner->value,
'source' => 'manual',
'created_by_user_id' => $user->getKey(),
],
]); ]);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'tenant_membership.bootstrap_assign',
context: [
'metadata' => [
'user_id' => (int) $user->getKey(),
'role' => TenantRole::Owner->value,
'source' => 'manual',
],
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
} }
return $tenant; return $tenant;

View File

@ -0,0 +1,596 @@
<?php
namespace App\Filament\Resources;
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;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class ProviderConnectionResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = ProviderConnection::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
protected static string|UnitEnum|null $navigationGroup = 'Providers';
protected static ?string $navigationLabel = 'Connections';
protected static ?string $recordTitleAttribute = 'display_name';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('display_name')
->label('Display name')
->required()
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current()))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Entra tenant ID')
->required()
->maxLength(255)
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current()))
->rules(['uuid']),
Toggle::make('is_default')
->label('Default connection')
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current()))
->helperText('Exactly one default connection is required per tenant/provider.'),
TextInput::make('status')
->label('Status')
->disabled()
->dehydrated(false),
TextInput::make('health_status')
->label('Health')
->disabled()
->dehydrated(false),
]);
}
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->defaultSort('display_name')
->columns([
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
Tables\Columns\TextColumn::make('status')
->label('Status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
Tables\Columns\TextColumn::make('health_status')
->label('Health')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
])
->filters([
SelectFilter::make('status')
->label('Status')
->options([
'connected' => 'Connected',
'needs_consent' => 'Needs consent',
'error' => 'Error',
'disabled' => 'Disabled',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('status', $value);
}),
SelectFilter::make('health_status')
->label('Health')
->options([
'ok' => 'OK',
'degraded' => 'Degraded',
'down' => 'Down',
'unknown' => 'Unknown',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('health_status', $value);
}),
])
->actions([
Actions\ActionGroup::make([
Actions\EditAction::make(),
Actions\Action::make('check_connection')
->label('Check connection')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current())
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user();
abort_unless($user instanceof User, 403);
$initiator = $user;
$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,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A connection check is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Connection check queued')
->body('Health check was queued and will run in the background.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
Actions\Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current())
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user();
abort_unless($user instanceof User, 403);
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'inventory.sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('An inventory sync is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Inventory sync queued')
->body('Inventory sync was queued and will run in the background.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
Actions\Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
->color('info')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current())
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user();
abort_unless($user instanceof User, 403);
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'compliance.snapshot',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderComplianceSnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A compliance snapshot is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Compliance snapshot queued')
->body('Compliance snapshot was queued and will run in the background.')
->success()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
Actions\Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current())
&& $record->status !== 'disabled'
&& ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$record->makeDefault();
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Default connection updated')
->success()
->send();
}),
Actions\Action::make('update_credentials')
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->visible(fn (): bool => Gate::allows('provider.manage', Tenant::current()))
->form([
TextInput::make('client_id')
->label('Client ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$credentials->upsertClientSecretCredential(
connection: $record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.credentials_updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Credentials updated')
->success()
->send();
}),
Actions\Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current())
&& $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status;
$record->update([
'status' => $status,
'health_status' => 'unknown',
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
]);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.enabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
'to_status' => $status,
'credentials_present' => $hadCredentials,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->warning()
->send();
return;
}
Notification::make()
->title('Provider connection enabled')
->success()
->send();
}),
Actions\Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current())
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$previousStatus = (string) $record->status;
$record->update([
'status' => 'disabled',
]);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.disabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Provider connection disabled')
->warning()
->send();
}),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListProviderConnections::route('/'),
'create' => Pages\CreateProviderConnection::route('/create'),
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateProviderConnection extends CreateRecord
{
protected static string $resource = ProviderConnectionResource::class;
protected bool $shouldMakeDefault = false;
protected function mutateFormDataBeforeCreate(array $data): array
{
$tenant = Tenant::current();
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
return [
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'],
'display_name' => $data['display_name'],
'is_default' => false,
];
}
protected function afterCreate(): void
{
$tenant = Tenant::current();
$record = $this->getRecord();
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.created',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
$hasDefault = $tenant->providerConnections()
->where('provider', $record->provider)
->where('is_default', true)
->exists();
if ($this->shouldMakeDefault || ! $hasDefault) {
$record->makeDefault();
}
Notification::make()
->title('Provider connection created')
->success()
->send();
}
}

View File

@ -0,0 +1,611 @@
<?php
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
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;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\OperationRunLinks;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
class EditProviderConnection extends EditRecord
{
protected static string $resource = ProviderConnectionResource::class;
protected bool $shouldMakeDefault = false;
protected bool $defaultWasChanged = false;
protected function mutateFormDataBeforeSave(array $data): array
{
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
unset($data['is_default']);
return $data;
}
protected function afterSave(): void
{
$tenant = Tenant::current();
$record = $this->getRecord();
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
if ($this->shouldMakeDefault && ! $record->is_default) {
$record->makeDefault();
$this->defaultWasChanged = true;
}
$hasDefault = $tenant->providerConnections()
->where('provider', $record->provider)
->where('is_default', true)
->exists();
if (! $hasDefault) {
$record->makeDefault();
$this->defaultWasChanged = true;
}
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
if ($changedFields !== []) {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'fields' => $changedFields,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
}
if ($this->defaultWasChanged) {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
}
}
protected function getHeaderActions(): array
{
$tenant = Tenant::current();
return [
Actions\DeleteAction::make()
->visible(false),
Actions\ActionGroup::make([
Action::make('view_last_check_run')
->label('View last check run')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.view', $tenant)
&& OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->where('context->provider_connection_id', (int) $record->getKey())
->exists())
->url(function (ProviderConnection $record): ?string {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->where('context->provider_connection_id', (int) $record->getKey())
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
return OperationRunLinks::view($run, $tenant);
}),
Action::make('check_connection')
->label('Check connection')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.run', $tenant)
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user();
abort_unless($user instanceof User, 403);
$initiator = $user;
$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,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A connection check is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Connection check queued')
->body('Health check was queued and will run in the background.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
Action::make('update_credentials')
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows('provider.manage', $tenant))
->form([
TextInput::make('client_id')
->label('Client ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$credentials->upsertClientSecretCredential(
connection: $record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.credentials_updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Credentials updated')
->success()
->send();
}),
Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.manage', $tenant)
&& $record->status !== 'disabled'
&& ! $record->is_default
&& ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->where('provider', $record->provider)
->count() > 1)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$record->makeDefault();
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Default connection updated')
->success()
->send();
}),
Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.run', $tenant)
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user();
abort_unless($user instanceof User, 403);
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'inventory.sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('An inventory sync is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Inventory sync queued')
->body('Inventory sync was queued and will run in the background.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
->color('info')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.run', $tenant)
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user();
abort_unless($user instanceof User, 403);
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'compliance.snapshot',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderComplianceSnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
Notification::make()
->title('Run already queued')
->body('A compliance snapshot is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title('Compliance snapshot queued')
->body('Compliance snapshot was queued and will run in the background.')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}),
Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.manage', $tenant)
&& $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status;
$record->update([
'status' => $status,
'health_status' => 'unknown',
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
]);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.enabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
'to_status' => $status,
'credentials_present' => $hadCredentials,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->warning()
->send();
return;
}
Notification::make()
->title('Provider connection enabled')
->success()
->send();
}),
Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.manage', $tenant)
&& $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
$previousStatus = (string) $record->status;
$record->update([
'status' => 'disabled',
]);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.disabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Provider connection disabled')
->warning()
->send();
}),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
}
protected function getFormActions(): array
{
$tenant = Tenant::current();
if ($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant)) {
return parent::getFormActions();
}
return [
$this->getCancelFormAction(),
];
}
protected function handleRecordUpdate(Model $record, array $data): Model
{
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
return parent::handleRecordUpdate($record, $data);
}
}

View File

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

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob; use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
@ -576,6 +577,13 @@ public static function getPages(): array
]; ];
} }
public static function getRelations(): array
{
return [
RelationManagers\TenantMembershipsRelationManager::class,
];
}
public static function rbacAction(): Actions\Action public static function rbacAction(): Actions\Action
{ {
// ... [RBAC Action Omitted - No Change] ... // ... [RBAC Action Omitted - No Change] ...

View File

@ -0,0 +1,224 @@
<?php
namespace App\Filament\Resources\TenantResource\RelationManagers;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
class TenantMembershipsRelationManager 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.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('source')
->badge()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->headerActions([
Actions\Action::make('add_member')
->label('Add member')
->icon('heroicon-o-plus')
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::allows('tenant_membership.manage', $tenant);
})
->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([
TenantRole::Owner->value => 'Owner',
TenantRole::Manager->value => 'Manager',
TenantRole::Operator->value => 'Operator',
TenantRole::Readonly->value => 'Readonly',
]),
])
->action(function (array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! Gate::allows('tenant_membership.manage', $tenant)) {
abort(403);
}
$member = User::query()->find((int) $data['user_id']);
if (! $member) {
Notification::make()->title('User not found')->danger()->send();
return;
}
try {
$manager->addMember(
tenant: $tenant,
actor: $actor,
member: $member,
role: TenantRole::from((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();
}),
])
->actions([
Actions\Action::make('change_role')
->label('Change role')
->icon('heroicon-o-pencil')
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::allows('tenant_membership.manage', $tenant);
})
->form([
Forms\Components\Select::make('role')
->label('Role')
->required()
->options([
TenantRole::Owner->value => 'Owner',
TenantRole::Manager->value => 'Manager',
TenantRole::Operator->value => 'Operator',
TenantRole::Readonly->value => 'Readonly',
]),
])
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! Gate::allows('tenant_membership.manage', $tenant)) {
abort(403);
}
try {
$manager->changeRole(
tenant: $tenant,
actor: $actor,
membership: $record,
newRole: TenantRole::from((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();
}),
Actions\Action::make('remove')
->label('Remove')
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->visible(function (): bool {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
return false;
}
return Gate::allows('tenant_membership.manage', $tenant);
})
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$actor = auth()->user();
if (! $actor instanceof User) {
abort(403);
}
if (! Gate::allows('tenant_membership.manage', $tenant)) {
abort(403);
}
try {
$manager->removeMember($tenant, $actor, $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();
}),
])
->bulkActions([]);
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftComplianceSnapshotService;
use App\Services\Providers\ProviderGateway;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
class ProviderComplianceSnapshotJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
MicrosoftComplianceSnapshotService $collector,
ProviderGateway $gateway,
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.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
try {
$counts = $collector->snapshot($connection);
$entraTenantName = $this->resolveEntraTenantName($connection, $gateway);
if ($entraTenantName !== null) {
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
$metadata['entra_tenant_name'] = $entraTenantName;
$connection->update(['metadata' => $metadata]);
}
if ($this->operationRun instanceof OperationRun) {
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: $counts,
);
}
} catch (Throwable $throwable) {
if (! $this->operationRun instanceof OperationRun) {
throw $throwable;
}
$message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
$reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage());
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'compliance.snapshot.failed',
'reason_code' => $reasonCode,
'message' => $message !== '' ? $message : 'Compliance snapshot failed.',
]],
);
}
}
private function resolveEntraTenantName(ProviderConnection $connection, ProviderGateway $gateway): ?string
{
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
$existing = $metadata['entra_tenant_name'] ?? null;
if (is_string($existing) && trim($existing) !== '') {
return trim($existing);
}
try {
$response = $gateway->getOrganization($connection);
} catch (Throwable) {
return null;
}
if (! $response->successful()) {
return null;
}
$displayName = $response->data['displayName'] ?? null;
return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : null;
}
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
}
$context['target_scope'] = $targetScope;
$run->update(['context' => $context]);
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\MicrosoftProviderHealthCheck;
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 Illuminate\Support\Arr;
use RuntimeException;
class ProviderConnectionHealthCheckJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
MicrosoftProviderHealthCheck $healthCheck,
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.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
$result = $healthCheck->check($connection);
$this->applyHealthResult($connection, $result);
if (! $this->operationRun instanceof OperationRun) {
return;
}
$entraTenantName = $this->resolveEntraTenantName($connection, $result);
if ($entraTenantName !== null) {
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
$metadata['entra_tenant_name'] = $entraTenantName;
$connection->update(['metadata' => $metadata]);
}
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
if ($result->healthy) {
$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' => 'provider.connection.check.failed',
'reason_code' => $result->reasonCode ?? 'unknown_error',
'message' => $result->message ?? 'Health check failed.',
]],
);
}
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
{
$existing = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name');
if (is_string($existing) && trim($existing) !== '') {
return trim($existing);
}
$candidate = $result->meta['organization_display_name'] ?? null;
return is_string($candidate) && trim($candidate) !== '' ? trim($candidate) : null;
}
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
}
$context['target_scope'] = $targetScope;
$run->update(['context' => $context]);
}
private function applyHealthResult(ProviderConnection $connection, HealthResult $result): void
{
$connection->update([
'status' => $result->status,
'health_status' => $result->healthStatus,
'last_health_check_at' => now(),
'last_error_reason_code' => $result->healthy ? null : $result->reasonCode,
'last_error_message' => $result->healthy ? null : $result->message,
]);
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftProviderInventoryCollector;
use App\Services\Providers\ProviderGateway;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
class ProviderInventorySyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
public int $providerConnectionId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
MicrosoftProviderInventoryCollector $collector,
ProviderGateway $gateway,
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.');
}
$connection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->find($this->providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('ProviderConnection not found.');
}
try {
$counts = $collector->collect($connection);
$entraTenantName = $this->resolveEntraTenantName($connection, $gateway);
if ($entraTenantName !== null) {
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
$metadata['entra_tenant_name'] = $entraTenantName;
$connection->update(['metadata' => $metadata]);
}
if ($this->operationRun instanceof OperationRun) {
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: $counts,
);
}
} catch (Throwable $throwable) {
if (! $this->operationRun instanceof OperationRun) {
throw $throwable;
}
$message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
$reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage());
$runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'inventory.sync.failed',
'reason_code' => $reasonCode,
'message' => $message !== '' ? $message : 'Inventory sync failed.',
]],
);
}
}
private function resolveEntraTenantName(ProviderConnection $connection, ProviderGateway $gateway): ?string
{
$metadata = is_array($connection->metadata) ? $connection->metadata : [];
$existing = $metadata['entra_tenant_name'] ?? null;
if (is_string($existing) && trim($existing) !== '') {
return trim($existing);
}
try {
$response = $gateway->getOrganization($connection);
} catch (Throwable) {
return null;
}
if (! $response->successful()) {
return null;
}
$displayName = $response->data['displayName'] ?? null;
return is_string($displayName) && trim($displayName) !== '' ? trim($displayName) : null;
}
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
}
$context['target_scope'] = $targetScope;
$run->update(['context' => $context]);
}
}

View File

@ -10,10 +10,8 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -135,18 +133,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
resourceId: (string) $run->id, resourceId: (string) $run->id,
); );
Notification::make()
->title('Inventory sync completed')
->body('Inventory sync finished successfully.')
->success()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
return; return;
} }
@ -190,18 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
resourceId: (string) $run->id, resourceId: (string) $run->id,
); );
Notification::make()
->title('Inventory sync completed with errors')
->body('Inventory sync finished with some errors. Review the run details for error codes.')
->warning()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
return; return;
} }
@ -243,18 +217,6 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
resourceId: (string) $run->id, resourceId: (string) $run->id,
); );
Notification::make()
->title('Inventory sync skipped')
->body('Inventory sync could not start due to locks or concurrency limits.')
->warning()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
return; return;
} }
@ -297,16 +259,5 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
resourceId: (string) $run->id, resourceId: (string) $run->id,
); );
Notification::make()
->title('Inventory sync failed')
->body('Inventory sync finished with errors.')
->danger()
->actions($this->operationRun ? [
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
] : [])
->sendToDatabase($user)
->send();
} }
} }

View File

@ -0,0 +1,51 @@
<?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\HasOne;
use Illuminate\Support\Facades\DB;
class ProviderConnection extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'is_default' => 'boolean',
'scopes_granted' => 'array',
'metadata' => 'array',
'last_health_check_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function credential(): HasOne
{
return $this->hasOne(ProviderCredential::class, 'provider_connection_id');
}
public function makeDefault(): void
{
DB::transaction(function (): void {
static::query()
->where('tenant_id', $this->tenant_id)
->where('provider', $this->provider)
->where('is_default', true)
->whereKeyNot($this->getKey())
->update(['is_default' => false]);
static::query()
->whereKey($this->getKey())
->update(['is_default' => true]);
});
$this->refresh();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProviderCredential extends Model
{
use HasFactory;
protected $guarded = [];
protected $hidden = [
'payload',
];
protected $casts = [
'payload' => 'encrypted:array',
];
public function providerConnection(): BelongsTo
{
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
}
}

View File

@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -151,6 +152,16 @@ public static function current(): self
return $tenant; return $tenant;
} }
public function memberships(): HasMany
{
return $this->hasMany(TenantMembership::class);
}
public function roleMappings(): HasMany
{
return $this->hasMany(TenantRoleMapping::class);
}
public function getFilamentName(): string public function getFilamentName(): string
{ {
$environment = strtoupper((string) ($this->environment ?? 'other')); $environment = strtoupper((string) ($this->environment ?? 'other'));
@ -215,6 +226,16 @@ public function permissions(): HasMany
return $this->hasMany(TenantPermission::class); return $this->hasMany(TenantPermission::class);
} }
public function providerConnections(): HasMany
{
return $this->hasMany(ProviderConnection::class);
}
public function providerCredentials(): HasManyThrough
{
return $this->hasManyThrough(ProviderCredential::class, ProviderConnection::class, 'tenant_id', 'provider_connection_id');
}
public function graphTenantId(): ?string public function graphTenantId(): ?string
{ {
return $this->tenant_id ?? $this->external_id; return $this->tenant_id ?? $this->external_id;

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
class TenantMembership extends Pivot
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $table = 'tenant_memberships';
protected $guarded = [];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function createdByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantRoleMapping extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = [
'is_enabled' => 'boolean',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -30,6 +30,8 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
'name', 'name',
'email', 'email',
'password', 'password',
'entra_tenant_id',
'entra_object_id',
]; ];
/** /**
@ -52,9 +54,15 @@ protected function casts(): array
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_platform_superadmin' => 'bool',
]; ];
} }
public function isPlatformSuperadmin(): bool
{
return (bool) $this->is_platform_superadmin;
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return true; return true;
@ -62,11 +70,17 @@ public function canAccessPanel(Panel $panel): bool
public function tenants(): BelongsToMany public function tenants(): BelongsToMany
{ {
return $this->belongsToMany(Tenant::class) return $this->belongsToMany(Tenant::class, 'tenant_memberships')
->withPivot('role') ->using(TenantMembership::class)
->withPivot(['id', 'role', 'source', 'source_ref', 'created_by_user_id'])
->withTimestamps(); ->withTimestamps();
} }
public function tenantMemberships(): HasMany
{
return $this->hasMany(TenantMembership::class);
}
public function tenantPreferences(): HasMany public function tenantPreferences(): HasMany
{ {
return $this->hasMany(UserTenantPreference::class); return $this->hasMany(UserTenantPreference::class);
@ -76,7 +90,7 @@ private function tenantPivotTableExists(): bool
{ {
static $exists; static $exists;
return $exists ??= Schema::hasTable('tenant_user'); return $exists ??= Schema::hasTable('tenant_memberships');
} }
private function tenantPreferencesTableExists(): bool private function tenantPreferencesTableExists(): bool
@ -116,6 +130,10 @@ public function canAccessTenant(Model $tenant): bool
return false; return false;
} }
if ($this->isPlatformSuperadmin()) {
return true;
}
if (! $this->tenantPivotTableExists()) { if (! $this->tenantPivotTableExists()) {
return false; return false;
} }
@ -127,6 +145,13 @@ public function canAccessTenant(Model $tenant): bool
public function getTenants(Panel $panel): array|Collection public function getTenants(Panel $panel): array|Collection
{ {
if ($this->isPlatformSuperadmin()) {
return Tenant::query()
->where('status', 'active')
->orderBy('name')
->get();
}
if (! $this->tenantPivotTableExists()) { if (! $this->tenantPivotTableExists()) {
return collect(); return collect();
} }
@ -139,6 +164,13 @@ public function getTenants(Panel $panel): array|Collection
public function getDefaultTenant(Panel $panel): ?Model public function getDefaultTenant(Panel $panel): ?Model
{ {
if ($this->isPlatformSuperadmin()) {
return Tenant::query()
->where('status', 'active')
->orderBy('name')
->first();
}
if (! $this->tenantPivotTableExists()) { if (! $this->tenantPivotTableExists()) {
return null; return null;
} }

View File

@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;
class ProviderConnectionPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
$tenant = Tenant::current();
return Gate::forUser($user)->allows('provider.view', $tenant);
}
public function view(User $user, ProviderConnection $connection): Response|bool
{
$tenant = Tenant::current();
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
return false;
}
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
return true;
}
public function create(User $user): bool
{
$tenant = Tenant::current();
return Gate::forUser($user)->allows('provider.manage', $tenant);
}
public function update(User $user, ProviderConnection $connection): Response|bool
{
$tenant = Tenant::current();
if (! Gate::forUser($user)->allows('provider.view', $tenant)) {
return false;
}
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
return true;
}
public function delete(User $user, ProviderConnection $connection): Response|bool
{
$tenant = Tenant::current();
if (! Gate::forUser($user)->allows('provider.manage', $tenant)) {
return false;
}
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
return Response::denyAsNotFound();
}
return false;
}
}

View File

@ -49,11 +49,7 @@ public function register(): void
$this->app->singleton(GraphClientInterface::class, function ($app) { $this->app->singleton(GraphClientInterface::class, function ($app) {
$config = $app['config']->get('graph'); $config = $app['config']->get('graph');
$hasCredentials = ! empty($config['client_id']) if (! empty($config['enabled'])) {
&& ! empty($config['client_secret'])
&& ! empty($config['tenant_id']);
if (! empty($config['enabled']) && $hasCredentials) {
return $app->make(MicrosoftGraphClient::class); return $app->make(MicrosoftGraphClient::class);
} }

View File

@ -0,0 +1,49 @@
<?php
namespace App\Providers;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Policies\ProviderConnectionPolicy;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
ProviderConnection::class => ProviderConnectionPolicy::class,
];
public function boot(): void
{
$this->registerPolicies();
$resolver = app(CapabilityResolver::class);
$defineTenantCapability = function (string $capability) use ($resolver): void {
Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool {
return $resolver->can($user, $tenant, $capability);
});
};
foreach ([
Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_MANAGE,
Capabilities::PROVIDER_RUN,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
Capabilities::AUDIT_VIEW,
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
] as $capability) {
$defineTenantCapability($capability);
}
}
}

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -38,6 +39,10 @@ public function panel(Panel $panel): Panel
->colors([ ->colors([
'primary' => Color::Amber, 'primary' => Color::Amber,
]) ])
->renderHook(
PanelsRenderHook::BODY_START,
fn () => view('filament.partials.break-glass-banner')->render()
)
->renderHook( ->renderHook(
PanelsRenderHook::HEAD_END, PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render() fn () => view('filament.partials.livewire-intercept-shim')->render()
@ -68,6 +73,7 @@ public function panel(Panel $panel): Panel
ShareErrorsFromSession::class, ShareErrorsFromSession::class,
VerifyCsrfToken::class, VerifyCsrfToken::class,
SubstituteBindings::class, SubstituteBindings::class,
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services\Auth;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Support\TenantRole;
/**
* Capability Resolver
*
* Resolves user memberships and capabilities for a given tenant.
* Caches results per request to avoid N+1 queries.
*/
class CapabilityResolver
{
private array $resolvedMemberships = [];
/**
* Get the user's role for a tenant
*/
public function getRole(User $user, Tenant $tenant): ?TenantRole
{
if ($user->isPlatformSuperadmin()) {
return TenantRole::Owner;
}
$membership = $this->getMembership($user, $tenant);
if ($membership === null) {
return null;
}
return TenantRole::tryFrom($membership['role']);
}
/**
* Check if user can perform a capability on a tenant
*/
public function can(User $user, Tenant $tenant, string $capability): bool
{
if ($user->isPlatformSuperadmin()) {
return true;
}
$role = $this->getRole($user, $tenant);
if ($role === null) {
return false;
}
return RoleCapabilityMap::hasCapability($role, $capability);
}
/**
* Check if user has any membership for a tenant
*/
public function isMember(User $user, Tenant $tenant): bool
{
if ($user->isPlatformSuperadmin()) {
return true;
}
return $this->getMembership($user, $tenant) !== null;
}
/**
* Get membership details (cached per request)
*/
private function getMembership(User $user, Tenant $tenant): ?array
{
$cacheKey = "membership_{$user->id}_{$tenant->id}";
if (! isset($this->resolvedMemberships[$cacheKey])) {
$membership = TenantMembership::query()
->where('user_id', $user->id)
->where('tenant_id', $tenant->id)
->first(['role', 'source', 'source_ref']);
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
}
return $this->resolvedMemberships[$cacheKey];
}
/**
* Clear cached memberships (useful for testing or after membership changes)
*/
public function clearCache(): void
{
$this->resolvedMemberships = [];
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Services\Auth;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
/**
* Role to Capability Mapping (Single Source of Truth)
*
* This class defines which capabilities each role has.
* All capability strings MUST be references from the Capabilities registry.
*/
class RoleCapabilityMap
{
private static array $roleCapabilities = [
TenantRole::Owner->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_MANAGE,
Capabilities::PROVIDER_RUN,
Capabilities::AUDIT_VIEW,
],
TenantRole::Manager->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_MANAGE,
Capabilities::PROVIDER_RUN,
Capabilities::AUDIT_VIEW,
],
TenantRole::Operator->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_RUN,
Capabilities::AUDIT_VIEW,
],
TenantRole::Readonly->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::PROVIDER_VIEW,
Capabilities::AUDIT_VIEW,
],
];
/**
* Get all capabilities for a given role
*
* @return array<string>
*/
public static function getCapabilities(TenantRole|string $role): array
{
$roleValue = $role instanceof TenantRole ? $role->value : $role;
return self::$roleCapabilities[$roleValue] ?? [];
}
/**
* Check if a role has a specific capability
*/
public static function hasCapability(TenantRole|string $role, string $capability): bool
{
return in_array($capability, self::getCapabilities($role), true);
}
}

View File

@ -0,0 +1,236 @@
<?php
namespace App\Services\Auth;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Support\TenantRole;
use DomainException;
use Illuminate\Support\Facades\DB;
class TenantMembershipManager
{
public function __construct(public AuditLogger $auditLogger) {}
public function addMember(
Tenant $tenant,
User $actor,
User $member,
TenantRole $role,
string $source = 'manual',
?string $sourceRef = null,
): TenantMembership {
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership {
$existing = TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('user_id', $member->getKey())
->first();
if ($existing) {
if ($existing->role !== $role->value) {
$existing->forceFill([
'role' => $role->value,
'source' => $source,
'source_ref' => $sourceRef,
'created_by_user_id' => (int) $actor->getKey(),
])->save();
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.role_change',
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'from_role' => $existing->getOriginal('role'),
'to_role' => $role->value,
'source' => $source,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
return $existing->refresh();
}
$membership = TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $member->getKey(),
'role' => $role->value,
'source' => $source,
'source_ref' => $sourceRef,
'created_by_user_id' => (int) $actor->getKey(),
]);
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.add',
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'role' => $role->value,
'source' => $source,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
return $membership;
});
}
public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, TenantRole $newRole): TenantMembership
{
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership {
$membership->refresh();
if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.');
}
$oldRole = $membership->role;
if ($oldRole === $newRole->value) {
return $membership;
}
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
$membership->forceFill([
'role' => $newRole->value,
])->save();
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.role_change',
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole->value,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
return $membership->refresh();
});
}
public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void
{
DB::transaction(function () use ($tenant, $actor, $membership): void {
$membership->refresh();
if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.');
}
$this->guardLastOwnerRemoval($tenant, $membership);
$memberUserId = (int) $membership->user_id;
$oldRole = (string) $membership->role;
$membership->delete();
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.remove',
context: [
'metadata' => [
'member_user_id' => $memberUserId,
'role' => $oldRole,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
});
}
public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership
{
$membership = $this->addMember(
tenant: $tenant,
actor: $actor,
member: $member,
role: TenantRole::Owner,
source: 'break_glass',
);
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.bootstrap_recover',
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
return $membership;
}
private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void
{
if ($membership->role !== TenantRole::Owner->value) {
return;
}
$owners = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('role', TenantRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot remove the last remaining owner.');
}
}
private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, TenantRole $newRole): void
{
if ($membership->role !== TenantRole::Owner->value) {
return;
}
if ($newRole === TenantRole::Owner) {
return;
}
$owners = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('role', TenantRole::Owner->value)
->count();
if ($owners <= 1) {
throw new DomainException('You cannot demote the last remaining owner.');
}
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Services\Providers\Contracts;
final class HealthResult
{
/**
* @param array<string, mixed> $meta
*/
public function __construct(
public readonly bool $healthy,
public readonly string $status,
public readonly string $healthStatus,
public readonly ?string $reasonCode = null,
public readonly ?string $message = null,
public readonly array $meta = [],
) {}
/**
* @param array<string, mixed> $meta
*/
public static function ok(string $status = 'connected', string $healthStatus = 'ok', array $meta = []): self
{
return new self(true, $status, $healthStatus, null, null, $meta);
}
/**
* @param array<string, mixed> $meta
*/
public static function failed(
string $reasonCode,
string $message,
string $status = 'error',
string $healthStatus = 'down',
array $meta = [],
): self {
return new self(false, $status, $healthStatus, $reasonCode, $message, $meta);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\Providers\Contracts;
use App\Models\ProviderConnection;
interface ProviderComplianceCollector
{
/**
* @return array<string, int>
*/
public function snapshot(ProviderConnection $connection): array;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\Providers\Contracts;
use App\Models\ProviderConnection;
interface ProviderDirectoryCollector
{
/**
* @return array<string, mixed>
*/
public function collect(ProviderConnection $connection): array;
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Services\Providers\Contracts;
use App\Models\ProviderConnection;
interface ProviderHealthCheck
{
public function check(ProviderConnection $connection): HealthResult;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\Providers\Contracts;
use App\Models\ProviderConnection;
interface ProviderInventoryCollector
{
/**
* @return array<string, int>
*/
public function collect(ProviderConnection $connection): array;
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Services\Providers\Contracts;
use App\Models\ProviderConnection;
interface ProviderScriptExecutor
{
/**
* @param array<string, mixed> $script
* @return array<string, mixed>
*/
public function execute(ProviderConnection $connection, array $script): array;
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use InvalidArgumentException;
use RuntimeException;
final class CredentialManager
{
/**
* @return array{client_id: string, client_secret: string}
*/
public function getClientCredentials(ProviderConnection $connection): array
{
$credential = $connection->credential;
if (! $credential instanceof ProviderCredential) {
throw new RuntimeException('Provider credentials are missing.');
}
if ($credential->type !== 'client_secret') {
throw new RuntimeException('Unsupported provider credential type.');
}
$payload = $credential->payload;
if (! is_array($payload)) {
throw new RuntimeException('Provider credential payload is invalid.');
}
$clientId = trim((string) ($payload['client_id'] ?? ''));
$clientSecret = trim((string) ($payload['client_secret'] ?? ''));
if ($clientId === '' || $clientSecret === '') {
throw new RuntimeException('Provider credential payload is missing required keys.');
}
$tenantId = $payload['tenant_id'] ?? null;
if (is_string($tenantId) && $tenantId !== '' && $tenantId !== $connection->entra_tenant_id) {
throw new InvalidArgumentException('Provider credential tenant_id does not match the connection entra_tenant_id.');
}
return [
'client_id' => $clientId,
'client_secret' => $clientSecret,
];
}
public function upsertClientSecretCredential(
ProviderConnection $connection,
string $clientId,
string $clientSecret,
): ProviderCredential {
$clientId = trim($clientId);
$clientSecret = trim($clientSecret);
if ($clientId === '' || $clientSecret === '') {
throw new InvalidArgumentException('client_id and client_secret are required.');
}
return ProviderCredential::query()->updateOrCreate(
[
'provider_connection_id' => $connection->getKey(),
],
[
'type' => 'client_secret',
'payload' => [
'client_id' => $clientId,
'client_secret' => $clientSecret,
],
],
);
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\Contracts\ProviderComplianceCollector;
use RuntimeException;
final class MicrosoftComplianceSnapshotService implements ProviderComplianceCollector
{
private const int MAX_PAGES = 50;
public function __construct(
private readonly ProviderGateway $gateway,
private readonly GraphContractRegistry $contracts,
) {}
public function snapshot(ProviderConnection $connection): array
{
$resource = $this->contracts->resourcePath('managedDevices');
if (! is_string($resource) || $resource === '') {
throw new RuntimeException('Graph contract missing for managed devices.');
}
$queryInput = [
'$top' => 999,
'$select' => 'id,complianceState',
];
$sanitized = $this->contracts->sanitizeQuery('managedDevices', $queryInput);
$query = $sanitized['query'];
$counts = [
'total' => 0,
'compliant' => 0,
'noncompliant' => 0,
'unknown' => 0,
];
$path = $resource;
$pages = 0;
while (true) {
$pages++;
if ($pages > self::MAX_PAGES) {
throw new RuntimeException('Graph pagination exceeded maximum page limit.');
}
$options = $query === [] ? [] : ['query' => $query];
$response = $this->gateway->request(
connection: $connection,
method: 'GET',
path: $path,
options: $options,
);
$payload = $this->requireSuccess($response);
$items = $payload['value'] ?? [];
if (! is_array($items)) {
$items = [];
}
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$counts['total']++;
$state = strtolower((string) ($item['complianceState'] ?? ''));
if ($state === 'compliant') {
$counts['compliant']++;
} elseif ($state === 'noncompliant') {
$counts['noncompliant']++;
} else {
$counts['unknown']++;
}
}
$nextLink = $payload['@odata.nextLink'] ?? null;
if (! is_string($nextLink) || $nextLink === '') {
break;
}
$path = $nextLink;
$query = [];
}
return $counts;
}
/**
* @return array<string, mixed>
*/
private function requireSuccess(GraphResponse $response): array
{
if ($response->successful()) {
$data = $response->data;
return is_array($data) ? $data : [];
}
$message = $this->messageForResponse($response);
$status = (int) ($response->status ?? 0);
throw new RuntimeException("Graph request failed (status {$status}): {$message}");
}
private function messageForResponse(GraphResponse $response): string
{
$error = $response->errors[0] ?? null;
if (is_string($error)) {
return $error;
}
if (is_array($error)) {
$message = $error['message'] ?? null;
if (is_string($message) && $message !== '') {
return $message;
}
return json_encode($error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'Request failed.';
}
return 'Request failed.';
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\Contracts\ProviderHealthCheck;
use App\Support\OpsUx\RunFailureSanitizer;
use Throwable;
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
{
public function __construct(private readonly ProviderGateway $gateway) {}
public function check(ProviderConnection $connection): HealthResult
{
try {
$response = $this->gateway->getOrganization($connection);
} catch (Throwable $throwable) {
$message = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
$reasonCode = RunFailureSanitizer::normalizeReasonCode($throwable->getMessage());
return HealthResult::failed(
reasonCode: $reasonCode,
message: $message !== '' ? $message : 'Health check failed.',
status: $this->statusForReason($reasonCode),
healthStatus: $this->healthForReason($reasonCode),
);
}
if ($response->successful()) {
return HealthResult::ok(
status: 'connected',
healthStatus: 'ok',
meta: [
'organization_id' => $response->data['id'] ?? null,
'organization_display_name' => $response->data['displayName'] ?? null,
],
);
}
$reasonCode = $this->reasonCodeForResponse($response);
$message = RunFailureSanitizer::sanitizeMessage($this->messageForResponse($response));
return HealthResult::failed(
reasonCode: $reasonCode,
message: $message !== '' ? $message : 'Health check failed.',
status: $this->statusForReason($reasonCode),
healthStatus: $this->healthForReason($reasonCode),
meta: [
'http_status' => $response->status,
],
);
}
private function reasonCodeForResponse(GraphResponse $response): string
{
return match ((int) ($response->status ?? 0)) {
401 => RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
403 => RunFailureSanitizer::REASON_PERMISSION_DENIED,
429 => RunFailureSanitizer::REASON_GRAPH_THROTTLED,
500, 502 => RunFailureSanitizer::REASON_PROVIDER_OUTAGE,
503, 504 => RunFailureSanitizer::REASON_GRAPH_TIMEOUT,
default => RunFailureSanitizer::REASON_UNKNOWN_ERROR,
};
}
private function messageForResponse(GraphResponse $response): string
{
$error = $response->errors[0] ?? null;
if (is_string($error)) {
return $error;
}
if (is_array($error)) {
$message = $error['message'] ?? null;
if (is_string($message) && $message !== '') {
return $message;
}
return json_encode($error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'Health check failed.';
}
return 'Health check failed.';
}
private function statusForReason(string $reasonCode): string
{
return match ($reasonCode) {
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
RunFailureSanitizer::REASON_PERMISSION_DENIED => 'needs_consent',
default => 'error',
};
}
private function healthForReason(string $reasonCode): string
{
return match ($reasonCode) {
RunFailureSanitizer::REASON_GRAPH_THROTTLED => 'degraded',
RunFailureSanitizer::REASON_GRAPH_TIMEOUT,
RunFailureSanitizer::REASON_PROVIDER_OUTAGE => 'down',
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
RunFailureSanitizer::REASON_PERMISSION_DENIED => 'down',
default => 'down',
};
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\Contracts\ProviderInventoryCollector;
use RuntimeException;
final class MicrosoftProviderInventoryCollector implements ProviderInventoryCollector
{
/**
* @var array<int, string>
*/
private array $policyTypes = [
'deviceConfiguration',
'settingsCatalogPolicy',
'groupPolicyConfiguration',
];
public function __construct(private readonly ProviderGateway $gateway) {}
public function collect(ProviderConnection $connection): array
{
$total = 0;
foreach ($this->policyTypes as $policyType) {
$response = $this->gateway->listPolicies($connection, $policyType);
if ($response->failed()) {
$message = $this->messageForResponse($response);
$status = (int) ($response->status ?? 0);
throw new RuntimeException("Graph request failed for {$policyType} (status {$status}): {$message}");
}
$items = is_array($response->data) ? $response->data : [];
$total += count($items);
}
return [
'total' => $total,
'items' => $total,
];
}
private function messageForResponse(GraphResponse $response): string
{
$error = $response->errors[0] ?? null;
if (is_string($error)) {
return $error;
}
if (is_array($error)) {
$message = $error['message'] ?? null;
if (is_string($message) && $message !== '') {
return $message;
}
return json_encode($error, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'Request failed.';
}
return 'Request failed.';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use Illuminate\Support\Str;
final class ProviderGateway
{
public function __construct(
private readonly GraphClientInterface $graph,
private readonly CredentialManager $credentials,
) {}
public function getOrganization(ProviderConnection $connection): GraphResponse
{
return $this->graph->getOrganization($this->graphOptions($connection));
}
public function listPolicies(ProviderConnection $connection, string $policyType, array $options = []): GraphResponse
{
return $this->graph->listPolicies($policyType, $this->graphOptions($connection, $options));
}
public function request(ProviderConnection $connection, string $method, string $path, array $options = []): GraphResponse
{
return $this->graph->request($method, $path, $this->graphOptions($connection, $options));
}
/**
* @return array<string, mixed>
*/
public function graphOptions(ProviderConnection $connection, array $overrides = []): array
{
$clientCredentials = $this->credentials->getClientCredentials($connection);
return array_merge([
'tenant' => $connection->entra_tenant_id,
'client_id' => $clientCredentials['client_id'],
'client_secret' => $clientCredentials['client_secret'],
'client_request_id' => (string) Str::uuid(),
], $overrides);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Services\Providers;
use InvalidArgumentException;
final class ProviderOperationRegistry
{
/**
* @return array<string, array{provider: string, module: string, label: string}>
*/
public function all(): array
{
return [
'provider.connection.check' => [
'provider' => 'microsoft',
'module' => 'health_check',
'label' => 'Provider connection check',
],
'inventory.sync' => [
'provider' => 'microsoft',
'module' => 'inventory',
'label' => 'Inventory sync',
],
'compliance.snapshot' => [
'provider' => 'microsoft',
'module' => 'compliance',
'label' => 'Compliance snapshot',
],
];
}
public function isAllowed(string $operationType): bool
{
return array_key_exists($operationType, $this->all());
}
/**
* @return array{provider: string, module: string, label: string}
*/
public function get(string $operationType): array
{
$operationType = trim($operationType);
$definition = $this->all()[$operationType] ?? null;
if (! is_array($definition)) {
throw new InvalidArgumentException("Unknown provider operation type: {$operationType}");
}
return $definition;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Services\Providers;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use ReflectionFunction;
use ReflectionMethod;
final class ProviderOperationStartGate
{
public function __construct(
private readonly OperationRunService $runs,
private readonly ProviderOperationRegistry $registry,
) {}
/**
* @param array<string, mixed> $extraContext
*/
public function start(
Tenant $tenant,
ProviderConnection $connection,
string $operationType,
callable $dispatcher,
?User $initiator = null,
array $extraContext = [],
): ProviderOperationStartResult {
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
throw new InvalidArgumentException('ProviderConnection does not belong to the given tenant.');
}
$definition = $this->registry->get($operationType);
return DB::transaction(function () use ($tenant, $connection, $operationType, $dispatcher, $initiator, $extraContext, $definition): ProviderOperationStartResult {
$lockedConnection = ProviderConnection::query()
->whereKey($connection->getKey())
->lockForUpdate()
->firstOrFail();
$activeRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id')
->first();
if ($activeRun instanceof OperationRun) {
if ($activeRun->type === $operationType) {
return ProviderOperationStartResult::deduped($activeRun);
}
return ProviderOperationStartResult::scopeBusy($activeRun);
}
$context = array_merge($extraContext, [
'provider' => $lockedConnection->provider,
'module' => $definition['module'],
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
],
]);
$run = $this->runs->ensureRunWithIdentity(
tenant: $tenant,
type: $operationType,
identityInputs: [
'provider_connection_id' => (int) $lockedConnection->getKey(),
],
context: $context,
initiator: $initiator,
);
$dispatched = false;
if ($run->wasRecentlyCreated) {
$this->invokeDispatcher($dispatcher, $run);
$dispatched = true;
}
return ProviderOperationStartResult::started($run, $dispatched);
});
}
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
{
$ref = null;
if (is_array($dispatcher) && count($dispatcher) === 2) {
$ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]);
} elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) {
[$class, $method] = explode('::', $dispatcher, 2);
$ref = new ReflectionMethod($class, $method);
} elseif ($dispatcher instanceof \Closure) {
$ref = new ReflectionFunction($dispatcher);
} elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) {
$ref = new ReflectionMethod($dispatcher, '__invoke');
}
if ($ref && $ref->getNumberOfParameters() >= 1) {
$dispatcher($run);
return;
}
$dispatcher();
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Services\Providers;
use App\Models\OperationRun;
final class ProviderOperationStartResult
{
private function __construct(
public readonly string $status,
public readonly OperationRun $run,
public readonly bool $dispatched,
) {}
public static function started(OperationRun $run, bool $dispatched): self
{
return new self('started', $run, $dispatched);
}
public static function deduped(OperationRun $run): self
{
return new self('deduped', $run, false);
}
public static function scopeBusy(OperationRun $run): self
{
return new self('scope_busy', $run, false);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Support\Auth;
/**
* Canonical Capability Registry
*
* This is the single source of truth for all capability strings in the system.
* All role-to-capability mappings must reference only these constants.
*/
class Capabilities
{
// Tenants
public const TENANT_VIEW = 'tenant.view';
public const TENANT_MANAGE = 'tenant.manage';
public const TENANT_DELETE = 'tenant.delete';
public const TENANT_SYNC = 'tenant.sync';
// Tenant memberships
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
public const TENANT_MEMBERSHIP_MANAGE = 'tenant_membership.manage';
// Optional mappings (no Graph resolution in v1)
public const TENANT_ROLE_MAPPING_VIEW = 'tenant_role_mapping.view';
public const TENANT_ROLE_MAPPING_MANAGE = 'tenant_role_mapping.manage';
// Providers (existing gate names used throughout the app)
public const PROVIDER_VIEW = 'provider.view';
public const PROVIDER_MANAGE = 'provider.manage';
public const PROVIDER_RUN = 'provider.run';
// Audit
public const AUDIT_VIEW = 'audit.view';
/**
* Get all capability constants
*
* @return array<string>
*/
public static function all(): array
{
$reflection = new \ReflectionClass(self::class);
return array_values($reflection->getConstants());
}
}

View File

@ -34,6 +34,8 @@ final class BadgeCatalog
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class, BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class, BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
]; ];
/** /**

View File

@ -26,4 +26,6 @@ enum BadgeDomain: string
case IgnoredAt = 'ignored_at'; case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision'; case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status'; case RestoreResultStatus = 'restore_result_status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
} }

View File

@ -0,0 +1,23 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConnectionHealthBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
'down' => new BadgeSpec('Down', 'danger', 'heroicon-m-x-circle'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConnectionStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'connected' => new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'),
'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Support\Middleware;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DenyNonMemberTenantAccess
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$tenant = $request->route()?->parameter('tenant');
if (! $tenant instanceof Tenant) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
abort(404);
}
return $next($request);
}
}

View File

@ -18,7 +18,9 @@ public static function labels(): array
'policy.delete' => 'Delete policies', 'policy.delete' => 'Delete policies',
'policy.unignore' => 'Restore policies', 'policy.unignore' => 'Restore policies',
'policy.export' => 'Export policies to backup', 'policy.export' => 'Export policies to backup',
'provider.connection.check' => 'Provider connection check',
'inventory.sync' => 'Inventory sync', 'inventory.sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot',
'directory_groups.sync' => 'Directory groups sync', 'directory_groups.sync' => 'Directory groups sync',
'drift.generate' => 'Drift generation', 'drift.generate' => 'Drift generation',
'backup_set.add_policies' => 'Backup set update', 'backup_set.add_policies' => 'Backup set update',
@ -54,8 +56,10 @@ public static function expectedDurationSeconds(string $operationType): ?int
{ {
return match (trim($operationType)) { return match (trim($operationType)) {
'policy.sync', 'policy.sync_one' => 90, 'policy.sync', 'policy.sync_one' => 90,
'provider.connection.check' => 30,
'policy.export' => 120, 'policy.export' => 120,
'inventory.sync' => 180, 'inventory.sync' => 180,
'compliance.snapshot' => 180,
'directory_groups.sync' => 120, 'directory_groups.sync' => 120,
'drift.generate' => 240, 'drift.generate' => 240,
default => null, default => null,

View File

@ -9,6 +9,7 @@
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -36,6 +37,13 @@ public static function related(OperationRun $run, Tenant $tenant): array
$links['Operations'] = self::index($tenant); $links['Operations'] = self::index($tenant);
$providerConnectionId = $context['provider_connection_id'] ?? null;
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant);
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant);
}
if ($run->type === 'inventory.sync') { if ($run->type === 'inventory.sync') {
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant); $links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
} }

View File

@ -16,6 +16,9 @@ public static function all(): array
'succeeded', 'succeeded',
'failed', 'failed',
'skipped', 'skipped',
'compliant',
'noncompliant',
'unknown',
'created', 'created',
'updated', 'updated',
'deleted', 'deleted',

View File

@ -10,6 +10,10 @@ final class RunFailureSanitizer
public const string REASON_PERMISSION_DENIED = 'permission_denied'; public const string REASON_PERMISSION_DENIED = 'permission_denied';
public const string REASON_PROVIDER_AUTH_FAILED = 'provider_auth_failed';
public const string REASON_PROVIDER_OUTAGE = 'provider_outage';
public const string REASON_VALIDATION_ERROR = 'validation_error'; public const string REASON_VALIDATION_ERROR = 'validation_error';
public const string REASON_CONFLICT_DETECTED = 'conflict_detected'; public const string REASON_CONFLICT_DETECTED = 'conflict_detected';
@ -39,6 +43,8 @@ public static function normalizeReasonCode(string $candidate): string
self::REASON_GRAPH_THROTTLED, self::REASON_GRAPH_THROTTLED,
self::REASON_GRAPH_TIMEOUT, self::REASON_GRAPH_TIMEOUT,
self::REASON_PERMISSION_DENIED, self::REASON_PERMISSION_DENIED,
self::REASON_PROVIDER_AUTH_FAILED,
self::REASON_PROVIDER_OUTAGE,
self::REASON_VALIDATION_ERROR, self::REASON_VALIDATION_ERROR,
self::REASON_CONFLICT_DETECTED, self::REASON_CONFLICT_DETECTED,
self::REASON_UNKNOWN_ERROR, self::REASON_UNKNOWN_ERROR,
@ -65,10 +71,18 @@ public static function normalizeReasonCode(string $candidate): string
return self::REASON_GRAPH_THROTTLED; return self::REASON_GRAPH_THROTTLED;
} }
if (str_contains($candidate, 'invalid_client') || str_contains($candidate, 'invalid_grant') || str_contains($candidate, '401') || str_contains($candidate, 'aadsts')) {
return self::REASON_PROVIDER_AUTH_FAILED;
}
if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) { if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) {
return self::REASON_GRAPH_TIMEOUT; return self::REASON_GRAPH_TIMEOUT;
} }
if (str_contains($candidate, 'outage') || str_contains($candidate, '500') || str_contains($candidate, '502') || str_contains($candidate, 'bad_gateway')) {
return self::REASON_PROVIDER_OUTAGE;
}
if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) { if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) {
return self::REASON_PERMISSION_DENIED; return self::REASON_PERMISSION_DENIED;
} }
@ -91,13 +105,24 @@ public static function sanitizeMessage(string $message): string
// Redact obvious PII (emails). // Redact obvious PII (emails).
$message = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $message) ?? $message; $message = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $message) ?? $message;
// Redact obvious bearer tokens / secrets. // Redact obvious auth headers.
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message; $message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message; $message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
// Redact common secret-like key/value patterns.
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
// Redact long opaque blobs that look token-like. // Redact long opaque blobs that look token-like.
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message; $message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
// Ensure forbidden substrings never leak into stored messages.
$message = str_ireplace(
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
'[REDACTED]',
$message,
);
return substr($message, 0, 120); return substr($message, 0, 120);
} }
} }

View File

@ -37,4 +37,28 @@ public function canRunBackupSchedules(): bool
self::Readonly => false, self::Readonly => false,
}; };
} }
public function canViewProviders(): bool
{
return true;
}
public function canManageProviders(): bool
{
return match ($this) {
self::Owner,
self::Manager => true,
default => false,
};
}
public function canRunProviderOperations(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
} }

View File

@ -2,5 +2,6 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class, App\Providers\Filament\AdminPanelProvider::class,
]; ];

View File

@ -1,7 +1,7 @@
<?php <?php
return [ return [
'enabled' => (bool) (env('GRAPH_CLIENT_ID') && env('GRAPH_CLIENT_SECRET') && env('GRAPH_TENANT_ID')), 'enabled' => (bool) env('GRAPH_ENABLED', (bool) (env('GRAPH_CLIENT_ID') && env('GRAPH_CLIENT_SECRET') && env('GRAPH_TENANT_ID'))),
'tenant_id' => env('GRAPH_TENANT_ID', ''), 'tenant_id' => env('GRAPH_TENANT_ID', ''),
'client_id' => env('GRAPH_CLIENT_ID', ''), 'client_id' => env('GRAPH_CLIENT_ID', ''),

View File

@ -17,6 +17,11 @@
'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'], 'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'],
'allowed_expand' => [], 'allowed_expand' => [],
], ],
'managedDevices' => [
'resource' => 'deviceManagement/managedDevices',
'allowed_select' => ['id', 'complianceState'],
'allowed_expand' => [],
],
'deviceConfiguration' => [ 'deviceConfiguration' => [
'resource' => 'deviceManagement/deviceConfigurations', 'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProviderConnection>
*/
class ProviderConnectionFactory extends Factory
{
protected $model = ProviderConnection::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'display_name' => fake()->company(),
'is_default' => false,
'status' => 'needs_consent',
'health_status' => 'unknown',
'scopes_granted' => [],
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
'metadata' => [],
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProviderCredential>
*/
class ProviderCredentialFactory extends Factory
{
protected $model = ProviderCredential::class;
public function definition(): array
{
return [
'provider_connection_id' => ProviderConnection::factory(),
'type' => 'client_secret',
'payload' => [
'client_id' => fake()->uuid(),
'client_secret' => fake()->sha1(),
],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('provider_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('provider');
$table->string('entra_tenant_id');
$table->string('display_name');
$table->boolean('is_default')->default(false);
$table->string('status')->default('needs_consent');
$table->string('health_status')->default('unknown');
$table->jsonb('scopes_granted')->default('{}');
$table->timestamp('last_health_check_at')->nullable();
$table->string('last_error_reason_code')->nullable();
$table->string('last_error_message')->nullable();
$table->jsonb('metadata')->default('{}');
$table->timestamps();
$table->unique(['tenant_id', 'provider', 'entra_tenant_id']);
$table->index(['tenant_id', 'provider', 'status']);
$table->index(['tenant_id', 'provider', 'health_status']);
});
DB::statement('CREATE UNIQUE INDEX provider_connections_default_unique ON provider_connections (tenant_id, provider) WHERE is_default = true');
}
public function down(): void
{
Schema::dropIfExists('provider_connections');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('provider_credentials', function (Blueprint $table) {
$table->id();
$table->foreignId('provider_connection_id')
->constrained('provider_connections')
->cascadeOnDelete();
$table->string('type')->default('client_secret');
$table->text('payload');
$table->timestamps();
$table->unique('provider_connection_id');
});
}
public function down(): void
{
Schema::dropIfExists('provider_credentials');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_memberships', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
$table->enum('source', ['manual', 'entra_group', 'entra_app_role', 'break_glass'])->default('manual');
$table->string('source_ref')->nullable();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['tenant_id', 'user_id']);
$table->index(['tenant_id', 'role']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_memberships');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_role_mappings', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->enum('mapping_type', ['entra_group', 'entra_app_role']);
$table->string('external_id');
$table->enum('role', ['owner', 'manager', 'operator', 'readonly']);
$table->boolean('is_enabled')->default(true);
$table->timestamps();
$table->unique(['tenant_id', 'mapping_type', 'external_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_role_mappings');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('entra_tenant_id')->nullable()->after('email');
$table->string('entra_object_id')->nullable()->after('entra_tenant_id');
$table->unique(['entra_tenant_id', 'entra_object_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['entra_tenant_id', 'entra_object_id']);
$table->dropColumn(['entra_tenant_id', 'entra_object_id']);
});
}
};

View File

@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('tenant_user')) {
return;
}
if (! Schema::hasTable('tenant_memberships')) {
return;
}
if (DB::table('tenant_memberships')->exists()) {
return;
}
$now = now();
$rows = [];
foreach (DB::table('tenant_user')->select(['tenant_id', 'user_id', 'role'])->cursor() as $pivot) {
$rows[] = [
'id' => (string) Str::uuid(),
'tenant_id' => (int) $pivot->tenant_id,
'user_id' => (int) $pivot->user_id,
'role' => is_string($pivot->role) && $pivot->role !== '' ? $pivot->role : 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
'created_at' => $now,
'updated_at' => $now,
];
if (count($rows) >= 500) {
DB::table('tenant_memberships')->insertOrIgnore($rows);
$rows = [];
}
}
if ($rows !== []) {
DB::table('tenant_memberships')->insertOrIgnore($rows);
}
}
/**
* Reverse the migrations.
*/
public function down(): void {}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_platform_superadmin')->default(false)->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_platform_superadmin');
});
}
};

View File

@ -42,7 +42,11 @@ services:
WWWGROUP: '${WWWGROUP:-1000}' WWWGROUP: '${WWWGROUP:-1000}'
LARAVEL_SAIL: 1 LARAVEL_SAIL: 1
APP_SERVICE: queue APP_SERVICE: queue
entrypoint: ["/bin/sh", "-c", "mkdir -p /var/www/html/node_modules && chown ${WWWUSER:-1000}:${WWWGROUP:-1000} /var/www/html/node_modules || true; exec start-container"] entrypoint:
- /bin/sh
- -c
- mkdir -p /var/www/html/node_modules && chown ${WWWUSER:-1000}:${WWWGROUP:-1000} /var/www/html/node_modules || true; exec start-container "$@"
- --
volumes: volumes:
- '.:/var/www/html' - '.:/var/www/html'
- '/var/www/html/node_modules' - '/var/www/html/node_modules'

View File

@ -0,0 +1,11 @@
<x-filament-panels::page>
<div class="space-y-2">
<p class="text-sm text-gray-700 dark:text-gray-200">
Use this page to recover tenant access by assigning an <span class="font-semibold">Owner</span> membership.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
All recovery actions are audited.
</p>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,18 @@
@php
/** @var \App\Models\User|null $user */
$user = auth()->user();
@endphp
@if ($user instanceof \App\Models\User && $user->isPlatformSuperadmin())
<div class="fi-topbar sticky top-0 z-50 border-b border-red-500/30 bg-red-600 text-white">
<div class="mx-auto flex max-w-screen-2xl items-center justify-between gap-4 px-4 py-2">
<div class="text-sm font-semibold">
Break-glass mode: platform superadmin access
</div>
<div class="text-xs opacity-90">
Use for recovery only. All actions are audited.
</div>
</div>
</div>
@endif

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Provider Foundation v1 (Microsoft-first, Security-first)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation run: 2026-01-23
- All checks passed; spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,38 @@
# Contracts: Graph Contract Registry Updates (Provider Foundation v1)
**Branch**: `061-provider-foundation`
**Date**: 2026-01-24
## Purpose
Provider foundation operations must not introduce ad-hoc Microsoft Graph endpoints. Any new Graph resources required by provider modules should be added to the central registry (`config/graph_contracts.php`) and consumed via `GraphClientInterface`.
## Required additions (v1)
### 1) Connection health check
**Operation**: Provider connection health check (“ping”)
**Graph call**: `GET /organization` (basic org metadata)
**Registry note**: Ensure the org endpoint is modeled/approved in the contract registry (either as a first-class type or as an explicitly allowed internal call within the Graph client).
---
### 2) Compliance snapshot (counts)
**Operation**: Compliance snapshot (device compliance state counts)
**Graph calls** (one of the contract-approved patterns):
- Option A: List managed devices with a minimal select and compute counts client-side.
- Resource: `deviceManagement/managedDevices`
- Required fields: `id`, `complianceState`
- Option B: Use Graph count endpoints / filtered counts if supported reliably.
- Resource: `deviceManagement/managedDevices/$count` (with filters)
**Registry note**: Add a contract entry for managed devices (and any count strategy used) with allowed selects/filters to prevent accidental over-fetching.
---
## Existing registry reuse
Provider inventory collection should prefer existing policy-type contracts already present in `config/graph_contracts.php` (e.g., Intune policy types and directory group listing), rather than introducing new “quick endpoints”.

View File

@ -0,0 +1,328 @@
openapi: 3.0.3
info:
title: Provider Foundation v1 (Internal)
version: 1.0.0
description: >
Conceptual API contract for Provider Connections and Provider Operations.
This is an internal planning artifact for the admin suite.
servers:
- url: https://example.invalid
paths:
/tenants/{tenantId}/provider-connections:
get:
summary: List provider connections
parameters:
- $ref: '#/components/parameters/TenantId'
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ProviderConnection'
post:
summary: Create provider connection
parameters:
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateProviderConnectionRequest'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnection'
'409':
description: Duplicate connection
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/tenants/{tenantId}/provider-connections/{connectionId}:
get:
summary: Get provider connection
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ConnectionId'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnection'
patch:
summary: Update provider connection (display, default, disable)
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ConnectionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateProviderConnectionRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnection'
/tenants/{tenantId}/provider-connections/{connectionId}/credentials:
put:
summary: Attach / rotate credentials (secret never returned)
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ConnectionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpsertCredentialsRequest'
responses:
'204':
description: Updated
/tenants/{tenantId}/provider-connections/{connectionId}/operations/health-check:
post:
summary: Start connection health check (OperationRun-backed)
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ConnectionId'
responses:
'201':
description: Run created
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunRef'
'200':
description: Returned existing active run (dedupe)
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunRef'
'409':
description: Scope busy (different operation already active for same scope)
content:
application/json:
schema:
$ref: '#/components/schemas/ScopeBusyResponse'
/tenants/{tenantId}/provider-connections/{connectionId}/operations/inventory:
post:
summary: Start inventory collection (OperationRun-backed)
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ConnectionId'
responses:
'201':
description: Run created
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunRef'
'200':
description: Returned existing active run (dedupe)
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunRef'
'409':
description: Scope busy (different operation already active for same scope)
content:
application/json:
schema:
$ref: '#/components/schemas/ScopeBusyResponse'
/tenants/{tenantId}/provider-connections/{connectionId}/operations/compliance-snapshot:
post:
summary: Start compliance snapshot (counts) (OperationRun-backed)
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ConnectionId'
responses:
'201':
description: Run created
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunRef'
'200':
description: Returned existing active run (dedupe)
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunRef'
'409':
description: Scope busy (different operation already active for same scope)
content:
application/json:
schema:
$ref: '#/components/schemas/ScopeBusyResponse'
components:
parameters:
TenantId:
name: tenantId
in: path
required: true
schema:
type: integer
format: int64
ConnectionId:
name: connectionId
in: path
required: true
schema:
type: string
format: uuid
schemas:
ProviderConnection:
type: object
required:
- id
- tenant_id
- provider
- entra_tenant_id
- display_name
- is_default
- status
- health_status
properties:
id:
type: string
format: uuid
tenant_id:
type: integer
format: int64
provider:
type: string
enum: [microsoft]
entra_tenant_id:
type: string
description: Entra tenant ID (GUID)
display_name:
type: string
is_default:
type: boolean
status:
type: string
enum: [connected, needs_consent, error, disabled]
health_status:
type: string
enum: [ok, degraded, down]
last_health_check_at:
type: string
format: date-time
nullable: true
last_error_reason_code:
type: string
nullable: true
last_error_message:
type: string
nullable: true
CreateProviderConnectionRequest:
type: object
required:
- provider
- entra_tenant_id
- display_name
properties:
provider:
type: string
enum: [microsoft]
entra_tenant_id:
type: string
display_name:
type: string
is_default:
type: boolean
default: false
UpdateProviderConnectionRequest:
type: object
properties:
display_name:
type: string
is_default:
type: boolean
status:
type: string
enum: [connected, needs_consent, error, disabled]
UpsertCredentialsRequest:
type: object
required:
- type
- client_id
- client_secret
properties:
type:
type: string
enum: [client_secret]
client_id:
type: string
client_secret:
type: string
format: password
OperationRunRef:
type: object
required:
- id
- type
- status
- outcome
properties:
id:
type: integer
format: int64
type:
type: string
status:
type: string
outcome:
type: string
view_url:
type: string
nullable: true
ScopeBusyResponse:
type: object
required:
- error
- active_run
properties:
error:
type: string
enum: [scope_busy]
message:
type: string
active_run:
$ref: '#/components/schemas/OperationRunRef'
ErrorResponse:
type: object
required:
- error
properties:
error:
type: string
message:
type: string

View File

@ -0,0 +1,92 @@
# Data Model: Provider Foundation v1
**Branch**: `061-provider-foundation`
**Date**: 2026-01-24
**Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md`
## Entities
### ProviderConnection
Represents a tenant-scoped connection to an external provider (v1: Microsoft).
**Identity**
- Scoped to a Suite Tenant (`tenant_id`).
- Canonical target scope identifier for Microsoft: `entra_tenant_id` (GUID).
- Uniqueness: `(tenant_id, provider, entra_tenant_id)` must be unique.
**Fields (suggested)**
- `id` (UUID)
- `tenant_id` (FK → tenants)
- `provider` (string; v1: `microsoft`)
- `entra_tenant_id` (string; GUID)
- `display_name` (string)
- `is_default` (boolean; exactly one default per `(tenant_id, provider)`)
- `status` (string enum): `connected | needs_consent | error | disabled`
- `health_status` (string enum): `ok | degraded | down`
- `scopes_granted` (json/jsonb; optional; stores observed granted scopes/permissions metadata)
- `last_health_check_at` (timestamp nullable)
- `last_error_reason_code` (string nullable; stable reason code)
- `last_error_message` (string nullable; sanitized short message)
- `metadata` (json/jsonb nullable; optional non-sensitive provider metadata)
- timestamps
**Indexes (suggested)**
- Unique index: `(tenant_id, provider, entra_tenant_id)`
- Partial unique index: `(tenant_id, provider)` where `is_default = true`
- Indexes for filtering: `(tenant_id, provider, status)`, `(tenant_id, provider, health_status)`
**State transitions (v1)**
- `status`: typically `needs_consent`/`error` → `connected` after successful health check; `disabled` is an explicit admin action.
- `health_status`: `ok` on successful check; `degraded/down` based on categorized failures (throttling vs outage vs auth).
---
### ProviderCredential
Represents securely stored credentials for exactly one provider connection.
**Identity**
- 1:1 with ProviderConnection (`provider_connection_id` unique).
**Fields (suggested)**
- `id` (UUID)
- `provider_connection_id` (FK → provider_connections, unique)
- `type` (string; v1: `client_secret`)
- `payload` (encrypted JSON/array)
- required keys (v1): `client_id`, `client_secret`
- optional key: `tenant_id` (should match ProviderConnection `entra_tenant_id` if stored)
- timestamps
**Constraints (suggested)**
- `payload` must be encrypted at rest.
- `payload` must never be exposed via UI/API serialization.
- If `payload.tenant_id` is stored, validate it matches `provider_connections.entra_tenant_id`.
---
### DefaultProviderConnection (concept)
Not a separate table by default; represented by `provider_connections.is_default = true`.
Rules:
- Multiple Microsoft connections per Suite Tenant are allowed.
- Exactly one default connection exists per `(tenant_id, provider)` and is used when starting operations without an explicit connection selection.
---
### OperationRun (existing)
Provider operations are tracked as `operation_runs` with provider context stored in the `context` JSON.
**Provider context fields (suggested)**
- `provider`: `microsoft`
- `provider_connection_id`: UUID
- `target_scope`: `{ "entra_tenant_id": "<guid>" }`
- `module`: `health_check | inventory | compliance`
- `selection`: optional selectors/filters for the operation (DB-only)
- `idempotency`: fingerprint/hash inputs used for dedupe
**Concurrency rules (from spec clarifications)**
- Same operation type + same scope: dedupe → return the active run.
- Different operation type while any run is active for the same scope: block as “scope busy” and link to the active run.

View File

@ -0,0 +1,130 @@
# Implementation Plan: Provider Foundation v1 (Microsoft-first, Security-first)
**Branch**: `061-provider-foundation` | **Date**: 2026-01-24 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Introduce tenant-scoped Microsoft provider connections (multiple allowed) with a required default connection and isolated credentials.
- Centralize all Microsoft Graph access through the existing Graph contract path (`GraphClientInterface` + `config/graph_contracts.php`) while standardizing provider operation context and failure handling.
- Add provider capability interfaces (health check, inventory, compliance, placeholders) so future provider modules can be introduced without scattering provider-specific logic.
- Run all provider operations asynchronously as canonical `OperationRun`s with clear UX rules: same-operation dedupe returns the active run; different-operation requests are blocked as “scope busy” with a link to the active run.
- Guarantee DB-only render for Provider Connections and Monitoring surfaces; outbound calls occur only in queued jobs.
- Add regression tests for tenant isolation, role authorization, dedupe/scope-busy behavior, and secret redaction in run failures.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4
**Storage**: PostgreSQL (Sail) with JSONB where appropriate
**Testing**: Pest (PHPUnit)
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
**Project Type**: web
**Performance Goals**: DB-only pages render fast (no outbound calls); operation start surfaces respond quickly after enqueueing a run
**Constraints**: No outbound HTTP during render/poll/hydration; max 1 active provider run per Entra tenant scope; secrets/tokens never persisted in runs/logs. Remote provider calls are read-only (Graph fetches), but the platform will persist local state (snapshots/summary_counts/health) as DB writes.
**Scale/Scope**: Multiple suite tenants; multiple Microsoft connections per tenant; operations history grows over time (indexing required)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first, snapshots-second: PASS (provider inventory is treated as “last observed”; no backup/restore semantics introduced in v1).
- Read/write separation: PASS (v1 provider operations perform remote reads only, while persisting local DB state such as health and snapshot summaries. Any future remote writes (e.g., script deploy/app publish) remain out of scope and would require preview + confirmation + audit + tests).
- Single contract path to Graph: PASS (all Microsoft Graph calls stay behind `GraphClientInterface` and are modeled in `config/graph_contracts.php`; no ad-hoc HTTP in UI/render).
- Deterministic capabilities: PASS (provider operations are registered centrally and validated/tested; no “quick endpoints” scattered across features).
- Tenant isolation: PASS (provider connections, credentials, and runs are scoped to the current Suite Tenant; cross-tenant access is denied-as-not-found).
- Operations / run observability standard: PASS (all provider operations create/reuse a canonical `OperationRun`; start surfaces enqueue-only; Monitoring remains DB-only).
- Automation (locks + idempotency): PASS (same-operation starts dedupe to the active run; different-operation starts for the same scope are blocked as “scope busy”; Graph throttling handled via existing retry/backoff+jitter).
- Data minimization & safe logging: PASS (no secrets/tokens persisted or logged; failures use stable reason codes + sanitized short messages).
- Badge semantics (BADGE-001): PASS (any status-like UI badges for connection status/health must render via `BadgeCatalog` / `BadgeRenderer` with tests; otherwise status is shown as plain text).
## Project Structure
### Documentation (this feature)
```text
specs/061-provider-foundation/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
├── Filament/
├── Jobs/
├── Models/
├── Policies/
├── Services/
│ ├── Graph/
│ └── Operations/
└── Support/
config/
database/
resources/
routes/
tests/
```
**Structure Decision**: Single Laravel web application with Filament admin panel; provider foundation lives in `app/Models`, `app/Services`, `app/Jobs`, `app/Policies`, and integrates with the existing Graph + Operations infrastructure.
## Complexity Tracking
No constitution violations required for this feature.
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/research.md`
Goals:
- Confirm the existing Graph contract path and reuse it (no new bespoke HTTP client in UI/features).
- Confirm token acquisition is app-only (client credentials) and is sourced from `provider_credentials` via `CredentialManager` inside `ProviderGateway` (no secrets in config/env beyond bootstrap).
- Ensure provider operations supply auth context to `GraphClientInterface` via `ProviderGateway` (the only decryptor), not from UI/service layers.
- Confirm `GraphClientInterface` binding can be enabled without env secrets when `ProviderGateway` supplies per-request credentials (e.g., via a `GRAPH_ENABLED` override).
- Confirm existing `OperationRun` idempotency patterns and align provider operations with them.
- Confirm existing sanitization / reason-code utilities for run failures and reuse them.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/contracts/`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/quickstart.md`
Design focus:
- Data model for provider connections + isolated credentials with a required default connection.
- Credential isolation: `provider_credentials` stores encrypted payload; only ProviderGateway/CredentialManager can decrypt; never expose in resources/logs/runs.
- Provider module contracts: define capability interfaces and ensure Microsoft implementations depend on `ProviderGateway` rather than calling Graph directly.
- OperationRun identity + scope-busy behavior consistent with clarified UX rules.
- Explicit Graph contract registry updates required for new provider operations.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
- Schema: create provider connection + credential tables, indexes, and state fields.
- Authorization (capabilities-first): implement gates/policies `provider.view`, `provider.manage`, `provider.run`.
- Owner/Manager: view+manage+run; Operator: view+run; Readonly: view only.
- No `role == X` checks in feature code; enforce via policies/gates + tests.
- Provider foundation services: connection resolution (default/explicit), credential resolution, and operation registry.
- Provider gateway + modules: implement `ProviderGateway`, `CredentialManager`, and capability interfaces used by all provider operations.
- Operations: implement health check, inventory collection (minimal), and compliance snapshot (counts) as queued `OperationRun`s.
- Concurrency: same-operation dedupe to active run; different-operation blocked as “scope busy” with active-run link; enforce race-safety with a DB lock/transaction around start-gate decisions.
- UI: Provider Connections management is DB-only at render; “Check connection” and any provider operations are enqueue-only (jobs perform outbound HTTP). Add a DB-only render test with Http::fake() asserting zero outbound requests during render/poll/hydration.
- Guardrails/tests: DB-only render tests, secret-redaction tests, reason-code mapping tests, concurrency behavior tests.
## Constitution Check (Post-Design)
Re-check result: PASS. Design artifacts explicitly keep Graph calls behind `GraphClientInterface` + contract registry and standardize provider operations via `OperationRun` with tenant isolation, idempotency, and safe failure handling.

View File

@ -0,0 +1,38 @@
# Quickstart: Provider Foundation v1
**Branch**: `061-provider-foundation`
**Date**: 2026-01-24
**Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md`
## Local setup
1. Start the app stack:
- `./vendor/bin/sail up -d`
2. Install dependencies (if needed):
- `./vendor/bin/sail composer install`
3. Run migrations:
- `./vendor/bin/sail artisan migrate`
## Manual QA (once implemented)
1. Select a Suite Tenant in the admin UI.
2. Create a Microsoft Provider Connection:
- Enter `entra_tenant_id` (GUID) and `display_name`
- Mark it as the default connection (or ensure exactly one default exists)
3. Attach credentials (Owner/Manager only):
- Enter `client_id` + `client_secret`
- Verify secrets are never shown again after saving
4. Run “Check connection” (Owner/Manager/Operator):
- Verify an `OperationRun` is created and visible in Monitoring → Operations
- Verify failures show a stable reason code + short sanitized message (no tokens/secrets/emails)
5. Start inventory collection and compliance snapshot:
- Re-start the same operation while its running → returns the existing active run
- Start a different operation while a run is active for the same scope → blocked as “scope busy” with a link to the active run
6. Confirm DB-only render:
- Provider Connections and Operations pages should load/poll without triggering outbound provider calls.
## Test run (once implemented)
- Run targeted tests:
- `./vendor/bin/sail artisan test tests/Feature`
- `./vendor/bin/sail artisan test tests/Unit`

View File

@ -0,0 +1,91 @@
y# Research: Provider Foundation v1 (Microsoft-first, Security-first)
**Branch**: `061-provider-foundation`
**Date**: 2026-01-24
**Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md`
**Plan**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/plan.md`
## Decisions
### D-001 — Keep the Graph contract path as the only Microsoft Graph entry
**Decision**: All Microsoft Graph calls remain behind `GraphClientInterface` and are modeled in `config/graph_contracts.php`; provider modules/services must not introduce ad-hoc HTTP clients or hardcoded Graph paths.
**Rationale**: This preserves the repos constitution (“Single Contract Path to Graph”) and prevents endpoint sprawl. It also reuses the existing retry/backoff/logging behavior already centralized in the Graph client.
**Alternatives considered**:
- Build a new provider-specific HTTP gateway: rejected because it risks bypassing the existing Graph contract + logging path and reintroduces “two Graph clients”.
- Allow `GraphClientInterface::request()` with hardcoded paths in feature code: rejected because it undermines contract registry governance.
---
### D-002 — Canonical Microsoft target scope identifier
**Decision**: The canonical target scope identifier for Microsoft provider connections is the Entra tenant ID (GUID). Domains may be stored as display labels only.
**Rationale**: Tenant IDs are stable and unambiguous; domains can change and multiple domains can exist.
**Alternatives considered**:
- Domain as canonical identifier: rejected due to ambiguity and change risk.
- Allow either: rejected for v1 to avoid inconsistent locking/routing keys.
---
### D-003 — Multiple Microsoft connections per Suite Tenant with a required default
**Decision**: A Suite Tenant may have multiple Microsoft provider connections (distinct Entra tenant IDs). Exactly one connection is marked as the default; operations use the default unless a connection is explicitly selected.
**Rationale**: Supports real-world multi-tenant management while keeping day-to-day usage predictable.
**Alternatives considered**:
- Exactly one Microsoft connection per Suite Tenant: rejected as too restrictive for long-term roadmap (M365/security suite expansion).
---
### D-004 — OperationRun identity, dedupe, and “scope busy” behavior
**Decision**: Provider operations are tracked as `OperationRun`s with two concurrency rules:
1) Re-starting the same operation type for the same scope returns the active run (dedupe; no new run).
2) Starting a different operation type while any run is active for that scope is blocked (“scope busy”) and links to the active run.
**Rationale**: Prevents accidental overlap, avoids run spam, and keeps user expectations simple and consistent.
**Alternatives considered**:
- Queue the second operation to run later: rejected for v1 because it creates “silent delays” and unexpected ordering.
- Always reuse an active run even for a different operation: rejected because it would attach unrelated work to the wrong run.
---
### D-005 — Authorization aligns with existing tenant roles
**Decision**:
- Owner/Manager can manage provider connections and credentials.
- Owner/Manager/Operator can start provider operations (health check, inventory, compliance).
- Readonly is view-only.
**Rationale**: Credential/connection management is security-sensitive; operations are operational work that Operators need for daily workflow.
**Alternatives considered**:
- Restrict operations to Owner/Manager only: rejected because it blocks day-to-day operations.
---
### D-006 — Failure reason codes and redaction reuse existing run sanitization
**Decision**: Provider operation failures use stable reason codes and short sanitized messages via the existing run failure sanitization utilities; extend reason codes only when needed for clear operator feedback (e.g., authentication misconfiguration).
**Rationale**: Keeps error handling consistent suite-wide and prevents token/secret/PII leakage.
**Alternatives considered**:
- Persist raw Graph error payloads for debugging: rejected due to secret/PII leak risk and constitution requirements.
---
### D-007 — DB-only render is enforced by design and tests
**Decision**: Provider Connections and Monitoring/Operations surfaces must be DB-only at render/poll time; outbound provider calls occur only in queued jobs started via explicit user actions.
**Rationale**: Prevents accidental “poll storms” against Graph and preserves predictable UI performance.
**Alternatives considered**:
- Inline health checks during page load: rejected as a render-side effect and operationally unsafe.

View File

@ -0,0 +1,155 @@
# Feature Specification: Provider Foundation v1 (Microsoft-first, Security-first)
**Feature Branch**: `061-provider-foundation`
**Created**: 2026-01-23
**Status**: Draft
**Input**: Build a provider integration foundation (starting with Microsoft) that centralizes provider communication, enables safe tenant-scoped connections, runs operations in the background with a tracked run record, prevents overlapping runs per provider tenant by default, and prevents credential leakage.
## Clarifications
### Session 2026-01-23
- Q: When an admin starts a provider operation for a target scope that already has an active run, what should the system do by default? → A: Dedupe: reuse/return the active run (no new run created).
- Q: For Microsoft in v1, what should the admin enter (and we store) as the canonical target scope identifier for a connection? → A: Entra tenant ID (GUID); domains may be stored only as a display label, not as the canonical identifier.
- Q: In v1, should a tenant be able to have multiple Microsoft provider connections, or exactly one? → A: Multiple connections allowed, but one “default” connection is required for operations unless explicitly selected.
- Q: In v1, which tenant roles should be allowed to (a) manage provider connections/credentials and (b) start provider operations (health check, inventory, compliance)? → A: Owner/Manager manage connections and credentials; Owner/Manager/Operator can start operations; Readonly is view-only.
- Q: If a provider run is already active for a target scope, and a user tries to start a different provider operation type, what should happen by default? → A: Block (“scope busy”) and link to the active run.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Set up a provider connection safely (Priority: P1)
An Owner or Manager can create and manage a provider connection for a tenant, attach credentials, and see the connections current state without ever exposing secrets.
**Why this priority**: All provider-backed capabilities depend on a secure, tenant-scoped connection and a safe way to manage credentials.
**Independent Test**: An Owner/Manager can create a connection, attach credentials, and later view/manage the connection without any secret value being displayed or leaked in the UI.
**Acceptance Scenarios**:
1. **Given** a tenant with no provider connections, **When** an Owner/Manager creates a new Microsoft provider connection with a display name and provider tenant identifier, **Then** the connection is saved and is uniquely identifiable within the tenant.
2. **Given** an existing provider connection with credentials attached, **When** an Owner/Manager views the connection details, **Then** secret values are never displayed and only safe metadata is shown.
---
### User Story 2 - Verify connection health without blocking the UI (Priority: P2)
An Owner, Manager, or Operator can trigger a connection health check and see a tracked run with a clear outcome, including a safe, categorized error when the check fails.
**Why this priority**: Admins need a reliable way to confirm connectivity/permissions and to troubleshoot failures without guesswork.
**Independent Test**: Triggering “Check connection” creates a new run, completes asynchronously, updates the connections health state, and records a stable failure category when applicable.
**Acceptance Scenarios**:
1. **Given** a configured provider connection, **When** an Owner/Manager/Operator runs a health check, **Then** the system creates a visible operation run and updates the connections health status when the run completes.
2. **Given** invalid or revoked credentials, **When** an Owner/Manager/Operator runs a health check, **Then** the run ends in a failure state with a stable reason code and a short, sanitized message (no secrets or raw payloads).
---
### User Story 3 - Run provider operations with safety and observability (Priority: P3)
An Owner, Manager, or Operator can run provider-backed operations (such as inventory collection and compliance snapshots) that are tenant-scoped, safe by default, limited to one active run per provider tenant (by default), and fully tracked through Operations monitoring.
**Why this priority**: Provider operations can be long-running and failure-prone; they must be safe by default and easy to audit and troubleshoot.
**Independent Test**: Starting a provider operation results in a single observable run, respects the per-scope concurrency limit, and surfaces summary counts and categorized failures.
**Acceptance Scenarios**:
1. **Given** a valid provider connection, **When** an Owner/Manager/Operator initiates an inventory collection run, **Then** the run is queued/executed asynchronously and appears in Operations monitoring with provider + scope context.
2. **Given** an active run for the same provider tenant identifier and the same operation type, **When** an Owner/Manager/Operator starts that operation again targeting the same scope, **Then** the system returns the active run (no new run created) and communicates the outcome clearly.
3. **Given** an active run for the same provider tenant identifier, **When** an Owner/Manager/Operator starts a different provider operation type targeting the same scope, **Then** the system blocks the request (“scope busy”) and links to the active run.
### Edge Cases
- A provider tenant identifier is entered that does not match the attached credentials: the system prevents unsafe configuration and guides the admin to fix it.
- Provider access is revoked or consent changes: health checks and operations fail with a clear, stable reason code and a safe message.
- Provider throttling/transient outages: operations behave predictably, remain tracked as a single run, and provide a clear outcome without repeated noise.
- Provider service downtime: runs fail safely and do not cause repeated background failures.
- An admin views Provider Connections or Operations pages: pages render using stored data only and never trigger provider calls during render/poll.
- A user attempts to start a different operation while a run is active for the same scope: the system blocks with “scope busy” and links to the active run.
## Requirements *(mandatory)*
**Constitution alignment:** This feature introduces tenant-scoped external-provider operations. It must enforce tenant isolation, safe run observability, and sanitized failure handling, and it must include automated tests for these guarantees.
### Scope
**In scope (v1)**
- Provider connections for Microsoft as the first supported provider.
- Multiple Microsoft connections per tenant are supported; one connection is marked as the default for operations unless the user explicitly selects another connection.
- Secure credential attachment/rotation without exposing secret values.
- A single controlled outbound-provider communication path (gateway) used by all provider-backed operations.
- Observable asynchronous operations (operation runs) for health checks and provider-backed data collection.
- Central concurrency limiting per provider tenant identifier (default: 1 concurrent run per scope).
- A minimal set of provider capabilities: inventory collection and compliance snapshot (counts).
**Out of scope (v1)**
- User-delegated sign-in flows.
- Certificate-based credentials and external secret managers.
- Cross-tenant “global MSP” dashboards.
- Provider-backed remediation/script execution and evidence-collection suites.
### Functional Requirements
- **FR-001**: System MUST store provider connections as tenant-scoped records, including provider type, a canonical provider tenant identifier (target scope) (v1 Microsoft: Entra tenant ID (GUID)), display name, and connection state (e.g., connected / needs consent / error / disabled).
- **FR-002**: System MUST enforce uniqueness of provider connections within a tenant by provider type + provider tenant identifier.
- **FR-003**: System MUST store provider credentials separately from provider connection identity and MUST support credential rotation without changing the connection identity.
- **FR-004**: System MUST never display stored secret values after they are submitted, and MUST not expose secrets in UI, notifications, operation runs, or logs.
- **FR-005**: System MUST categorize provider failures using stable reason codes and short, sanitized messages suitable for audit and support triage.
- **FR-006**: System MUST route all outbound provider communication through a single controlled gateway that enforces consistent authentication, tracking identifiers for support, and safe handling of throttling and transient failures.
- **FR-007**: System MUST ensure that viewing admin pages (including Provider Connections and Operations monitoring) never triggers outbound provider calls during page render/poll.
- **FR-008**: System MUST execute provider operations asynchronously when they involve provider communication or may exceed normal UI response times.
- **FR-009**: System MUST create or reuse a canonical operation run for each provider operation and MUST record provider, target scope, module/capability, timestamps, and outcome for Monitoring → Operations.
- **FR-010**: System MUST enforce a central per-scope concurrency limit for provider operations (default: 1 concurrent run per provider tenant identifier). If a run is already active for a scope: (a) re-starting the same operation type MUST return the active run (no new run created), and (b) starting a different operation type MUST be blocked with “scope busy” and a link to the active run.
- **FR-011**: System MUST provide a “Check connection” operation that updates connection health state based on the result and records the outcome as an operation run.
- **FR-012**: System MUST define provider capability interfaces that allow adding new provider-backed modules (inventory, compliance, directory, scripts) without scattering provider-specific logic across unrelated features.
- **FR-013**: System MUST ship at least one Microsoft provider implementation that supports (a) inventory collection and (b) compliance snapshot runs, producing stored results and summary counts.
- **FR-014**: Security-relevant configuration changes (creating/updating connections and credentials, disabling connections) MUST be recorded in an audit trail with actor + tenant + timestamp.
- **FR-015**: System MUST maintain a centralized, reviewed registry of allowed provider operations, so new provider calls cannot be introduced ad-hoc outside the approved integration path.
- **FR-016**: System MUST allow multiple Microsoft provider connections per tenant (distinguished by Entra tenant ID (GUID)) and MUST require exactly one default Microsoft connection that is used when starting provider operations without an explicit connection selection.
- **FR-017**: System MUST enforce tenant-role based access: Owner/Manager can create/update/disable provider connections and manage credentials; Owner/Manager/Operator can start provider operations; Readonly is view-only and cannot start operations.
### Acceptance Criteria
- Connection setup is tenant-scoped, unique per provider + scope, uses Entra tenant ID (GUID) as the canonical scope identifier, and does not expose secrets.
- Health checks and provider operations run in the background and are tracked as operation runs with clear outcomes.
- Provider operations do not overlap for the same target scope by default.
- When a run is already in progress for a target scope, starting the same operation returns the active run (no new run created).
- When a run is already in progress for a target scope, starting a different operation is blocked with “scope busy” and links to the active run.
- When multiple Microsoft connections exist for a tenant, one is designated as the default; starting an operation without selecting a connection uses the default.
- Only Owner/Manager can manage connections and credentials; Owner/Manager/Operator can start operations; Readonly can only view.
- Monitoring/Connections pages are “read-only at render time” and do not call providers while loading or polling.
### Key Entities *(include if feature involves data)*
- **Provider Connection**: A tenant-scoped representation of a relationship to an external provider, including target scope identifier, display name, state, and health indicators.
- **Provider Credential**: A securely stored credential set linked to exactly one provider connection, used only for provider communication.
- **Target Scope**: A stable identifier that defines which external tenant/environment a provider operation targets (v1 Microsoft: Entra tenant ID (GUID)); domains may be stored only as labels.
- **Default Provider Connection**: The tenants selected default connection for a provider, used when starting provider operations without an explicit connection selection.
- **Operation Run**: A canonical record of an initiated provider operation, including initiator, scope, timestamps, outcome, summary counts, and categorized failures.
- **Provider Capability (Module)**: A named area of functionality (e.g., inventory, compliance) that can be implemented per provider and executed as an operation run.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A tenant admin can create a provider connection and attach credentials in under 5 minutes without support intervention.
- **SC-002**: 100% of provider-backed operations initiated by users create an operation run visible in Operations monitoring within 5 seconds of initiation.
- **SC-003**: For a given target scope, the system prevents overlapping provider operations by default (max 1 concurrent run per scope), with clear user feedback.
- **SC-004**: Automated tests demonstrate zero secret leakage in UI surfaces and operation run messages (no tokens/secrets/PII displayed or persisted).
- **SC-005**: 95% of connection health checks complete and update connection health status within 2 minutes under normal provider conditions.
## Assumptions
- v1 targets a single provider (Microsoft) but must be designed to add additional providers and modules later without reworking existing features.
- Provider operations are initiated by authorized administrators and must respect tenant scoping and audit requirements.
- Monitoring and “read-only” admin pages prioritize predictable load and safety over real-time provider querying.
## Dependencies
- A tenant model and authorization boundaries exist so provider connections and runs can be scoped correctly.
- Operations monitoring and an audit trail mechanism exist (or are introduced alongside this feature) to record runs and security-relevant configuration changes.

View File

@ -0,0 +1,188 @@
---
description: "Task list for Provider Foundation v1"
---
# Tasks: Provider Foundation v1 (Microsoft-first, Security-first)
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/research.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/contracts/`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/061-provider-foundation/quickstart.md`
**Tests**: REQUIRED (Pest) for runtime behavior changes
**Operations**: Provider operations MUST create/reuse a canonical `OperationRun`, be enqueue-only, and keep Monitoring/ProviderConnections pages DB-only at render/poll time
**Badges**: If ProviderConnection status/health is rendered as a badge, use `BadgeCatalog` / `BadgeRenderer` (BADGE-001) and add mapping tests
**Organization**: Tasks are grouped by user story (US1, US2, US3) to enable independent delivery.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare local environment + validate baseline
- [X] T001 Start containers with `./vendor/bin/sail up -d` (script: `./vendor/bin/sail`)
- [X] T002 Run baseline Ops-UX DB-only tests with `./vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsDbOnlyTest.php` (test: `tests/Feature/Monitoring/OperationsDbOnlyTest.php`)
- [X] T003 [P] Review existing idempotency + run tracking patterns in `app/Services/OperationRunService.php` and `app/Jobs/Middleware/TrackOperationRun.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core provider foundation that all stories depend on (schema, models, policies, ops primitives)
**Checkpoint**: Migrations + models + policies exist; provider operation types are registered; shared run-gating utilities exist
- [X] T004 Create provider connections migration in `database/migrations/2026_01_24_000001_create_provider_connections_table.php`
- [X] T005 Create provider credentials migration in `database/migrations/2026_01_24_000002_create_provider_credentials_table.php`
- [X] T005a Add DB-level invariant: partial unique index ensuring only one default per (tenant_id, provider) (example: `unique (tenant_id, provider) WHERE is_default = true`; ensure default flipping is atomic/transactional)
- [X] T006 [P] Create `App\Models\ProviderConnection` in `app/Models/ProviderConnection.php` (relations, casts, default-connection invariant)
- [X] T007 [P] Create `App\Models\ProviderCredential` in `app/Models/ProviderCredential.php` (encrypted payload cast, hidden attributes, 1:1 relation)
- [X] T008 [P] Add tenant relationship for connections in `app/Models/Tenant.php` (e.g., `providerConnections()` / `providerCredentials()` as needed)
- [X] T009 [P] Add factories in `database/factories/ProviderConnectionFactory.php` and `database/factories/ProviderCredentialFactory.php`
- [X] T010 Create authorization policy + gates for `provider.view`, `provider.manage`, `provider.run` (capabilities-first; tenant-scoped). Map roles to capabilities: Owner/Manager=view+manage+run; Operator=view+run; Readonly=view only. No `role == X` checks in feature code.
- [X] T010b [P] Add RBAC capability tests in `tests/Feature/ProviderConnections/ProviderRbacCapabilitiesTest.php` (Operator can start operations but cannot manage; Readonly is view-only)
- [X] T011 Register the policy + gates in `app/Providers/AuthServiceProvider.php` (create it and register in `bootstrap/providers.php` if missing; otherwise use the projects canonical policy registration provider)
- [X] T012 Add provider operation labels in `app/Support/OperationCatalog.php` using canonical operation types: `provider.connection.check`, `inventory.sync`, `compliance.snapshot` (ensure UI/actions/jobs use these exact strings; update `tests/Feature/OpsUx/OperationCatalogCoverageTest.php` to detect multi-dot types)
- [X] T013 Add provider related links in `app/Support/OperationRunLinks.php` (link provider runs to Provider Connections pages when `context.provider_connection_id` exists)
- [X] T014 Create provider operation registry in `app/Services/Providers/ProviderOperationRegistry.php` (central allowlist + metadata for v1 operations)
- [X] T014a [P] Define provider capability interfaces + DTOs in `app/Services/Providers/Contracts/ProviderHealthCheck.php`, `app/Services/Providers/Contracts/HealthResult.php`, `app/Services/Providers/Contracts/ProviderInventoryCollector.php`, `app/Services/Providers/Contracts/ProviderComplianceCollector.php`, `app/Services/Providers/Contracts/ProviderDirectoryCollector.php`, `app/Services/Providers/Contracts/ProviderScriptExecutor.php`
- [X] T014b [P] Implement credential retrieval/rotation service in `app/Services/Providers/CredentialManager.php` (reads `provider_credentials`, validates required keys, never logs decrypted payload)
- [X] T014c [P] Implement provider gateway in `app/Services/Providers/ProviderGateway.php` (build Graph request context from ProviderConnection + CredentialManager, centralize correlation IDs + failure mapping, call `GraphClientInterface`)
- [X] T014d Enable Graph client binding for per-request credentials in `config/graph.php` and `app/Providers/AppServiceProvider.php` (e.g., `GRAPH_ENABLED` override; do not require env client_secret for binding when ProviderGateway supplies request context)
- [X] T014e [P] Add unit tests for provider gateway + credential manager in `tests/Unit/Providers/CredentialManagerTest.php` and `tests/Unit/Providers/ProviderGatewayTest.php`
- [X] T015 Create run gating service in `app/Services/Providers/ProviderOperationStartGate.php` (DB transaction + `lockForUpdate()` on ProviderConnection; dedupe same-operation; block different-operation as “scope busy”; returns active-run link)
- [X] T016 [P] Extend failure sanitization in `app/Support/OpsUx/RunFailureSanitizer.php` (provider auth/throttling/outage reason codes + stronger secret redaction)
- [X] T017 [P] Add unit tests for failure sanitization in `tests/Unit/OpsUx/RunFailureSanitizerTest.php`
- [X] T018 [P] Add unit tests for run gating in `tests/Unit/Providers/ProviderOperationStartGateTest.php`
- [X] T019 Run foundational tests with `./vendor/bin/sail artisan test tests/Unit/OpsUx/RunFailureSanitizerTest.php` (test: `tests/Unit/OpsUx/RunFailureSanitizerTest.php`)
---
## Phase 3: User Story 1 — Set up a provider connection safely (Priority: P1) 🎯 MVP
**Goal**: Owner/Manager can create/manage Microsoft provider connections, attach credentials, set a default connection, and never expose secrets.
**Independent Test**: An Owner/Manager can create a connection + credentials, later view/edit it, and no secret values are displayed or leaked; pages remain DB-only at render/poll time.
### Tests for User Story 1
- [X] T020 [P] [US1] Add DB-only render test for Provider Connections in `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`
- [X] T021 [P] [US1] Add role authorization test in `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`
- [X] T022 [P] [US1] Add credential encryption/non-disclosure test in `tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php`
- [X] T022b [P] [US1] Add credential leak guard test in `tests/Feature/ProviderConnections/CredentialLeakGuardTest.php` (assert no secrets appear in OperationRun failure records or captured logs; forbidden substrings: `client_secret`, `Bearer `, `access_token`, `refresh_token`, `Authorization`)
### Implementation for User Story 1
- [X] T023 [US1] Create Filament resource skeleton in `app/Filament/Resources/ProviderConnectionResource.php`
- [X] T024 [P] [US1] Create pages in `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php`, `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
- [X] T025 [US1] Implement list/table + filters in `app/Filament/Resources/ProviderConnectionResource.php` (tenant-scoped; DB-only)
- [X] T026 [US1] Implement create/edit forms in `app/Filament/Resources/ProviderConnectionResource.php` (provider=microsoft, `entra_tenant_id`, `display_name`, `is_default`, status/health read-only fields)
- [X] T027 [US1] Implement credential upsert action (Owner/Manager only; secrets never shown) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
- [X] T028 [US1] Implement disable connection action with confirmation + audit log in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
- [X] T029 [US1] Write audit log entries for connection + credential changes using `app/Services/Intune/AuditLogger.php` (called from `app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`)
- [X] T030 [US1] Run story tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` (test: `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`)
---
## Phase 4: User Story 2 — Verify connection health without blocking the UI (Priority: P2)
**Goal**: Owner/Manager/Operator can enqueue a health check that creates an `OperationRun`, updates health state, and shows stable reason codes/messages on failure.
**Independent Test**: Clicking “Check connection” enqueues exactly one run (dedupe), never calls Graph in the request cycle, and produces a visible run outcome + updates connection health.
### Tests for User Story 2
- [X] T031 [P] [US2] Add start-surface test (no Graph in request; job queued; run created) in `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`
- [X] T032 [P] [US2] Add job behavior test (success + categorized failure) in `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php`
- [X] T032b [P] [US2] Assert OperationRun context contract for connection health checks in `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php` (`context.provider`, `context.module`, `context.provider_connection_id`, `context.target_scope.entra_tenant_id`)
### Implementation for User Story 2
- [X] T033 [US2] Add “Check connection” Filament action (enqueue-only) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (MUST call `ProviderOperationStartGate` first: same-operation returns existing active run; different-operation for same scope blocks “scope busy” + link to active run)
- [X] T034 [US2] Create queued job in `app/Jobs/ProviderConnectionHealthCheckJob.php` (uses `app/Jobs/Middleware/TrackOperationRun.php`, updates `provider_connections` health fields, updates `operation_runs`)
- [X] T035 [US2] Create health check module in `app/Services/Providers/MicrosoftProviderHealthCheck.php` (implements `app/Services/Providers/Contracts/ProviderHealthCheck.php`, uses `ProviderGateway`, maps failures to reason codes via `app/Support/OpsUx/RunFailureSanitizer.php`)
- [X] T035b [P] [US2] Ensure health check uses ProviderConnection context (`entra_tenant_id`) and obtains tokens via ProviderGateway/CredentialManager (ProviderGateway is the only decryptor; no secrets in config/env beyond bootstrap)
- [X] T036 [US2] Run story tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php` (test: `tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`)
---
## Phase 5: User Story 3 — Run provider operations with safety and observability (Priority: P3)
**Goal**: Owner/Manager/Operator can run provider operations (inventory + compliance snapshot) as observable runs, with per-scope concurrency rules (dedupe vs scope-busy) and summary counts.
**Independent Test**: Starting inventory/compliance creates runs, obeys dedupe/scope-busy rules, never calls Graph in the request cycle, and writes summary counts + failures safely.
### Tests for User Story 3
- [X] T037 [P] [US3] Add scope-busy + dedupe behavior tests in `tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php`
- [X] T038 [P] [US3] Add compliance snapshot summary_counts tests in `tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php`
- [X] T038b [P] [US3] Assert OperationRun context contract for inventory/compliance runs in `tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php` and `tests/Feature/ProviderConnections/ProviderComplianceSnapshotJobTest.php` (`context.provider`, `context.module`, `context.provider_connection_id`, `context.target_scope.entra_tenant_id`)
### Implementation for User Story 3
- [X] T039 [US3] Add managed devices contract entry for compliance snapshot in `config/graph_contracts.php` (resource `deviceManagement/managedDevices`, select includes compliance state)
- [X] T040 [US3] Add allowed summary keys for compliance counts in `app/Support/OpsUx/OperationSummaryKeys.php` (add `compliant`, `noncompliant`, `unknown`)
- [X] T041 [US3] Create compliance snapshot collector in `app/Services/Providers/MicrosoftComplianceSnapshotService.php` (implements `app/Services/Providers/Contracts/ProviderComplianceCollector.php`, uses `ProviderGateway`, Graph list + count compliance states)
- [X] T042 [US3] Create queued job in `app/Jobs/ProviderComplianceSnapshotJob.php` (writes `OperationRun` context + summary_counts; updates failures with sanitized reason codes)
- [X] T043 [US3] Add “Compliance snapshot” Filament action (enqueue-only) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (MUST call `ProviderOperationStartGate` first: same-operation returns existing active run; different-operation for same scope blocks “scope busy” + link to active run)
- [X] T044 [US3] Create minimal inventory collector in `app/Services/Providers/MicrosoftProviderInventoryCollector.php` (implements `app/Services/Providers/Contracts/ProviderInventoryCollector.php`, uses `ProviderGateway`, contract-backed listing + summary counts only)
- [X] T045 [US3] Create queued job in `app/Jobs/ProviderInventorySyncJob.php` (writes `OperationRun` context + summary_counts; obeys scope-busy rules via start gate)
- [X] T046 [US3] Add “Inventory sync” Filament action (enqueue-only) in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (MUST call `ProviderOperationStartGate` first: same-operation returns existing active run; different-operation for same scope blocks “scope busy” + link to active run)
- [X] T047 [US3] Run story tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php` (test: `tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php`)
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Reduce regressions and improve consistency across stories
- [X] T048 [P] Ensure provider operations set `context.target_scope.entra_tenant_id` and `context.target_scope.entra_tenant_name` in `app/Jobs/ProviderConnectionHealthCheckJob.php`, `app/Jobs/ProviderComplianceSnapshotJob.php`, and `app/Jobs/ProviderInventorySyncJob.php`
- [X] T049 [P] If ProviderConnection status/health is shown as badges, add a badge mapper + tests in `app/Support/Badges/Domains/` and `tests/Unit/Badges/`
- [X] T050 Run formatting with `./vendor/bin/sail php ./vendor/bin/pint --dirty` (script: `./vendor/bin/pint`)
- [X] T051 Run focused feature tests with `./vendor/bin/sail artisan test tests/Feature/ProviderConnections` (folder: `tests/Feature/ProviderConnections`)
---
## Dependencies & Execution Order
### User Story Dependency Graph
```text
Phase 1 (Setup)
Phase 2 (Foundation: schema/models/policy + ops primitives)
US1 (Connections + credentials UI) ─┬─→ US2 (Health check run)
└─→ US3 (Inventory + compliance runs)
```
### Parallel Opportunities
- Phase 2 tasks marked `[P]` can be done in parallel (different files).
- Within each user story phase, `[P]` tests can be written in parallel.
- Caution: US2 and US3 both extend `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` (avoid parallel edits to that file unless coordinated).
---
## Parallel Example: User Story 1
```bash
Task: "Add DB-only render test for Provider Connections in tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php"
Task: "Add role authorization test in tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php"
Task: "Add credential encryption/non-disclosure test in tests/Feature/ProviderConnections/ProviderCredentialSecurityTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1)
1. Complete Phase 1 + Phase 2
2. Complete US1 (Provider Connections + credentials management)
3. Validate with `tests/Feature/ProviderConnections/*` and DB-only render checks
### Incremental Delivery
1. US1 → demo/manage connections safely (no provider calls)
2. US2 → add health check (first real provider call, but enqueue-only + observable)
3. US3 → add inventory + compliance snapshot operations (summary counts + scope-busy rules)

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Tenant RBAC v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-25
**Feature**: [specs/062-tenant-rbac-v1/spec.md](specs/062-tenant-rbac-v1/spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- All checks passed. The specification is ready for the next phase.

View File

@ -0,0 +1,49 @@
# Data Model for Tenant RBAC v1
This document outlines the data models for the Tenant RBAC feature.
## `users`
Represents a user identity, linked to an Entra ID.
- `id` (PK)
- `entra_tenant_id` (string) - The Entra ID tenant ID (tid).
- `entra_object_id` (string) - The Entra ID object ID (oid).
- `name` (string)
- `email` (string, nullable)
- `timestamps`
**Indexes**:
- Unique index on `(entra_tenant_id, entra_object_id)`.
## `tenant_memberships`
Links a User to a Suite Tenant with a specific role. This is the source of truth for authorization.
- `id` (PK, uuid)
- `tenant_id` (FK to `tenants.id`)
- `user_id` (FK to `users.id`)
- `role` (enum: `owner`, `manager`, `operator`, `readonly`)
- `source` (enum: `manual`, `entra_group`, `entra_app_role`, `break_glass`)
- `source_ref` (string, nullable) - e.g., Entra group ID or app role ID.
- `created_by_user_id` (FK to `users.id`, nullable)
- `timestamps`
**Indexes**:
- Unique index on `(tenant_id, user_id)`.
- Index on `(tenant_id, role)`.
## `tenant_role_mappings`
Defines the mapping between an Entra group/app-role and a TenantAtlas role for a Suite Tenant.
- `id` (PK, uuid)
- `tenant_id` (FK to `tenants.id`)
- `mapping_type` (enum: `entra_group`, `entra_app_role`)
- `external_id` (string) - The Entra group GUID or appRole string.
- `role` (enum: `owner`, `manager`, `operator`, `readonly`)
- `is_enabled` (boolean)
- `timestamps`
**Indexes**:
- Unique index on `(tenant_id, mapping_type, external_id)`.

View File

@ -0,0 +1,103 @@
# Implementation Plan: Tenant RBAC v1
**Branch**: `062-tenant-rbac-v1` | **Date**: 2026-01-25 | **Spec**: [specs/062-tenant-rbac-v1/spec.md](specs/062-tenant-rbac-v1/spec.md)
**Input**: Feature specification from `specs/062-tenant-rbac-v1/spec.md`
## Summary
This feature introduces a comprehensive Role-Based Access Control (RBAC) system for TenantAtlas. It leverages Microsoft Entra ID for authentication and manages authorization on a per-Suite-Tenant basis. The core of this feature is the `tenant_memberships` table, which will be the source of truth for authorization. The implementation will follow a capabilities-first approach, where permissions are checked using Gates and Policies rather than direct role comparisons.
### Clarifications
- No Entra user credentials are stored; only the dedicated break-glass platform superadmin may have local credentials.
- All access control decisions must be auditable.
- Non-member access to tenant-scoped routes (including direct `/t/{tenant}` URLs) MUST be deny-as-not-found (404).
- A canonical capability registry (e.g., `app/Support/Auth/Capabilities.php` or an enum) will be the source of truth. Role → capability mapping MUST reference only registry entries; tests must fail if unknown capabilities are used.
- Audit action_ids will be standardized:
- `tenant_membership.add`
- `tenant_membership.role_change`
- `tenant_membership.remove`
- `tenant_membership.bootstrap_assign`
- `tenant_membership.bootstrap_recover`
- `tenant_role_mapping.create`
- `tenant_role_mapping.update`
- `tenant_role_mapping.delete`
- The system MUST prevent removing or demoting the last remaining `owner` membership for a Suite Tenant.
## Technical Context
**Language/Version**: PHP 8.4
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL
**Testing**: Pest
**Target Platform**: Web
**Project Type**: Web Application
**Performance Goals**:
- User login and tenant selection should be completed in under 3 seconds.
- Membership changes should be reflected in under 2 seconds.
- Audit log entries should be created in under 1 second.
**Constraints**:
- No Entra user credentials are stored; only the dedicated break-glass platform superadmin may have local credentials.
- All access control decisions must be auditable.
**Scale/Scope**:
- The system should be designed to handle up to 1,000 tenants and 10,000 users.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **Inventory-first**: Not directly applicable.
- **Read/write separation**: **PASS**.
- **Graph contract path**: **PASS**.
- **Deterministic capabilities**: **PASS**.
- **Tenant isolation**: **PASS**.
- **Run observability**: **PASS**.
- **Automation**: **PASS**.
- **Data minimization**: **PASS**.
- **Badge semantics (BADGE-001)**: Not applicable.
## Project Structure
### Documentation (this feature)
```text
specs/062-tenant-rbac-v1/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output
```
### Source Code (repository root)
```text
app/
├── Models/
│ ├── User.php
│ ├── Tenant.php
│ ├── TenantMembership.php
│ └── TenantRoleMapping.php
├── Policies/
│ └── TenantMembershipPolicy.php
├── Providers/
│ └── AuthServiceProvider.php
└── Support/
└── Auth/
└── Capabilities.php
database/
└── migrations/
├── XXXX_XX_XX_XXXXXX_create_tenant_memberships_table.php
└── XXXX_XX_XX_XXXXXX_create_tenant_role_mappings_table.php
routes/
└── web.php
tests/
└── Feature/
└── TenantRBAC.php
```
**Structure Decision**: The project is a standard Laravel application. New files will be created in the appropriate directories.
## Complexity Tracking
No violations to the constitution.

View File

@ -0,0 +1,16 @@
# Quickstart for Tenant RBAC v1
This document provides a brief overview of how to get started with the new RBAC feature.
## 1. Login
- Users can now log in to TenantAtlas using their Microsoft Entra ID credentials.
## 2. Managing Tenant Members
- Users with the `owner` or `manager` role can manage tenant members from the "Settings" -> "Tenants" -> "Members" page.
- From here, you can add, edit, or remove members from the tenant.
## 3. Role Mappings
- Optional role mappings can be configured from the tenant detail page to automatically provision memberships based on Entra groups or app roles.
## 4. Break-glass
- A local superadmin account exists for emergency access. When logged in as the break-glass admin, a persistent banner will be displayed.

View File

@ -0,0 +1,3 @@
# Research & Decisions for Tenant RBAC v1
No major research was required for this feature as the technical approach is straightforward and relies on existing patterns within the TenantPilot application. The provided clarifications have been incorporated into the implementation plan.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,123 @@
# Actionable Tasks for Tenant RBAC v1
**Feature**: Tenant RBAC v1
**Branch**: `062-tenant-rbac-v1`
**Plan**: `specs/062-tenant-rbac-v1/plan.md`
This task list is dependency-ordered and test-driven. It implements:
- Entra (OIDC) identity (no Entra credentials stored)
- Suite-tenant authorization via `tenant_memberships` (SoT)
- Capabilities-first gates/policies (no role checks in feature code)
- Tenant switcher + direct route enforcement (non-members = 404)
- Audit logging with canonical action_ids
- Break-glass platform superadmin recovery
---
## Phase 0 — Discovery / Fit Check
- [x] T001 [P] Confirm existing auth entrypoints (where OIDC callback/upsert happens) and Filament tenancy resolver (where current tenant is set).
- [x] T002 [P] Confirm existing `User` / `Tenant` models and current schema (do NOT create duplicates). Identify required columns for Entra identity: `entra_tenant_id (tid)`, `entra_object_id (oid)`.
- [x] T003 [P] Identify existing AuditLog service/model and how to write audit entries (target format + redaction).
---
## Phase 1 — Schema (RBAC source of truth)
- [x] T004 Create migration `create_tenant_memberships_table` with:
- `tenant_id`, `user_id`, `role` (`owner|manager|operator|readonly`)
- `source` (`manual|entra_group|entra_app_role|break_glass`)
- `source_ref` (nullable)
- `created_by_user_id` (nullable)
- unique `(tenant_id, user_id)` and index `(tenant_id, role)`
- [ ] T005 (Optional, but supported) Create migration `create_tenant_role_mappings_table` with:
- `tenant_id`, `mapping_type` (`entra_group|entra_app_role`), `external_id`, `role`, `is_enabled`
- unique `(tenant_id, mapping_type, external_id)`
- [x] T006 Add/adjust `users` columns if missing: `entra_tenant_id` (tid), `entra_object_id` (oid) + unique index `(entra_tenant_id, entra_object_id)`.
- [x] T007 Run migrations.
---
## Phase 2 — Models + Capability Registry (capabilities-first)
- [x] T008 Create `app/Support/Auth/Capabilities.php` as the canonical allowlist (constants/enum) of capability strings.
- [x] T009 Create `app/Services/Auth/RoleCapabilityMap.php` (single source of truth) mapping roles → capabilities.
- [x] T010 Create `app/Services/Auth/CapabilityResolver.php`:
- resolves membership for (user, tenant) once per request (no N+1)
- answers `can($capability)` using the registry + map
- [x] T011 Register Gates in `app/Providers/AuthServiceProvider.php` using `CapabilityResolver` (no direct role checks).
- [x] T012 Add model `TenantMembership` and (if used) `TenantRoleMapping` with relationships:
- `Tenant::memberships()`, `User::tenantMemberships()`
- [x] T013 Unit tests:
- `CapabilitiesRegistryTest`: role map only references registry entries
- `CapabilityResolverTest`: Owner/Manager/Operator/Readonly mapping works and is deterministic
---
## Phase 3 — Tenant Isolation (switcher + deny-as-not-found)
- [x] T014 Enforce tenant switcher scoping: only tenants with a membership are listable/selectable for a user.
- [x] T015 Enforce route-level deny-as-not-found:
- direct access to `/t/{tenant}` and tenant-scoped resources returns 404 when user is not a member.
- member without capability returns 403.
- [x] T016 Feature tests:
- `TenantSwitcherScopeTest`: only membership tenants appear
- `TenantRouteDenyAsNotFoundTest`: non-member gets 404 for direct URL
---
## Phase 4 — Suite Tenant Membership Management UI (Tenant → Members)
- [x] T017 Add a Filament Relation Manager (or equivalent) under `Settings → Tenants` to manage memberships:
- list members + role
- add member (select existing user) + role
- edit member role
- remove member
- [x] T018 Implement **Last Owner Guard**:
- prevent removing/demoting last `owner` membership (clear UI message)
- [x] T019 Implement **Bootstrap assign**:
- on tenant creation, creator becomes Owner (action_id `tenant_membership.bootstrap_assign`)
- [x] T020 Implement **Bootstrap recover** (platform superadmin path):
- add/assign Owner when needed (action_id `tenant_membership.bootstrap_recover`)
- [x] T021 Feature tests:
- `TenantMembershipCrudTest`
- `LastOwnerGuardTest`
- `TenantBootstrapAssignTest`
---
## Phase 5 — Audit Logging (canonical action_ids)
- [x] T022 Add audit logging for membership and mapping changes with canonical action_ids:
- `tenant_membership.add`
- `tenant_membership.role_change`
- `tenant_membership.remove`
- `tenant_membership.bootstrap_assign`
- `tenant_membership.bootstrap_recover`
- `tenant_role_mapping.create|update|delete` (if mappings are enabled)
Audit entries must be redacted (no secrets; minimal identity data).
- [x] T023 Feature test `MembershipAuditLogTest` ensures audit entries are written on add/change/remove and contain no sensitive fields.
---
## Phase 6 — Break-glass Platform Superadmin (recovery)
- [x] T024 Implement (or confirm existing) local platform superadmin authentication separate from Entra users.
- [x] T025 Add a persistent UI banner when authenticated as break-glass.
- [x] T026 Ensure platform superadmin can manage memberships across all tenants for recovery (at least to add an Owner).
- [x] T027 Feature test `BreakGlassRecoveryTest`:
- can assign owner to tenant
- actions are audited with bootstrap_recover
---
## Phase 7 — Optional: Entra Mapping (deferred execution in v1)
- [ ] T028 (Optional) Add UI to manage `tenant_role_mappings` (no Graph calls for resolution in v1).
- [ ] T029 (Optional) Test that mapping records are tenant-scoped and audited on create/update/delete.
---
## Phase 8 — Quality Gates
- [x] T030 Run formatting: `./vendor/bin/sail php ./vendor/bin/pint --dirty`
- [x] T031 Run focused tests: `./vendor/bin/sail artisan test tests/Feature/TenantRBAC --stop-on-failure`
---
## Notes / Guardrails
- Non-member access = **404** (deny-as-not-found). Member without capability = **403**.
- No feature code may use `role == ...` checks. Always gates/capabilities.
- Do not add any render-time Graph calls (group/app-role resolution is deferred unless explicitly scheduled as a job in a later feature).

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use Illuminate\Support\Facades\Bus;
it('renders Provider Connections DB-only (no outbound HTTP, no background work)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Contoso',
'entra_tenant_id' => fake()->uuid(),
'provider' => 'microsoft',
]);
$this->actingAs($user);
Bus::fake();
assertNoOutboundHttp(function () use ($tenant, $connection): void {
$this->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Contoso');
$this->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk()
->assertSee('Contoso');
});
Bus::assertNothingDispatched();
});

View File

@ -2,11 +2,12 @@
use App\Jobs\RunInventorySyncJob; use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Services\OperationRunService; use App\Notifications\OperationRunCompleted;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use Mockery\MockInterface; use Mockery\MockInterface;
it('executes a pending inventory sync run and updates bulk progress + initiator attribution', function () { it('executes a pending inventory sync run and updates bulk progress + initiator attribution', function () {
@ -59,6 +60,14 @@
expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes));
expect((int) ($counts['succeeded'] ?? 0))->toBe(count($policyTypes)); expect((int) ($counts['succeeded'] ?? 0))->toBe(count($policyTypes));
expect((int) ($counts['failed'] ?? 0))->toBe(0); expect((int) ($counts['failed'] ?? 0))->toBe(0);
expect($user->notifications()->count())->toBe(1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => OperationRunCompleted::class,
'data->title' => 'Inventory sync completed',
]);
}); });
it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () { it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () {
@ -125,4 +134,12 @@
expect($failures)->toBeArray(); expect($failures)->toBeArray();
expect($failures[0]['code'] ?? null)->toBe('inventory.skipped'); expect($failures[0]['code'] ?? null)->toBe('inventory.skipped');
expect($failures[0]['message'] ?? null)->toBe('locked'); expect($failures[0]['message'] ?? null)->toBe('locked');
expect($user->notifications()->count())->toBe(1);
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => OperationRunCompleted::class,
'data->title' => 'Inventory sync failed',
]);
}); });

View File

@ -27,14 +27,14 @@
// Capture common patterns where operation type strings are produced in code. // Capture common patterns where operation type strings are produced in code.
// Example: ensureRun(type: 'inventory.sync', ...) // Example: ensureRun(type: 'inventory.sync', ...)
if (preg_match_all("/(?:\btype\s*:\s*|\btype\b\s*=>\s*)'([a-z0-9_]+\.[a-z0-9_]+)'/i", $contents, $matches)) { if (preg_match_all("/(?:\btype\s*:\s*|\btype\b\s*=>\s*)'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $contents, $matches)) {
foreach ($matches[1] as $type) { foreach ($matches[1] as $type) {
$discoveredTypes[] = $type; $discoveredTypes[] = $type;
} }
} }
// Example: if ($run->type === 'inventory.sync') // Example: if ($run->type === 'inventory.sync')
if (preg_match_all("/\btype\s*(?:===|!==|==|!=)\s*'([a-z0-9_]+\.[a-z0-9_]+)'/i", $contents, $matches)) { if (preg_match_all("/\btype\s*(?:===|!==|==|!=)\s*'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $contents, $matches)) {
foreach ($matches[1] as $type) { foreach ($matches[1] as $type) {
$discoveredTypes[] = $type; $discoveredTypes[] = $type;
} }
@ -43,7 +43,7 @@
// Example: in_array($run->type, ['a.b', 'c.d'], true) // Example: in_array($run->type, ['a.b', 'c.d'], true)
if (preg_match_all("/\bin_array\([^\)]*\[([^\]]+)\]/i", $contents, $matches)) { if (preg_match_all("/\bin_array\([^\)]*\[([^\]]+)\]/i", $contents, $matches)) {
foreach ($matches[1] as $list) { foreach ($matches[1] as $list) {
if (preg_match_all("/'([a-z0-9_]+\.[a-z0-9_]+)'/i", $list, $inner)) { if (preg_match_all("/'([a-z0-9_]+(?:\.[a-z0-9_]+)+)'/i", $list, $inner)) {
foreach ($inner[1] as $type) { foreach ($inner[1] as $type) {
$discoveredTypes[] = $type; $discoveredTypes[] = $type;
} }

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('never persists secret-like substrings in operation run failures or notifications', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => 123,
],
]);
/** @var OperationRunService $service */
$service = app(OperationRunService::class);
$service->updateRun(
$run,
status: 'completed',
outcome: 'failed',
failures: [[
'code' => 'provider.auth',
'message' => 'Authorization: Bearer super-secret-token access_token=abc refresh_token=def client_secret=ghi',
]],
);
$run->refresh();
$failuresJson = json_encode($run->failure_summary);
expect($failuresJson)
->not->toContain('Authorization')
->not->toContain('Bearer ')
->not->toContain('access_token')
->not->toContain('refresh_token')
->not->toContain('client_secret')
->not->toContain('super-secret-token')
->not->toContain('abc')
->not->toContain('def')
->not->toContain('ghi');
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
$body = (string) ($notification->data['body'] ?? '');
expect($body)
->not->toContain('Authorization')
->not->toContain('Bearer ')
->not->toContain('access_token')
->not->toContain('refresh_token')
->not->toContain('client_secret')
->not->toContain('super-secret-token')
->not->toContain('abc')
->not->toContain('def')
->not->toContain('ghi');
})->group('ops-ux');

View File

@ -0,0 +1,117 @@
<?php
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
it('writes compliance summary_counts and marks the run succeeded', function (): void {
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, data: ['displayName' => 'Contoso']);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, data: [
'value' => [
['id' => '1', 'complianceState' => 'compliant'],
['id' => '2', 'complianceState' => 'noncompliant'],
['id' => '3', 'complianceState' => null],
],
]);
}
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'compliance.snapshot',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider' => 'microsoft',
'module' => 'compliance',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
],
]);
$job = new ProviderComplianceSnapshotJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$job->handle(
app(\App\Services\Providers\MicrosoftComplianceSnapshotService::class),
app(\App\Services\Providers\ProviderGateway::class),
app(OperationRunService::class),
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
expect($run->summary_counts)->toMatchArray([
'total' => 3,
'compliant' => 1,
'noncompliant' => 1,
'unknown' => 1,
]);
expect($run->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'compliance',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
'entra_tenant_name' => 'Contoso',
],
]);
});

View File

@ -0,0 +1,95 @@
<?php
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
test('owners can manage provider connections in their tenant', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Contoso',
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee(ProviderConnectionResource::getUrl('create', tenant: $tenant));
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))
->assertOk();
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk()
->assertSee('Contoso');
});
test('operators can view provider connections but cannot manage them', function () {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertDontSee(ProviderConnectionResource::getUrl('create', tenant: $tenant));
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))
->assertForbidden();
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk()
->assertDontSee('Update credentials')
->assertDontSee('Disable connection');
});
test('readonly users can view provider connections but cannot manage them', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertDontSee(ProviderConnectionResource::getUrl('create', tenant: $tenant));
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))
->assertForbidden();
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk()
->assertDontSee('Update credentials')
->assertDontSee('Disable connection');
});
test('provider connection edit is not accessible cross-tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$connectionB = ProviderConnection::factory()->create([
'tenant_id' => $tenantB->getKey(),
'display_name' => 'Tenant B Connection',
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connectionB], tenant: $tenantA))
->assertNotFound();
});

View File

@ -0,0 +1,104 @@
<?php
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('enables a disabled connection and sets needs_consent when credentials are missing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'disabled',
'health_status' => 'down',
'last_health_check_at' => now(),
'last_error_reason_code' => 'provider_auth_failed',
'last_error_message' => 'Some failure',
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->callAction('enable_connection');
$connection->refresh();
expect($connection->status)->toBe('needs_consent');
expect($connection->health_status)->toBe('unknown');
expect($connection->last_health_check_at)->toBeNull();
expect($connection->last_error_reason_code)->toBeNull();
expect($connection->last_error_message)->toBeNull();
});
it('enables a disabled connection and sets connected when credentials are present', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'disabled',
'health_status' => 'down',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->callAction('enable_connection');
$connection->refresh();
expect($connection->status)->toBe('connected');
expect($connection->health_status)->toBe('unknown');
});
it('shows a link to the last connection check run when present', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
],
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk()
->assertSee(OperationRunLinks::view($run, $tenant));
});

View File

@ -0,0 +1,218 @@
<?php
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
it('updates connection health and marks the run succeeded on success', function (): void {
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, data: ['id' => 'org-id', 'displayName' => 'Contoso']);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'needs_consent',
'health_status' => 'unknown',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
],
]);
$job = new ProviderConnectionHealthCheckJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
$connection->refresh();
$run->refresh();
expect($connection->status)->toBe('connected');
expect($connection->health_status)->toBe('ok');
expect($connection->last_health_check_at)->not->toBeNull();
expect($connection->last_error_reason_code)->toBeNull();
expect($connection->last_error_message)->toBeNull();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
expect($run->context)->toMatchArray([
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
'entra_tenant_name' => 'Contoso',
],
]);
expect($connection->metadata)->toMatchArray([
'entra_tenant_name' => 'Contoso',
]);
});
it('categorizes auth failures and stores sanitized reason codes and messages', function (): void {
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(
success: false,
data: [],
status: 401,
errors: ['invalid_client Authorization: Bearer super-secret-token client_secret=ghi'],
);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'needs_consent',
'health_status' => 'unknown',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
],
]);
$job = new ProviderConnectionHealthCheckJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
$connection->refresh();
$run->refresh();
expect($connection->status)->toBe('needs_consent');
expect($connection->health_status)->toBe('down');
expect($connection->last_error_reason_code)->toBe('provider_auth_failed');
expect((string) $connection->last_error_message)
->not->toContain('Authorization')
->not->toContain('Bearer ')
->not->toContain('client_secret');
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('failed');
$failures = $run->failure_summary;
expect($failures)->toBeArray()->not->toBeEmpty();
$message = (string) ($failures[0]['message'] ?? '');
expect($failures[0]['reason_code'] ?? null)->toBe('provider_auth_failed');
expect($message)
->not->toContain('Authorization')
->not->toContain('Bearer ')
->not->toContain('client_secret');
});

View File

@ -0,0 +1,93 @@
<?php
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('enqueues a connection check and creates a canonical operation run without calling Graph in request', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->callAction('check_connection');
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
expect($opRun?->outcome)->toBe('pending');
expect($opRun?->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
});
it('dedupes connection checks and does not enqueue a second job', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);
$component->callAction('check_connection');
$component->callAction('check_connection');
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1);
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
});

View File

@ -0,0 +1,36 @@
<?php
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Providers\CredentialManager;
test('provider credentials are encrypted at rest and never rendered in the UI', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$this->actingAs($user);
/** @var CredentialManager $manager */
$manager = app(CredentialManager::class);
$manager->upsertClientSecretCredential(
connection: $connection,
clientId: 'client-id',
clientSecret: 'super-secret',
);
$credential = ProviderCredential::query()
->where('provider_connection_id', $connection->getKey())
->first();
expect($credential)->not->toBeNull();
expect((string) $credential->getRawOriginal('payload'))->not->toContain('super-secret');
$this->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk()
->assertDontSee('super-secret')
->assertDontSee('client_secret');
});

View File

@ -0,0 +1,158 @@
<?php
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('dedupes inventory sync runs and does not call Graph during start', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);
$component->callAction('inventory_sync');
$component->callAction('inventory_sync');
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory.sync')
->count())->toBe(1);
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
});
it('dedupes compliance snapshot runs and does not call Graph during start', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);
$component->callAction('compliance_snapshot');
$component->callAction('compliance_snapshot');
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'compliance.snapshot')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'compliance',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(1);
Queue::assertPushed(ProviderComplianceSnapshotJob::class, 1);
});
it('blocks different provider operations for the same scope as scope busy', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);
$component->callAction('inventory_sync');
$component->callAction('compliance_snapshot');
$inventoryRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($inventoryRun)->not->toBeNull();
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0);
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($inventoryRun, $tenant));
});

View File

@ -0,0 +1,18 @@
<?php
use Illuminate\Support\Facades\Gate;
it('grants provider capabilities per tenant role', function (string $role, bool $canView, bool $canManage, bool $canRun): void {
[$user, $tenant] = createUserWithTenant(role: $role);
$this->actingAs($user);
expect(Gate::allows('provider.view', $tenant))->toBe($canView);
expect(Gate::allows('provider.manage', $tenant))->toBe($canManage);
expect(Gate::allows('provider.run', $tenant))->toBe($canRun);
})->with([
'owner' => ['owner', true, true, true],
'manager' => ['manager', true, true, true],
'operator' => ['operator', true, false, true],
'readonly' => ['readonly', true, false, false],
]);

View File

@ -0,0 +1,39 @@
<?php
use App\Filament\Pages\BreakGlassRecovery;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('allows platform superadmin to assign an owner via break-glass recovery and audits it', function () {
$superadmin = User::factory()->create(['is_platform_superadmin' => true]);
$this->actingAs($superadmin);
$tenant = Tenant::factory()->create();
$targetUser = User::factory()->create();
Livewire::test(BreakGlassRecovery::class)
->callAction('bootstrap_recover', data: [
'tenant_id' => $tenant->getKey(),
'user_id' => $targetUser->getKey(),
]);
$this->assertDatabaseHas('tenant_memberships', [
'tenant_id' => $tenant->getKey(),
'user_id' => $targetUser->getKey(),
'role' => 'owner',
'source' => 'break_glass',
]);
$audit = AuditLog::query()
->where('tenant_id', $tenant->getKey())
->where('action', 'tenant_membership.bootstrap_recover')
->latest('id')
->first();
expect($audit)->not->toBeNull();
});

View File

@ -0,0 +1,38 @@
<?php
use App\Models\TenantMembership;
use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prevents demoting the last remaining owner', function () {
[$actor, $tenant] = createUserWithTenant(role: 'owner');
$membership = TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('user_id', $actor->getKey())
->firstOrFail();
$manager = app(TenantMembershipManager::class);
$callback = fn () => $manager->changeRole($tenant, $actor, $membership, TenantRole::Readonly);
expect($callback)->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
});
it('prevents removing the last remaining owner', function () {
[$actor, $tenant] = createUserWithTenant(role: 'owner');
$membership = TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('user_id', $actor->getKey())
->firstOrFail();
$manager = app(TenantMembershipManager::class);
$callback = fn () => $manager->removeMember($tenant, $actor, $membership);
expect($callback)->toThrow(DomainException::class, 'You cannot remove the last remaining owner.');
});

View File

@ -0,0 +1,53 @@
<?php
use App\Models\AuditLog;
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('writes audit logs for membership add, role change, and remove without sensitive fields', function () {
[$actor, $tenant] = createUserWithTenant(role: 'owner');
$member = User::factory()->create();
$manager = app(TenantMembershipManager::class);
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly);
$manager->changeRole($tenant, $actor, $membership, TenantRole::Operator);
$manager->removeMember($tenant, $actor, $membership);
$actions = AuditLog::query()
->where('tenant_id', $tenant->getKey())
->whereIn('action', [
'tenant_membership.add',
'tenant_membership.role_change',
'tenant_membership.remove',
])
->pluck('action')
->all();
expect($actions)->toContain('tenant_membership.add');
expect($actions)->toContain('tenant_membership.role_change');
expect($actions)->toContain('tenant_membership.remove');
$metadata = AuditLog::query()
->where('tenant_id', $tenant->getKey())
->whereIn('action', [
'tenant_membership.add',
'tenant_membership.role_change',
'tenant_membership.remove',
])
->get()
->pluck('metadata')
->all();
foreach ($metadata as $entry) {
expect($entry)->toBeArray();
expect(array_key_exists('app_client_secret', $entry))->toBeFalse();
expect(array_key_exists('client_secret', $entry))->toBeFalse();
expect(array_key_exists('refresh_token', $entry))->toBeFalse();
expect(array_key_exists('access_token', $entry))->toBeFalse();
}
});

View File

@ -0,0 +1,43 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('bootstraps tenant creator as owner and audits the assignment', function () {
$user = User::factory()->create();
$this->actingAs($user);
$tenantGuid = '11111111-1111-1111-1111-111111111111';
Livewire::test(RegisterTenant::class)
->set('data.name', 'Acme')
->set('data.environment', 'other')
->set('data.tenant_id', $tenantGuid)
->set('data.domain', 'acme.example')
->call('register');
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$membership = TenantMembership::query()
->where('tenant_id', $tenant->getKey())
->where('user_id', $user->getKey())
->firstOrFail();
expect($membership->role)->toBe('owner');
expect($membership->source)->toBe('manual');
$audit = AuditLog::query()
->where('tenant_id', $tenant->getKey())
->where('action', 'tenant_membership.bootstrap_assign')
->latest('id')
->first();
expect($audit)->not->toBeNull();
});

View File

@ -0,0 +1,36 @@
<?php
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('can add, change role, and remove tenant members', function () {
[$actor, $tenant] = createUserWithTenant(role: 'owner');
$member = User::factory()->create();
$manager = app(TenantMembershipManager::class);
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly);
$this->assertDatabaseHas('tenant_memberships', [
'id' => $membership->getKey(),
'tenant_id' => $tenant->getKey(),
'user_id' => $member->getKey(),
'role' => 'readonly',
'source' => 'manual',
]);
$updated = $manager->changeRole($tenant, $actor, $membership, TenantRole::Operator);
expect($updated->role)->toBe('operator');
$manager->removeMember($tenant, $actor, $updated);
$this->assertDatabaseMissing('tenant_memberships', [
'tenant_id' => $tenant->getKey(),
'user_id' => $member->getKey(),
]);
});

View File

@ -0,0 +1,24 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns 404 for non-members on tenant dashboard route', function () {
$tenant = Tenant::factory()->create(['external_id' => 'tenant-a']);
$user = User::factory()->create();
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}")
->assertNotFound();
});
it('allows members to access the tenant dashboard route', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}")
->assertSuccessful();
});

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