Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
6bb3919814 feat: unify provider connection actions and notifications 2026-01-25 01:55:06 +01:00
71 changed files with 5368 additions and 63 deletions

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

@ -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;
@ -215,6 +216,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,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,46 @@
<?php
namespace App\Providers;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Policies\ProviderConnectionPolicy;
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();
Gate::define('provider.view', function (User $user, Tenant $tenant): bool {
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->tenantRole($tenant)?->canViewProviders() ?? false;
});
Gate::define('provider.manage', function (User $user, Tenant $tenant): bool {
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->tenantRole($tenant)?->canManageProviders() ?? false;
});
Gate::define('provider.run', function (User $user, Tenant $tenant): bool {
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->tenantRole($tenant)?->canRunProviderOperations() ?? false;
});
}
}

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

@ -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

@ -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

@ -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,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 @@
<?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,42 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps provider connection status safely', function (): void {
$connected = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'connected');
expect($connected->color)->toBe('success');
expect($connected->label)->toBe('Connected');
$needsConsent = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'needs_consent');
expect($needsConsent->color)->toBe('warning');
expect($needsConsent->label)->toBe('Needs consent');
$error = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'error');
expect($error->color)->toBe('danger');
expect($error->label)->toBe('Error');
$disabled = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'disabled');
expect($disabled->color)->toBe('gray');
expect($disabled->label)->toBe('Disabled');
});
it('maps provider connection health safely', function (): void {
$ok = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'ok');
expect($ok->color)->toBe('success');
expect($ok->label)->toBe('OK');
$degraded = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'degraded');
expect($degraded->color)->toBe('warning');
expect($degraded->label)->toBe('Degraded');
$down = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'down');
expect($down->color)->toBe('danger');
expect($down->label)->toBe('Down');
$unknown = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown');
expect($unknown->color)->toBe('gray');
expect($unknown->label)->toBe('Unknown');
});

View File

@ -0,0 +1,29 @@
<?php
use App\Support\OpsUx\RunFailureSanitizer;
it('normalizes provider auth and outage reason codes', function (): void {
expect(RunFailureSanitizer::normalizeReasonCode('invalid_client'))->toBe(RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED);
expect(RunFailureSanitizer::normalizeReasonCode('AADSTS700016'))->toBe(RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED);
expect(RunFailureSanitizer::normalizeReasonCode('bad_gateway'))->toBe(RunFailureSanitizer::REASON_PROVIDER_OUTAGE);
expect(RunFailureSanitizer::normalizeReasonCode('500'))->toBe(RunFailureSanitizer::REASON_PROVIDER_OUTAGE);
});
it('redacts common secret patterns and forbidden substrings', function (): void {
$message = 'Authorization: Bearer super-secret-token access_token=abc refresh_token=def client_secret=ghi password=jkl';
$sanitized = RunFailureSanitizer::sanitizeMessage($message);
expect($sanitized)
->not->toContain('Authorization')
->not->toContain('Bearer ')
->not->toContain('access_token')
->not->toContain('refresh_token')
->not->toContain('client_secret')
->not->toContain('password')
->not->toContain('super-secret-token')
->not->toContain('abc')
->not->toContain('def')
->not->toContain('ghi')
->not->toContain('jkl');
});

View File

@ -0,0 +1,73 @@
<?php
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Providers\CredentialManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns client credentials from encrypted payload', function (): void {
$connection = ProviderConnection::factory()->create([
'entra_tenant_id' => fake()->uuid(),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$manager = app(CredentialManager::class);
expect($manager->getClientCredentials($connection))
->toBe([
'client_id' => 'client-id',
'client_secret' => 'client-secret',
]);
});
it('rejects credential payload that does not match the connection scope', function (): void {
$connection = ProviderConnection::factory()->create([
'entra_tenant_id' => 'tenant-a',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'tenant_id' => 'tenant-b',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$manager = app(CredentialManager::class);
$manager->getClientCredentials($connection);
})->throws(InvalidArgumentException::class);
it('upserts client secret credentials and never serializes the payload', function (): void {
$connection = ProviderConnection::factory()->create();
$manager = app(CredentialManager::class);
$credential = $manager->upsertClientSecretCredential(
connection: $connection,
clientId: 'client-id',
clientSecret: 'client-secret',
);
expect($credential->type)->toBe('client_secret');
expect($credential->payload)->toBe([
'client_id' => 'client-id',
'client_secret' => 'client-secret',
]);
expect($credential->toArray())->not->toHaveKey('payload');
expect((string) $credential->getRawOriginal('payload'))
->not->toContain('client-secret')
->not->toContain('client_id');
});

View File

@ -0,0 +1,96 @@
<?php
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderGateway;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds per-request graph context from a provider connection + credentials', function (): void {
$connection = ProviderConnection::factory()->create([
'entra_tenant_id' => 'entra-tenant-id',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$graph = new class implements GraphClientInterface
{
public array $calls = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$this->calls[] = ['fn' => 'listPolicies', 'policyType' => $policyType, 'options' => $options];
return new GraphResponse(true);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->calls[] = ['fn' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options];
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
$this->calls[] = ['fn' => 'getOrganization', 'options' => $options];
return new GraphResponse(true);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->calls[] = ['fn' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'payload' => $payload, 'options' => $options];
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
$this->calls[] = ['fn' => 'getServicePrincipalPermissions', 'options' => $options];
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->calls[] = ['fn' => 'request', 'method' => $method, 'path' => $path, 'options' => $options];
return new GraphResponse(true);
}
};
$gateway = new ProviderGateway(
graph: $graph,
credentials: app(CredentialManager::class),
);
$gateway->getOrganization($connection);
$gateway->request($connection, 'GET', 'organization', ['query' => ['a' => 'b']]);
expect($graph->calls)->toHaveCount(2);
$first = $graph->calls[0]['options'];
$second = $graph->calls[1]['options'];
expect($first['tenant'])->toBe('entra-tenant-id');
expect($first['client_id'])->toBe('client-id');
expect($first['client_secret'])->toBe('client-secret');
expect($first['client_request_id'])->toBeString()->not->toBeEmpty();
expect($second['tenant'])->toBe('entra-tenant-id');
expect($second['client_id'])->toBe('client-id');
expect($second['client_secret'])->toBe('client-secret');
expect($second['client_request_id'])->toBeString()->not->toBeEmpty();
expect($second['query'])->toBe(['a' => 'b']);
});

View File

@ -0,0 +1,116 @@
<?php
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartGate;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('starts a provider operation and dispatches the job once', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => 'entra-tenant-id',
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $run) use (&$dispatched): void {
$dispatched++;
expect($run->type)->toBe('provider.connection.check');
},
);
expect($dispatched)->toBe(1);
expect($result->status)->toBe('started');
expect($result->dispatched)->toBeTrue();
$run = $result->run->fresh();
expect($run)->not->toBeNull();
expect($run->type)->toBe('provider.connection.check');
expect($run->status)->toBe('queued');
expect($run->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => 'entra-tenant-id',
],
]);
});
it('dedupes when the same operation is already active for the scope', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$existing = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function () use (&$dispatched): void {
$dispatched++;
},
);
expect($dispatched)->toBe(0);
expect($result->status)->toBe('deduped');
expect($result->run->getKey())->toBe($existing->getKey());
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(1);
});
it('blocks when a different operation is already active for the scope', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$blocking = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function () use (&$dispatched): void {
$dispatched++;
},
);
expect($dispatched)->toBe(0);
expect($result->status)->toBe('scope_busy');
expect($result->run->getKey())->toBe($blocking->getKey());
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(1);
});