Compare commits

..

5 Commits

Author SHA1 Message Date
Ahmed Darrazi
5e104101b6 feat(spec-078): canonical tenantless operations detail 2026-02-07 02:02:37 +01:00
Ahmed Darrazi
94d5eab217 tasks: add task breakdown for 078 (47 tasks, 7 phases, 4 user stories)
- Phase 1: Setup (2 tasks)
- Phase 2: Foundational — headless resource, dead code, test fixes (14 tasks)
- Phase 3: US1 (P1) — infolist reuse on canonical detail (11 tasks, MVP)
- Phase 4: US2 (P2) — legacy URL 404 validation (4 tasks)
- Phase 5: US3 (P2) — related links header actions (6 tasks)
- Phase 6: US4 (P3) — KPI header + list redirect (5 tasks)
- Phase 7: Polish — sweep, pint, full validation (5 tasks)
- 24 tasks marked parallel, clear dependency graph
2026-02-07 00:14:45 +01:00
Ahmed Darrazi
e57157016c plan: add implementation plan and design artifacts for 078
- plan.md: 5 implementation phases (A-E), constitution check, risk assessment, test strategy
- research.md: 5 findings (R-001 through R-005) on Filament v5 schema reuse
- data-model.md: entity documentation, routing changes, file deletion/modification map
- contracts/routes.md: canonical vs decommissioned route contracts
- quickstart.md: verification steps for implementors
- Updated copilot agent context with plan technologies
2026-02-07 00:11:20 +01:00
Ahmed Darrazi
3aa8f27213 spec: remove FR-078-005/006 detail redirect handlers
Legacy detail URLs naturally 404 after route decommission.
No redirect handlers needed — simplifies scope significantly.
FR-078-012 (list convenience redirect) retained.
Cascaded changes across goals, principles, user stories, tests,
success criteria, security, and acceptance criteria.
2026-02-06 23:54:11 +01:00
Ahmed Darrazi
58758a5bcf spec: 078 Operations tenantless canonical migration
- Single canonical run detail at /admin/operations/{run}
- Decommission auto-generated tenant-scoped pages (list + view)
- Secure 302 redirects for legacy URLs (deny-as-not-found)
- Infolist reuse strategy (InteractsWithInfolists + fallback)
- KPI header hidden in tenantless mode (Phase 1)
- Dead code cleanup (OperationsDetail.php)
- 10 test specifications covering redirects, 404 semantics, rendering
- Quality checklist: all items pass
2026-02-06 23:31:55 +01:00
116 changed files with 764 additions and 5743 deletions

View File

@ -18,9 +18,6 @@ ## Active Technologies
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -40,9 +37,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
- 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
<!-- MANUAL ADDITIONS START -->

View File

@ -1,12 +1,13 @@
<!--
Sync Impact Report
- Version change: 1.6.0 → 1.7.0
- Version change: 1.5.0 → 1.6.0
- Modified principles:
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate)
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
- Added sections:
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Removed sections: None
- RBAC & UI Enforcement Standards (RBAC-UX)
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md
@ -138,31 +139,6 @@ ### Operations / Run Observability Standard
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
For every new or modified Filament Resource / RelationManager / Page:
Required surfaces
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
- Create/Edit MUST provide consistent Save/Cancel UX.
Grouping & safety
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
- Bulk actions MUST be grouped via BulkActionGroup.
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
- Relevant mutations MUST write an audit log entry.
RBAC enforcement
- Non-member access MUST abort(404) and MUST NOT leak existence.
- Member without capability: UI visible but disabled with tooltip; server-side MUST abort(403).
- Central enforcement helpers (tenant/workspace UI enforcement) MUST be used for gating.
Spec / DoD gates
- Every spec MUST include a “UI Action Matrix”.
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
- CI MUST enforce the contract (test/command) and block merges on violations.
### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
@ -198,4 +174,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28

View File

@ -43,7 +43,6 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
## Project Structure

View File

@ -100,10 +100,6 @@ ## Requirements *(mandatory)*
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
@ -122,17 +118,6 @@ ### Functional Requirements
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|
| Resource/Page/RM | e.g. app/Filament/... | | | | | | | | |
### Key Entities *(include if feature involves data)*
- **[Entity 1]**: [What it represents, key attributes without implementation]

View File

@ -24,14 +24,6 @@ # Tasks: [FEATURE NAME]
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
- cross-plane deny-as-not-found (404) checks where applicable,
- at least one positive + one negative authorization test.
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
- filling the specs “UI Action Matrix” for all changed surfaces,
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
- grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.

View File

@ -72,7 +72,7 @@ public function selectTenant(int $tenantId): void
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void

View File

@ -14,6 +14,7 @@
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class ChooseWorkspace extends Page
{
@ -42,7 +43,7 @@ protected function getHeaderActions(): array
$user = auth()->user();
return $user instanceof User
&& $user->can('create', Workspace::class);
&& Gate::forUser($user)->check('create', Workspace::class);
})
->form([
TextInput::make('name')
@ -123,9 +124,7 @@ public function createWorkspace(array $data): void
abort(403);
}
if (! $user->can('create', Workspace::class)) {
abort(403);
}
Gate::forUser($user)->authorize('create', Workspace::class);
$workspace = Workspace::query()->create([
'name' => $data['name'],
@ -178,7 +177,7 @@ private function redirectAfterWorkspaceSelected(User $user): string
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
return TenantDashboard::getUrl(tenant: $tenant);
}
}

View File

@ -10,8 +10,6 @@
class Alerts extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';

View File

@ -10,8 +10,6 @@
class AuditLog extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';

View File

@ -84,8 +84,21 @@ public function form(Schema $schema): Schema
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255)
->helperText('Credentials are managed after tenant creation in Provider connections.'),
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
]);
}

View File

@ -11,18 +11,9 @@
use Filament\Pages\Dashboard;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Database\Eloquent\Model;
class TenantDashboard extends Dashboard
{
/**
* @param array<mixed> $parameters
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
}
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/

View File

@ -8,18 +8,16 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Auth\Capabilities;
use Filament\Pages\Page;
class TenantRequiredPermissions extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
protected static ?string $slug = 'required-permissions';
protected static ?string $title = 'Required permissions';
@ -43,28 +41,17 @@ class TenantRequiredPermissions extends Page
public static function canAccess(): bool
{
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
}
public function currentTenant(): ?Tenant
{
return static::resolveScopedTenant();
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
@ -147,7 +134,7 @@ public function resetFilters(): void
private function refreshViewModel(): void
{
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
@ -176,7 +163,7 @@ private function refreshViewModel(): void
public function reRunVerificationUrl(): ?string
{
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
@ -189,26 +176,9 @@ public function reRunVerificationUrl(): ?string
->value('id');
if (! is_int($connectionId)) {
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
return ProviderConnectionResource::getUrl('index', tenant: $tenant);
}
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant
{
$routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) {
return $routeTenant;
}
if (is_string($routeTenant) && $routeTenant !== '') {
return Tenant::query()
->where('external_id', $routeTenant)
->first();
}
return Tenant::current();
return ProviderConnectionResource::getUrl('edit', ['record' => $connectionId], tenant: $tenant);
}
}

View File

@ -54,6 +54,7 @@
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Support\Exceptions\Halt;
use Illuminate\Contracts\View\View;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@ -1836,7 +1837,7 @@ public function completeOnboarding(): void
resourceId: (string) $tenant->getKey(),
);
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function verificationRun(): ?OperationRun

View File

@ -74,6 +74,6 @@ public function openTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
@ -31,21 +32,16 @@
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class ProviderConnectionResource extends Resource
{
protected static bool $isDiscovered = false;
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false;
protected static ?string $model = ProviderConnection::class;
protected static ?string $slug = 'tenants/{tenant}/provider-connections';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-link';
protected static string|UnitEnum|null $navigationGroup = 'Providers';
@ -56,7 +52,7 @@ class ProviderConnectionResource extends Resource
protected static function hasTenantCapability(string $capability): bool
{
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -70,23 +66,6 @@ protected static function hasTenantCapability(string $capability): bool
&& $resolver->can($user, $tenant, $capability);
}
protected static function resolveScopedTenant(): ?Tenant
{
$routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) {
return $routeTenant;
}
if (is_string($routeTenant) && $routeTenant !== '') {
return Tenant::query()
->where('external_id', $routeTenant)
->first();
}
return Tenant::current();
}
public static function form(Schema $schema): Schema
{
return $schema
@ -122,7 +101,7 @@ public static function table(Table $table): Table
return $table
->modifyQueryUsing(function (Builder $query): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantId = static::resolveScopedTenant()?->getKey();
$tenantId = Tenant::current()?->getKey();
if ($workspaceId === null) {
return $query->whereRaw('1 = 0');
@ -205,7 +184,7 @@ public static function table(Table $table): Table
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -231,9 +210,6 @@ public static function table(Table $table): Table
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
@ -249,31 +225,6 @@ public static function table(Table $table): Table
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
Notification::make()
->title('Connection check blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
@ -303,7 +254,7 @@ public static function table(Table $table): Table
->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -357,25 +308,6 @@ public static function table(Table $table): Table
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
Notification::make()
->title('Inventory sync blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->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.')
@ -399,7 +331,7 @@ public static function table(Table $table): Table
->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -453,25 +385,6 @@ public static function table(Table $table): Table
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
Notification::make()
->title('Compliance snapshot blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->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.')
@ -495,7 +408,7 @@ public static function table(Table $table): Table
->color('primary')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
@ -540,7 +453,6 @@ public static function table(Table $table): Table
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->requiresConfirmation()
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->form([
TextInput::make('client_id')
@ -553,8 +465,8 @@ public static function table(Table $table): Table
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void {
$tenant = static::resolveScopedTenant();
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
@ -566,6 +478,28 @@ public static function table(Table $table): Table
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()
@ -582,7 +516,7 @@ public static function table(Table $table): Table
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
@ -653,7 +587,7 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveScopedTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
@ -708,7 +642,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantId = static::resolveScopedTenant()?->getKey();
$tenantId = Tenant::current()?->getKey();
$query = parent::getEloquentQuery();
@ -730,30 +664,4 @@ public static function getPages(): array
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
];
}
/**
* @param array<mixed> $parameters
*/
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
if (! array_key_exists('tenant', $parameters)) {
if ($tenant instanceof Tenant) {
$parameters['tenant'] = $tenant->external_id;
}
$resolvedTenant = static::resolveScopedTenant();
if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) {
$parameters['tenant'] = $resolvedTenant->external_id;
}
}
$panel ??= 'admin';
if (array_key_exists('tenant', $parameters)) {
$tenant = null;
}
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
}
}

View File

@ -17,11 +17,7 @@ class CreateProviderConnection extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant = Tenant::current();
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
@ -37,12 +33,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
protected function afterCreate(): void
{
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant = Tenant::current();
$record = $this->getRecord();
$user = auth()->user();
@ -81,21 +72,4 @@ protected function afterCreate(): void
->success()
->send();
}
private function currentTenant(): ?Tenant
{
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
}
}

View File

@ -42,7 +42,7 @@ protected function mutateFormDataBeforeSave(array $data): array
protected function afterSave(): void
{
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$record = $this->getRecord();
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
@ -109,7 +109,7 @@ protected function afterSave(): void
protected function getHeaderActions(): array
{
$tenant = $this->currentTenant();
$tenant = Tenant::current();
return [
Actions\DeleteAction::make()
@ -128,7 +128,7 @@ protected function getHeaderActions(): array
->where('context->provider_connection_id', (int) $record->getKey())
->exists())
->url(function (ProviderConnection $record): ?string {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
@ -159,7 +159,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-check-badge')
->color('success')
->visible(function (ProviderConnection $record): bool {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
@ -168,7 +168,7 @@ protected function getHeaderActions(): array
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -200,9 +200,6 @@ protected function getHeaderActions(): array
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
Action::make('manage_connections')
->label('Manage Provider Connections')
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
])
->send();
@ -218,31 +215,6 @@ protected function getHeaderActions(): array
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
Action::make('manage_connections')
->label('Manage Provider Connections')
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
Notification::make()
->title('Connection check blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
Action::make('manage_connections')
->label('Manage Provider Connections')
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
])
->send();
@ -270,7 +242,6 @@ protected function getHeaderActions(): array
->label('Update credentials')
->icon('heroicon-o-key')
->color('primary')
->requiresConfirmation()
->modalDescription('Client secret is stored encrypted and will never be shown again.')
->visible(fn (): bool => $tenant instanceof Tenant)
->form([
@ -284,8 +255,8 @@ protected function getHeaderActions(): array
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void {
$tenant = $this->currentTenant();
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
@ -297,6 +268,28 @@ protected function getHeaderActions(): array
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()
@ -321,7 +314,7 @@ protected function getHeaderActions(): array
->where('provider', $record->provider)
->count() > 1)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
@ -368,7 +361,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-arrow-path')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
@ -377,7 +370,7 @@ protected function getHeaderActions(): array
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -439,25 +432,6 @@ protected function getHeaderActions(): array
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
Notification::make()
->title('Inventory sync blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->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.')
@ -481,7 +455,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-shield-check')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
return $tenant instanceof Tenant
@ -490,7 +464,7 @@ protected function getHeaderActions(): array
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
@ -552,25 +526,6 @@ protected function getHeaderActions(): array
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
Notification::make()
->title('Compliance snapshot blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->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.')
@ -595,7 +550,7 @@ protected function getHeaderActions(): array
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
@ -667,7 +622,7 @@ protected function getHeaderActions(): array
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return;
@ -721,7 +676,7 @@ protected function getHeaderActions(): array
protected function getFormActions(): array
{
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
@ -744,7 +699,7 @@ protected function getFormActions(): array
protected function handleRecordUpdate(Model $record, array $data): Model
{
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$user = auth()->user();
@ -764,21 +719,4 @@ protected function handleRecordUpdate(Model $record, array $data): Model
return parent::handleRecordUpdate($record, $data);
}
private function currentTenant(): ?Tenant
{
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
}
}

View File

@ -58,14 +58,8 @@ class TenantResource extends Resource
// ... [Properties Omitted for Brevity] ...
protected static ?string $model = Tenant::class;
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $recordRouteKeyName = 'external_id';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
@ -174,8 +168,21 @@ public static function form(Schema $schema): Schema
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255)
->helperText('Credentials are managed in Provider connections.'),
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
]);
}
@ -279,7 +286,7 @@ public static function table(Table $table): Table
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
@ -398,13 +405,13 @@ public static function table(Table $table): Table
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
@ -727,6 +734,7 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('domain')->copyable(),
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
@ -739,6 +747,7 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Infolists\Components\TextEntry::make('app_notes')->label('Notes'),
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
Infolists\Components\TextEntry::make('rbac_status')
@ -767,7 +776,7 @@ public static function infolist(Schema $schema): Schema
->copyable(),
Infolists\Components\RepeatableEntry::make('permissions')
->label('Required permissions')
->state(fn (Tenant $record) => static::storedPermissionSnapshot($record))
->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false, useConfiguredStub: false)['permissions'])
->schema([
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('type')->badge(),
@ -785,42 +794,6 @@ public static function infolist(Schema $schema): Schema
]);
}
/**
* @return array<int, array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>
*/
protected static function storedPermissionSnapshot(Tenant $tenant): array
{
$required = config('intune_permissions.permissions', []);
$stored = $tenant->permissions()
->get()
->keyBy('permission_key');
$snapshot = [];
foreach ($required as $permission) {
$key = is_string($permission['key'] ?? null) ? (string) $permission['key'] : null;
if ($key === null || $key === '') {
continue;
}
$storedEntry = $stored->get($key);
$storedDetails = $storedEntry?->details;
$snapshot[] = [
'key' => $key,
'type' => is_string($permission['type'] ?? null) ? (string) $permission['type'] : 'application',
'description' => is_string($permission['description'] ?? null) ? (string) $permission['description'] : null,
'features' => is_array($permission['features'] ?? null) ? $permission['features'] : [],
'status' => is_string($storedEntry?->status ?? null) ? (string) $storedEntry->status : 'missing',
'details' => is_array($storedDetails) ? $storedDetails : null,
];
}
return $snapshot;
}
public static function getPages(): array
{
return [
@ -828,7 +801,6 @@ public static function getPages(): array
'create' => Pages\CreateTenant::route('/create'),
'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'),
];
}
@ -839,16 +811,6 @@ public static function getRelations(): array
];
}
/**
* @param array<mixed> $parameters
*/
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
$panel ??= 'admin';
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
}
public static function rbacAction(): Actions\Action
{
// ... [RBAC Action Omitted - No Change] ...
@ -980,6 +942,7 @@ public static function rbacAction(): Actions\Action
->url(route('admin.rbac.start', [
'tenant' => $record->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $record->external_id,
'record' => $record,
]),
])),
@ -1119,6 +1082,7 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
]));
@ -1308,6 +1272,7 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
]));

View File

@ -1,8 +0,0 @@
<?php
namespace App\Filament\Resources\TenantResource\Pages;
class ManageTenantMemberships extends ViewTenant
{
protected static ?string $title = 'Tenant memberships';
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
@ -11,9 +10,7 @@
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Services\Providers\ProviderConnectionResolver;
use App\Support\Auth\Capabilities;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
@ -35,14 +32,6 @@ protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('provider_connections')
->label('Provider connections')
->icon('heroicon-o-link')
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant' => $record->external_id], panel: 'admin'))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
@ -73,47 +62,8 @@ protected function getHeaderActions(): array
TenantConfigService $configService,
TenantPermissionService $permissionService,
RbacHealthService $rbacHealthService,
AuditLogger $auditLogger,
ProviderConnectionResolver $connectionResolver,
ProviderNextStepsRegistry $nextStepsRegistry,
AuditLogger $auditLogger
) {
$resolution = $connectionResolver->resolveDefault($record, 'microsoft');
if (! $resolution->resolved) {
$reasonCode = $resolution->effectiveReasonCode();
$nextSteps = $nextStepsRegistry->forReason($record, $reasonCode, $resolution->connection);
$notification = Notification::make()
->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->warning();
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? $step['label'] : null;
$url = is_string($step['url'] ?? null) ? $step['url'] : null;
if ($label === null || $url === null) {
continue;
}
$notification->actions([
Actions\Action::make('next_step_'.$index)
->label($label)
->url($url),
]);
break;
}
$notification->send();
return;
}
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
TenantResource::rbacAction(),

View File

@ -2,11 +2,13 @@
namespace App\Http\Controllers;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
use App\Support\Providers\ProviderReasonCodes;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
@ -18,6 +20,9 @@ class AdminConsentCallbackController extends Controller
public function __invoke(
Request $request,
AuditLogger $auditLogger,
TenantConfigService $configService,
TenantPermissionService $permissionService,
GraphClientInterface $graphClient
): View {
$expectedState = $request->session()->pull('tenant_onboard_state');
$tenantKey = $request->string('tenant')->toString();
@ -30,7 +35,23 @@ public function __invoke(
abort_if(empty($tenantIdentifier), 404);
$tenant = $this->resolveTenant($tenantIdentifier);
$tenant = Tenant::withTrashed()
->forTenant($tenantIdentifier)
->first();
if ($tenant?->trashed()) {
$tenant->restore();
}
if (! $tenant) {
$tenant = Tenant::create([
'tenant_id' => $tenantIdentifier,
'name' => 'New Tenant',
'app_client_id' => config('graph.client_id'),
'app_client_secret' => config('graph.client_secret'),
'app_status' => 'pending',
]);
}
$error = $request->string('error')->toString() ?: null;
$consentGranted = $request->has('admin_consent')
@ -44,11 +65,10 @@ public function __invoke(
default => 'pending',
};
$connection = $this->upsertProviderConnectionForConsent(
tenant: $tenant,
status: $status,
error: $error,
);
$tenant->update([
'app_status' => $status,
'app_notes' => $error,
]);
$auditLogger->log(
tenant: $tenant,
@ -59,7 +79,6 @@ public function __invoke(
'state' => $state,
'error' => $error,
'consent' => $consentGranted,
'provider_connection_id' => (int) $connection->getKey(),
],
],
status: $status === 'ok' ? 'success' : 'error',
@ -75,71 +94,133 @@ public function __invoke(
]);
}
private function resolveTenant(string $tenantIdentifier): Tenant
{
private function handleAuthorizationCodeFlow(
Request $request,
AuditLogger $auditLogger,
TenantConfigService $configService,
TenantPermissionService $permissionService,
GraphClientInterface $graphClient
): View {
$expectedState = $request->session()->pull('tenant_onboard_state');
if ($expectedState && $expectedState !== $request->string('state')->toString()) {
abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state');
}
$redirectUri = route('admin.consent.callback');
$token = $this->exchangeAuthorizationCode(
code: $request->string('code')->toString(),
redirectUri: $redirectUri
);
$tenantId = $token['tenant_id'] ?? null;
abort_if(empty($tenantId), 500, 'Tenant ID missing from token');
/** @var Tenant|null $tenant */
$tenant = Tenant::withTrashed()
->forTenant($tenantIdentifier)
->forTenant($tenantId)
->first();
if ($tenant?->trashed()) {
$tenant->restore();
}
if ($tenant instanceof Tenant) {
return $tenant;
}
return Tenant::create([
'tenant_id' => $tenantIdentifier,
if (! $tenant) {
$tenant = Tenant::create([
'tenant_id' => $tenantId,
'name' => 'New Tenant',
'app_client_id' => config('graph.client_id'),
'app_client_secret' => config('graph.client_secret'),
'app_status' => 'pending',
]);
}
private function upsertProviderConnectionForConsent(Tenant $tenant, string $status, ?string $error): ProviderConnection
{
$hasDefault = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->exists();
$orgResponse = $graphClient->getOrganization([
'tenant' => $tenant->graphTenantId(),
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
$connectionStatus = match ($status) {
'ok' => 'connected',
'error' => 'error',
'consent_denied' => 'needs_consent',
default => 'needs_consent',
};
$reasonCode = match ($status) {
'ok' => null,
'consent_denied' => ProviderReasonCodes::ProviderConsentMissing,
'error' => ProviderReasonCodes::ProviderAuthFailed,
default => ProviderReasonCodes::ProviderConsentMissing,
};
$connection = ProviderConnection::query()->updateOrCreate(
[
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) ($tenant->graphTenantId() ?? $tenant->tenant_id ?? $tenant->external_id),
],
[
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
'status' => $connectionStatus,
'health_status' => $connectionStatus === 'connected' ? 'unknown' : 'degraded',
'last_error_reason_code' => $reasonCode,
'last_error_message' => $error,
'is_default' => $hasDefault ? false : true,
],
);
if (! $hasDefault && ! $connection->is_default) {
$connection->makeDefault();
if ($orgResponse->successful()) {
$org = $orgResponse->data ?? [];
$tenant->update([
'name' => $org['displayName'] ?? $tenant->name,
'domain' => $org['verifiedDomains'][0]['name'] ?? $tenant->domain,
]);
}
return $connection;
$configResult = $configService->testConnectivity($tenant);
$permissionService->compare($tenant);
$status = $configResult['success'] ? 'ok' : 'error';
$tenant->update([
'app_status' => $status,
'app_notes' => $configResult['error_message'],
]);
$auditLogger->log(
tenant: $tenant,
action: 'tenant.consent.callback',
context: [
'metadata' => [
'status' => $status,
'error' => $configResult['error_message'],
'from' => 'authorization_code',
],
],
status: $status === 'ok' ? 'success' : 'error',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
);
return view('admin-consent-callback', [
'tenant' => $tenant,
'status' => $status,
'error' => $configResult['error_message'],
'consentGranted' => $status === 'ok',
]);
}
/**
* @return array{access_token:string,id_token:string,tenant_id:?string}
*/
private function exchangeAuthorizationCode(string $code, string $redirectUri): array
{
$response = Http::asForm()->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [
'client_id' => config('graph.client_id'),
'client_secret' => config('graph.client_secret'),
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUri,
'scope' => 'https://graph.microsoft.com/.default offline_access openid profile',
]);
if ($response->failed()) {
abort(ResponseAlias::HTTP_BAD_GATEWAY, 'Failed to exchange code for token');
}
$body = $response->json();
$idToken = $body['id_token'] ?? null;
$tenantId = $this->parseTenantIdFromToken($idToken);
return [
'access_token' => $body['access_token'] ?? '',
'id_token' => $idToken ?? '',
'tenant_id' => $tenantId,
];
}
private function parseTenantIdFromToken(?string $token): ?string
{
if (! $token || ! str_contains($token, '.')) {
return null;
}
$parts = explode('.', $token);
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')) ?: '[]', true);
return $payload['tid'] ?? $payload['tenant'] ?? null;
}
private function parseState(?string $state): ?string

View File

@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void

View File

@ -65,7 +65,7 @@ public function __invoke(Request $request): RedirectResponse
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
}

View File

@ -65,10 +65,6 @@ public function handle(Request $request, Closure $next): Response
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
abort(404);
}
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
? '/admin/choose-workspace'
: '/admin/no-access';

View File

@ -10,13 +10,12 @@
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderGateway;
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\MicrosoftProviderHealthCheck;
use App\Services\Providers\ProviderGateway;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable;
@ -202,13 +201,12 @@ public function handle(
])),
'next_steps' => $result->healthy
? []
: app(ProviderNextStepsRegistry::class)->forReason(
$tenant,
is_string($result->reasonCode) && $result->reasonCode !== ''
? $result->reasonCode
: 'unknown_error',
$connection,
),
: [[
'label' => 'Review provider connection',
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
'record' => (int) $connection->getKey(),
], tenant: $tenant),
]],
],
...$permissionChecks,
],

View File

@ -177,11 +177,6 @@ public static function currentOrFail(): self
return $tenant;
}
public function getRouteKeyName(): string
{
return 'external_id';
}
public function resolveRouteBinding($value, $field = null): ?Model
{
$field ??= $this->getRouteKeyName();
@ -291,8 +286,6 @@ public function graphTenantId(): ?string
}
/**
* @deprecated Runtime provider calls must resolve ProviderConnection + ProviderGateway.
*
* @return array{tenant:?string,client_id:?string,client_secret:?string}
*/
public function graphOptions(): array

View File

@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
class ProviderCredentialObserver
{
public function created(ProviderCredential $credential): void
{
$connection = $this->resolveConnection($credential);
if (! $connection instanceof ProviderConnection) {
return;
}
$tenant = $connection->tenant;
if (! $tenant instanceof Tenant) {
return;
}
$this->audit(
tenant: $tenant,
connection: $connection,
action: 'provider_connection.credentials_created',
changedFields: ['type', 'client_id', 'client_secret'],
);
}
public function updated(ProviderCredential $credential): void
{
$connection = $this->resolveConnection($credential);
if (! $connection instanceof ProviderConnection) {
return;
}
$tenant = $connection->tenant;
if (! $tenant instanceof Tenant) {
return;
}
$changedFields = $this->changedFields($credential);
if ($changedFields === []) {
return;
}
$action = in_array('client_secret', $changedFields, true)
? 'provider_connection.credentials_rotated'
: 'provider_connection.credentials_updated';
$this->audit(
tenant: $tenant,
connection: $connection,
action: $action,
changedFields: $changedFields,
);
}
private function resolveConnection(ProviderCredential $credential): ?ProviderConnection
{
$credential->loadMissing('providerConnection.tenant');
return $credential->providerConnection;
}
/**
* @return array<int, string>
*/
private function changedFields(ProviderCredential $credential): array
{
$fields = [];
if ($credential->isDirty('type') || $credential->wasChanged('type')) {
$fields[] = 'type';
}
$previousPayload = $credential->getOriginal('payload');
$currentPayload = $credential->payload;
$previousPayload = is_array($previousPayload) ? $previousPayload : [];
$currentPayload = is_array($currentPayload) ? $currentPayload : [];
$previousClientId = trim((string) ($previousPayload['client_id'] ?? ''));
$currentClientId = trim((string) ($currentPayload['client_id'] ?? ''));
if ($previousClientId !== $currentClientId) {
$fields[] = 'client_id';
}
$previousClientSecret = trim((string) ($previousPayload['client_secret'] ?? ''));
$currentClientSecret = trim((string) ($currentPayload['client_secret'] ?? ''));
if ($previousClientSecret !== $currentClientSecret) {
$fields[] = 'client_secret';
}
return array_values(array_unique($fields));
}
/**
* @param array<int, string> $changedFields
*/
private function audit(
Tenant $tenant,
ProviderConnection $connection,
string $action,
array $changedFields,
): void {
$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: $action,
context: [
'metadata' => [
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'credential_type' => (string) $connection->credential?->type,
'changed_fields' => $changedFields,
'redacted_fields' => ['client_secret'],
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
}

View File

@ -22,7 +22,7 @@ public function viewAny(User $user): bool
return false;
}
$tenant = $this->currentTenant();
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool
return Response::denyAsNotFound();
}
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -64,7 +64,7 @@ public function create(User $user): bool
return false;
}
$tenant = $this->currentTenant();
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
$tenant = $this->currentTenant();
$tenant = Tenant::current();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -135,21 +135,4 @@ private function currentWorkspace(): ?Workspace
? Workspace::query()->whereKey($workspaceId)->first()
: null;
}
private function currentTenant(): ?Tenant
{
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
return Tenant::current();
}
}

View File

@ -7,12 +7,10 @@
use App\Models\EntraGroupSyncRun;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderCredential;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Observers\ProviderCredentialObserver;
use App\Observers\RestoreRunObserver;
use App\Policies\BackupSchedulePolicy;
use App\Policies\EntraGroupPolicy;
@ -91,7 +89,6 @@ public function boot(): void
});
RestoreRun::observe(RestoreRunObserver::class);
ProviderCredential::observe(ProviderCredentialObserver::class);
Event::listen(TenantSet::class, function (TenantSet $event): void {
static $hasPreferencesTable;

View File

@ -6,14 +6,15 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -37,6 +38,7 @@ class AdminPanelProvider extends PanelProvider
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('admin')
->path('admin')
->login(Login::class)
@ -47,6 +49,10 @@ public function panel(Panel $panel): Panel
WorkspaceResource::registerRoutes($panel);
})
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->colors([
'primary' => Color::Amber,
])
@ -102,13 +108,13 @@ public function panel(Panel $panel): Panel
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
)
->resources([
TenantResource::class,
ProviderConnectionResource::class,
])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
TenantRequiredPermissions::class,
TenantDashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
@ -124,6 +130,8 @@ public function panel(Panel $panel): Panel
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])

View File

@ -1,94 +0,0 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class TenantPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('tenant')
->path('admin/t')
->login(Login::class)
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix(null)
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->colors([
'primary' => Color::Amber,
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.context-bar')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
)
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
TenantDashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
])
->databaseNotifications()
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
if (! app()->runningUnitTests()) {
$panel->viteTheme('resources/css/filament/admin/theme.css');
}
return $panel;
}
}

View File

@ -3,15 +3,13 @@
namespace App\Services\Graph;
use App\Models\Tenant;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use Illuminate\Support\Facades\Cache;
class ScopeTagResolver
{
public function __construct(
private readonly ProviderConnectionResolver $providerConnections,
private readonly ProviderGateway $providerGateway,
private readonly MicrosoftGraphClient $graphClient,
private readonly GraphLogger $logger,
) {}
/**
@ -44,30 +42,23 @@ public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
*/
private function fetchAllScopeTags(?Tenant $tenant = null): array
{
if (! $tenant instanceof Tenant) {
return [];
}
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
try {
$resolution = $this->providerConnections->resolveDefault($tenant, 'microsoft');
$options = ['query' => ['$select' => 'id,displayName']];
if (! $resolution->resolved || $resolution->connection === null) {
\Log::warning('Scope tag fetch blocked: provider connection unavailable', [
'tenant_id' => $tenant->id,
'reason_code' => $resolution->effectiveReasonCode(),
]);
return [];
// Add tenant credentials if provided
if ($tenant) {
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
$options['client_id'] = $tenant->app_client_id;
$options['client_secret'] = $tenant->app_client_secret;
}
$graphResponse = $this->providerGateway->request(
$resolution->connection,
$graphResponse = $this->graphClient->request(
'GET',
'/deviceManagement/roleScopeTags',
['query' => ['$select' => 'id,displayName']]
$options
);
$scopeTags = $graphResponse->data['value'] ?? [];

View File

@ -3,18 +3,13 @@
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Arr;
use RuntimeException;
use Throwable;
class PolicySnapshotService
@ -25,8 +20,6 @@ public function __construct(
private readonly GraphContractRegistry $contracts,
private readonly SnapshotValidator $snapshotValidator,
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
) {}
/**
@ -37,8 +30,6 @@ public function __construct(
public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null): array
{
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$connection = null;
$graphOptions = [];
$context = [
'tenant' => $tenantIdentifier,
@ -49,13 +40,12 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
$this->graphLogger->logRequest('get_policy', $context);
try {
$connection = $this->resolveProviderConnection($tenant);
$tenantIdentifier = (string) $connection->entra_tenant_id;
$context['tenant'] = $tenantIdentifier;
$context['provider_connection_id'] = (int) $connection->getKey();
$graphOptions = $this->providerGateway()->graphOptions($connection);
$options = ['platform' => $policy->platform] + $graphOptions;
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $policy->platform,
];
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
$select = $this->metadataOnlySelect($policy->policy_type);
@ -95,7 +85,8 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
if ($policy->policy_type === 'windowsUpdateRing') {
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
graphOptions: $graphOptions,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata,
@ -105,7 +96,8 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
policyType: $policy->policy_type,
graphOptions: $graphOptions,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
@ -114,7 +106,8 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
if ($policy->policy_type === 'groupPolicyConfiguration') {
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
graphOptions: $graphOptions,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
@ -123,7 +116,8 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
if ($policy->policy_type === 'deviceCompliancePolicy') {
[$payload, $metadata] = $this->hydrateComplianceActions(
graphOptions: $graphOptions,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
policyId: $policy->external_id,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
@ -132,7 +126,8 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
graphOptions: $graphOptions,
tenantIdentifier: $tenantIdentifier,
tenant: $tenant,
payload: is_array($payload) ? $payload : [],
metadata: $metadata
);
@ -235,7 +230,7 @@ private function formatGraphFailureReason(GraphResponse $response): string
*
* @return array{0:array,1:array}
*/
private function hydrateWindowsUpdateRing(array $graphOptions, string $policyId, array $payload, array $metadata): array
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$odataType = $payload['@odata.type'] ?? null;
$castSegment = $this->deriveTypeCastSegment($odataType);
@ -248,7 +243,11 @@ private function hydrateWindowsUpdateRing(array $graphOptions, string $policyId,
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
$response = $this->graphClient->request('GET', $castPath, Arr::except($graphOptions, ['platform']));
$response = $this->graphClient->request('GET', $castPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed() || ! is_array($response->data)) {
$metadata['properties_hydration'] = 'failed';
@ -330,7 +329,7 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload):
*
* @return array{0:array,1:array}
*/
private function hydrateConfigurationPolicySettings(string $policyType, array $graphOptions, string $policyId, array $payload, array $metadata): array
private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy($policyType);
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
@ -344,7 +343,11 @@ private function hydrateConfigurationPolicySettings(string $policyType, array $g
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, Arr::except($graphOptions, ['platform']));
$response = $this->graphClient->request('GET', $nextPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed()) {
$hydrationStatus = 'failed';
@ -386,7 +389,7 @@ private function hydrateConfigurationPolicySettings(string $policyType, array $g
*
* @return array{0:array,1:array}
*/
private function hydrateGroupPolicyConfiguration(array $graphOptions, string $policyId, array $payload, array $metadata): array
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
@ -404,7 +407,11 @@ private function hydrateGroupPolicyConfiguration(array $graphOptions, string $po
$hydrationStatus = 'complete';
while ($nextPath) {
$response = $this->graphClient->request('GET', $nextPath, Arr::except($graphOptions, ['platform']));
$response = $this->graphClient->request('GET', $nextPath, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($response->failed()) {
$hydrationStatus = 'failed';
@ -475,7 +482,11 @@ private function hydrateGroupPolicyConfiguration(array $graphOptions, string $po
$presentationNext = $presentationValuesPath;
while ($presentationNext) {
$pvResponse = $this->graphClient->request('GET', $presentationNext, Arr::except($graphOptions, ['platform']));
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
]);
if ($pvResponse->failed()) {
$metadata['warnings'] = array_values(array_unique(array_merge(
@ -548,7 +559,7 @@ private function hydrateGroupPolicyConfiguration(array $graphOptions, string $po
*
* @return array{0:array,1:array}
*/
private function hydrateComplianceActions(array $graphOptions, string $policyId, array $payload, array $metadata): array
private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
{
$existingActions = $payload['scheduledActionsForRule'] ?? null;
@ -559,7 +570,11 @@ private function hydrateComplianceActions(array $graphOptions, string $policyId,
}
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
$options = Arr::except($graphOptions, ['platform']);
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$actions = [];
$nextPath = $path;
@ -606,7 +621,7 @@ private function hydrateComplianceActions(array $graphOptions, string $policyId,
*
* @return array{0:array,1:array}
*/
private function hydrateEnrollmentNotificationTemplates(array $graphOptions, array $payload, array $metadata): array
private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array
{
$existing = $payload['notificationTemplateSnapshots'] ?? null;
@ -624,7 +639,11 @@ private function hydrateEnrollmentNotificationTemplates(array $graphOptions, arr
return [$payload, $metadata];
}
$options = Arr::except($graphOptions, ['platform']);
$options = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$snapshots = [];
$failures = 0;
@ -747,34 +766,6 @@ private function extractDefinitionIds(array $settings): array
return array_unique($definitionIds);
}
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
{
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection;
}
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
$reasonMessage,
));
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
private function stripGraphBaseUrl(string $nextLink): string
{
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/');

View File

@ -4,15 +4,10 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Arr;
use RuntimeException;
use Throwable;
@ -22,9 +17,6 @@ class PolicySyncService
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphLogger $graphLogger,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
) {}
/**
@ -54,18 +46,7 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
$synced = [];
$failures = [];
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
return [
'synced' => [],
'failures' => $this->blockedFailuresForTypes($tenant, $types, $resolution->effectiveReasonCode(), $resolution->message, $resolution->connection),
];
}
$connection = $resolution->connection;
$tenantIdentifier = (string) $connection->entra_tenant_id;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
foreach ($types as $typeConfig) {
$policyType = $typeConfig['type'];
@ -80,7 +61,10 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
]);
try {
$response = $this->providerGateway()->listPolicies($connection, $policyType, [
$response = $this->graphClient->listPolicies($policyType, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $platform,
'filter' => $filter,
]);
@ -89,7 +73,6 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
'policy_type' => $policyType,
'tenant_id' => $tenant->id,
'tenant_identifier' => $tenantIdentifier,
'provider_connection_id' => (int) $connection->getKey(),
]);
}
@ -446,17 +429,7 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
throw new RuntimeException('Tenant is archived or inactive.');
}
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf('[%s] %s', $reasonCode, $reasonMessage));
}
$connection = $resolution->connection;
$tenantIdentifier = (string) $connection->entra_tenant_id;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$this->graphLogger->logRequest('get_policy', [
'tenant' => $tenantIdentifier,
@ -466,21 +439,18 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
]);
try {
$response = $this->providerGateway()->getPolicy(
connection: $connection,
policyType: $policy->policy_type,
policyId: $policy->external_id,
options: [
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $policy->platform,
],
);
]);
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable($throwable, [
'policy_type' => $policy->policy_type,
'policy_id' => $policy->external_id,
'tenant_id' => $tenant->id,
'tenant_identifier' => $tenantIdentifier,
'provider_connection_id' => (int) $connection->getKey(),
]);
}
@ -513,68 +483,4 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
}
/**
* @param array<int, array{type: string, platform?: string|null, filter?: string|null}> $types
* @return array<int, array{policy_type: string, status: int|null, errors: array, meta: array}>
*/
private function blockedFailuresForTypes(
Tenant $tenant,
array $types,
string $reasonCode,
?string $reasonMessage = null,
?ProviderConnection $connection = null,
): array {
$knownReasonCode = ProviderReasonCodes::isKnown($reasonCode)
? $reasonCode
: ProviderReasonCodes::UnknownError;
$message = sprintf(
'[%s] %s',
$knownReasonCode,
$reasonMessage ?? 'Provider connection is not configured.',
);
$nextSteps = $this->nextStepsRegistry()->forReason($tenant, $knownReasonCode, $connection);
$failures = [];
foreach ($types as $typeConfig) {
if (! is_array($typeConfig)) {
continue;
}
$policyType = $typeConfig['type'] ?? null;
if (! is_string($policyType) || $policyType === '') {
continue;
}
$failures[] = [
'policy_type' => $policyType,
'status' => null,
'errors' => [['message' => $message]],
'meta' => [
'reason_code' => $knownReasonCode,
'next_steps' => $nextSteps,
],
];
}
return $failures;
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
private function nextStepsRegistry(): ProviderNextStepsRegistry
{
return $this->nextStepsRegistry ?? app(ProviderNextStepsRegistry::class);
}
}

View File

@ -2,23 +2,14 @@
namespace App\Services\Intune;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason;
use Carbon\CarbonImmutable;
use RuntimeException;
class RbacHealthService
{
public function __construct(
private readonly GraphClientInterface $graph,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
) {}
public function __construct(private readonly GraphClientInterface $graph) {}
/**
* @return array{status:string,reason:?string,used_artifacts:bool}
@ -35,20 +26,9 @@ public function check(Tenant $tenant): array
return $this->record($tenant, 'missing', RbacReason::MissingArtifacts->value, false);
}
try {
$connection = $this->resolveProviderConnection($tenant);
} catch (RuntimeException) {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
$context = $tenant->graphOptions();
$context = $this->providerGateway()->graphOptions($connection);
$appClientId = is_string($context['client_id'] ?? null) ? (string) $context['client_id'] : null;
if ($appClientId === null || $appClientId === '') {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
$spId = $this->resolveServicePrincipalId($appClientId, $context);
$spId = $this->resolveServicePrincipalId($tenant, $context);
if (! $spId) {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
@ -119,11 +99,11 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
];
}
private function resolveServicePrincipalId(string $appClientId, array $context): ?string
private function resolveServicePrincipalId(Tenant $tenant, array $context): ?string
{
$response = $this->graph->request('GET', 'servicePrincipals', [
'query' => [
'$filter' => "appId eq '{$appClientId}'",
'$filter' => "appId eq '{$tenant->app_client_id}'",
],
] + $context);
@ -183,32 +163,4 @@ private function assignmentIncludesGroup(array $assignment, Tenant $tenant): boo
return collect($members)->contains($expected);
}
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
{
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection;
}
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
$reasonMessage,
));
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
}

View File

@ -2,13 +2,9 @@
namespace App\Services\Intune;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -21,8 +17,6 @@ class RbacOnboardingService
public function __construct(
private readonly GraphClientInterface $graph,
private readonly AuditLogger $auditLogger,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
) {}
/**
@ -37,6 +31,10 @@ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $
return $this->failure($tenant, 'Tenant is not active', $actor);
}
if (empty($tenant->app_client_id)) {
return $this->failure($tenant, 'Tenant is missing app_client_id', $actor);
}
if (empty($accessToken)) {
return $this->failure($tenant, 'Delegated access token missing. Please sign in first.', $actor);
}
@ -48,32 +46,8 @@ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $
return $this->failure($tenant, 'Select an Intune RBAC role (roleDefinitionId required). Login to load roles.', $actor);
}
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
return $this->failure(
$tenant,
sprintf(
'[%s] %s',
ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
$reasonMessage,
),
$actor
);
}
$connection = $resolution->connection;
$context = $this->providerGateway()->graphOptions($connection);
$context = $tenant->graphOptions();
$context['access_token'] = $accessToken;
$appClientId = is_string($context['client_id'] ?? null) ? (string) $context['client_id'] : null;
if ($appClientId === null || $appClientId === '') {
return $this->failure($tenant, 'Provider credential is missing client_id.', $actor);
}
$result = [
'status' => 'success',
'warnings' => [],
@ -90,11 +64,10 @@ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $
'role_definition_id' => $roleDefinitionId,
'role_display_name' => $roleDisplayName,
'scope' => $input['scope'] ?? null,
'provider_connection_id' => (int) $connection->getKey(),
], 'success', $actor);
try {
$servicePrincipal = $this->resolveServicePrincipal($appClientId, $context);
$servicePrincipal = $this->resolveServicePrincipal($tenant->app_client_id, $context);
$result['service_principal_id'] = $servicePrincipal['id'];
$result['steps'][] = 'service_principal_resolved';
@ -207,7 +180,7 @@ private function resolveServicePrincipal(string $appClientId, array $context): a
$servicePrincipal = $response->data['value'][0] ?? null;
if (! $servicePrincipal || empty($servicePrincipal['id'])) {
throw new RuntimeException('Service principal not found for provider connection client_id');
throw new RuntimeException('Service principal not found for app_client_id');
}
return $servicePrincipal;
@ -767,16 +740,6 @@ private function failure(Tenant $tenant, string $message, ?User $actor = null):
];
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
private function audit(Tenant $tenant, string $action, array $context, string $status, ?User $actor = null): void
{
$this->auditLogger->log(

View File

@ -6,7 +6,6 @@
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
@ -14,13 +13,9 @@
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use RuntimeException;
use Throwable;
class RestoreService
@ -35,8 +30,6 @@ public function __construct(
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
private readonly AssignmentRestoreService $assignmentRestoreService,
private readonly FoundationMappingService $foundationMappingService,
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
) {}
/**
@ -260,14 +253,6 @@ public function execute(
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$baseGraphOptions = [];
if (! $dryRun) {
$connection = $this->resolveProviderConnection($tenant);
$tenantIdentifier = (string) $connection->entra_tenant_id;
$baseGraphOptions = $this->providerGateway()->graphOptions($connection);
}
$items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items);
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
@ -450,7 +435,12 @@ public function execute(
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
$payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping);
$graphOptions = ['platform' => $item->platform] + $baseGraphOptions;
$graphOptions = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $item->platform,
];
$updateMethod = $this->resolveUpdateMethod($item->policy_type);
$settingsApply = null;
@ -2698,34 +2688,6 @@ private function buildScopeTagsForVersion(
];
}
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
{
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection;
}
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
$reasonMessage,
));
}
private function providerConnections(): ProviderConnectionResolver
{
return $this->providerConnections ?? app(ProviderConnectionResolver::class);
}
private function providerGateway(): ProviderGateway
{
return $this->providerGateway ?? app(ProviderGateway::class);
}
private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void
{
if (! $tenant->isActive()) {

View File

@ -4,30 +4,25 @@
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class InventorySyncService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly PolicyTypeResolver $policyTypeResolver,
private readonly InventorySelectionHasher $selectionHasher,
private readonly InventoryMetaSanitizer $metaSanitizer,
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
private readonly ProviderConnectionResolver $providerConnections,
private readonly ProviderGateway $providerGateway,
) {}
/**
@ -248,7 +243,6 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
$warnings = [];
try {
$connection = $this->resolveProviderConnection($tenant);
$typesConfig = $this->supportedTypeConfigByType();
$policyTypes = $normalizedSelection['policy_types'] ?? [];
@ -272,14 +266,13 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
continue;
}
$response = $this->listPoliciesWithRetry(
$policyType,
[
$response = $this->listPoliciesWithRetry($policyType, [
'tenant' => $tenant->tenant_id ?? $tenant->external_id,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $typeConfig['platform'] ?? null,
'filter' => $typeConfig['filter'] ?? null,
],
$connection
);
]);
if ($response->failed()) {
$hadErrors = true;
@ -312,7 +305,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
if ($includeDeps && $this->shouldHydrateAssignments($policyType)) {
$existingAssignments = $policyData['assignments'] ?? null;
if (! is_array($existingAssignments) || count($existingAssignments) === 0) {
$hydratedAssignments = $this->fetchAssignmentsForPolicyType($policyType, $connection, $externalId, $warnings);
$hydratedAssignments = $this->fetchAssignmentsForPolicyType($policyType, $tenant, $externalId, $warnings);
if (is_array($hydratedAssignments)) {
$policyData['assignments'] = $hydratedAssignments;
}
@ -423,7 +416,7 @@ private function shouldHydrateAssignments(string $policyType): bool
* @param array<int, array<string, mixed>> $warnings
* @return null|array<int, mixed>
*/
private function fetchAssignmentsForPolicyType(string $policyType, ProviderConnection $connection, string $externalId, array &$warnings): ?array
private function fetchAssignmentsForPolicyType(string $policyType, Tenant $tenant, string $externalId, array &$warnings): ?array
{
$pathTemplate = config("graph_contracts.types.{$policyType}.assignments_list_path");
if (! is_string($pathTemplate) || $pathTemplate === '') {
@ -432,10 +425,16 @@ private function fetchAssignmentsForPolicyType(string $policyType, ProviderConne
$path = str_replace('{id}', $externalId, $pathTemplate);
$options = [
'tenant' => $tenant->tenant_id ?? $tenant->external_id,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
];
$maxAttempts = 3;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$response = $this->providerGateway->request($connection, 'GET', $path);
$response = $this->graphClient->request('GET', $path, $options);
if (! $response->failed()) {
$data = $response->data;
@ -612,12 +611,12 @@ private function mapGraphFailureToErrorCode(GraphResponse $response): string
};
}
private function listPoliciesWithRetry(string $policyType, array $options, ProviderConnection $connection): GraphResponse
private function listPoliciesWithRetry(string $policyType, array $options): GraphResponse
{
$maxAttempts = 3;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$response = $this->providerGateway->listPolicies($connection, $policyType, $options);
$response = $this->graphClient->listPolicies($policyType, $options);
if (! $response->failed()) {
return $response;
@ -640,27 +639,6 @@ private function listPoliciesWithRetry(string $policyType, array $options, Provi
return new GraphResponse(false, [], null, ['error' => ['code' => 'unexpected_exception', 'message' => 'retry loop failed']]);
}
/**
* @throws RuntimeException
*/
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
{
$resolution = $this->providerConnections->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
$reasonCode = $resolution->effectiveReasonCode();
$reasonMessage = $resolution->message ?? 'Provider connection is not configured.';
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
$reasonMessage,
));
}
return $resolution->connection;
}
/**
* @return array<string, mixed>
*/

View File

@ -557,44 +557,6 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
);
}
/**
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
*
* @param array<int, array{label?: mixed, url?: mixed}> $nextSteps
*/
public function finalizeBlockedRun(
OperationRun $run,
string $reasonCode,
array $nextSteps = [],
?string $message = null,
): OperationRun {
$reasonCode = RunFailureSanitizer::normalizeReasonCode($reasonCode);
$nextSteps = $this->sanitizeNextSteps($nextSteps);
$context = is_array($run->context) ? $run->context : [];
$context['reason_code'] = $reasonCode;
$context['next_steps'] = $nextSteps;
$run->update([
'context' => $context,
]);
$run->refresh();
return $this->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Blocked->value,
failures: [
[
'code' => 'operation.blocked',
'reason_code' => $reasonCode,
'message' => $message ?? 'Operation blocked due to provider configuration.',
],
],
);
}
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
{
$ref = null;
@ -721,29 +683,4 @@ protected function sanitizeSummaryCounts(array $summaryCounts): array
{
return SummaryCountsNormalizer::normalize($summaryCounts);
}
/**
* @param array<int, array{label?: mixed, url?: mixed}> $nextSteps
* @return array<int, array{label: string, url: string}>
*/
protected function sanitizeNextSteps(array $nextSteps): array
{
$sanitized = [];
foreach ($nextSteps as $nextStep) {
$label = is_string($nextStep['label'] ?? null) ? trim((string) $nextStep['label']) : '';
$url = is_string($nextStep['url'] ?? null) ? trim((string) $nextStep['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$sanitized[] = [
'label' => substr($label, 0, 120),
'url' => substr($url, 0, 2048),
];
}
return $sanitized;
}
}

View File

@ -7,7 +7,6 @@
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\Contracts\ProviderHealthCheck;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderReasonCodes;
use Throwable;
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
@ -57,15 +56,14 @@ public function check(ProviderConnection $connection): HealthResult
private function reasonCodeForResponse(GraphResponse $response): string
{
$candidate = match ((int) ($response->status ?? 0)) {
401 => ProviderReasonCodes::ProviderAuthFailed,
403 => ProviderReasonCodes::ProviderPermissionDenied,
429 => ProviderReasonCodes::RateLimited,
500, 502, 503, 504 => ProviderReasonCodes::NetworkUnreachable,
default => ProviderReasonCodes::UnknownError,
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,
};
return RunFailureSanitizer::normalizeReasonCode($candidate);
}
private function messageForResponse(GraphResponse $response): string
@ -92,9 +90,8 @@ private function messageForResponse(GraphResponse $response): string
private function statusForReason(string $reasonCode): string
{
return match ($reasonCode) {
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderConsentMissing => 'needs_consent',
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
RunFailureSanitizer::REASON_PERMISSION_DENIED => 'needs_consent',
default => 'error',
};
}
@ -102,10 +99,11 @@ private function statusForReason(string $reasonCode): string
private function healthForReason(string $reasonCode): string
{
return match ($reasonCode) {
ProviderReasonCodes::RateLimited => 'degraded',
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderPermissionDenied => 'down',
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

@ -1,48 +0,0 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Support\Providers\ProviderReasonCodes;
final class ProviderConnectionResolution
{
private function __construct(
public readonly bool $resolved,
public readonly ?ProviderConnection $connection,
public readonly ?string $reasonCode,
public readonly ?string $extensionReasonCode,
public readonly ?string $message,
) {}
public static function resolved(ProviderConnection $connection): self
{
return new self(
resolved: true,
connection: $connection,
reasonCode: null,
extensionReasonCode: null,
message: null,
);
}
public static function blocked(
string $reasonCode,
?string $message = null,
?string $extensionReasonCode = null,
?ProviderConnection $connection = null,
): self {
return new self(
resolved: false,
connection: $connection,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
extensionReasonCode: $extensionReasonCode,
message: $message,
);
}
public function effectiveReasonCode(): string
{
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
}
}

View File

@ -1,124 +0,0 @@
<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Support\Providers\ProviderReasonCodes;
final class ProviderConnectionResolver
{
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution
{
$defaults = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', $provider)
->where('is_default', true)
->orderBy('id')
->get();
if ($defaults->count() === 0) {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionMissing,
'No default provider connection is configured for this tenant/provider.',
);
}
if ($defaults->count() > 1) {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'Multiple default provider connections were detected.',
'ext.multiple_defaults_detected',
);
}
/** @var ProviderConnection $connection */
$connection = $defaults->first();
return $this->validateConnection($tenant, $provider, $connection);
}
public function validateConnection(Tenant $tenant, string $provider, ProviderConnection $connection): ProviderConnectionResolution
{
if ((int) $connection->tenant_id !== (int) $tenant->getKey() || (string) $connection->provider !== $provider) {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection does not match tenant/provider scope.',
'ext.connection_scope_mismatch',
$connection,
);
}
if ((string) $connection->status === 'disabled') {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is disabled.',
'ext.connection_disabled',
$connection,
);
}
if ((string) $connection->status === 'needs_consent') {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentMissing,
'Provider connection requires admin consent before use.',
'ext.connection_needs_consent',
$connection,
);
}
if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is missing target tenant scope.',
'ext.connection_tenant_missing',
$connection,
);
}
$credential = $connection->credential()->first();
if (! $credential instanceof ProviderCredential) {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderCredentialMissing,
'Provider connection is missing credentials.',
connection: $connection,
);
}
if ($credential->type !== 'client_secret') {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderCredentialInvalid,
'Provider credential type is invalid.',
'ext.invalid_credential_type',
$connection,
);
}
$payload = $credential->payload;
if (! is_array($payload)) {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderCredentialInvalid,
'Provider credential payload is invalid.',
'ext.invalid_credential_payload',
$connection,
);
}
$clientId = trim((string) ($payload['client_id'] ?? ''));
$clientSecret = trim((string) ($payload['client_secret'] ?? ''));
if ($clientId === '' || $clientSecret === '') {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderCredentialInvalid,
'Provider credential payload is missing required fields.',
'ext.missing_credential_fields',
$connection,
);
}
return ProviderConnectionResolution::resolved($connection);
}
}

View File

@ -19,31 +19,11 @@ public function getOrganization(ProviderConnection $connection): GraphResponse
return $this->graph->getOrganization($this->graphOptions($connection));
}
public function getPolicy(ProviderConnection $connection, string $policyType, string $policyId, array $options = []): GraphResponse
{
return $this->graph->getPolicy($policyType, $policyId, $this->graphOptions($connection, $options));
}
public function listPolicies(ProviderConnection $connection, string $policyType, array $options = []): GraphResponse
{
return $this->graph->listPolicies($policyType, $this->graphOptions($connection, $options));
}
public function applyPolicy(
ProviderConnection $connection,
string $policyType,
string $policyId,
array $payload,
array $options = [],
): GraphResponse {
return $this->graph->applyPolicy($policyType, $policyId, $payload, $this->graphOptions($connection, $options));
}
public function getServicePrincipalPermissions(ProviderConnection $connection, array $options = []): GraphResponse
{
return $this->graph->getServicePrincipalPermissions($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));

View File

@ -7,8 +7,6 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use ReflectionFunction;
@ -19,8 +17,6 @@ final class ProviderOperationStartGate
public function __construct(
private readonly OperationRunService $runs,
private readonly ProviderOperationRegistry $registry,
private readonly ProviderConnectionResolver $resolver,
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
) {}
/**
@ -28,39 +24,19 @@ public function __construct(
*/
public function start(
Tenant $tenant,
?ProviderConnection $connection,
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);
$resolution = $connection instanceof ProviderConnection
? $this->resolver->validateConnection($tenant, (string) $definition['provider'], $connection)
: $this->resolver->resolveDefault($tenant, (string) $definition['provider']);
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
return $this->startBlocked(
tenant: $tenant,
operationType: $operationType,
provider: (string) $definition['provider'],
module: (string) $definition['module'],
reasonCode: $resolution->effectiveReasonCode(),
extensionReasonCode: $resolution->extensionReasonCode,
reasonMessage: $resolution->message,
connection: $resolution->connection ?? $connection,
initiator: $initiator,
extraContext: $extraContext,
);
}
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $resolution): ProviderOperationStartResult {
$connection = $resolution->connection;
if (! $connection instanceof ProviderConnection) {
throw new InvalidArgumentException('Resolved provider connection is missing.');
}
return DB::transaction(function () use ($tenant, $connection, $operationType, $dispatcher, $initiator, $extraContext, $definition): ProviderOperationStartResult {
$lockedConnection = ProviderConnection::query()
->whereKey($connection->getKey())
->lockForUpdate()
@ -111,62 +87,6 @@ public function start(
});
}
/**
* @param array<string, mixed> $extraContext
*/
private function startBlocked(
Tenant $tenant,
string $operationType,
string $provider,
string $module,
string $reasonCode,
?string $extensionReasonCode = null,
?string $reasonMessage = null,
?ProviderConnection $connection = null,
?User $initiator = null,
array $extraContext = [],
): ProviderOperationStartResult {
$context = array_merge($extraContext, [
'provider' => $provider,
'module' => $module,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
]);
$identityInputs = [
'provider' => $provider,
'reason_code' => $reasonCode,
];
if (is_string($extensionReasonCode) && $extensionReasonCode !== '') {
$context['reason_code_extension'] = $extensionReasonCode;
$identityInputs['reason_code_extension'] = $extensionReasonCode;
}
if ($connection instanceof ProviderConnection) {
$context['provider_connection_id'] = (int) $connection->getKey();
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
}
$run = $this->runs->ensureRunWithIdentity(
tenant: $tenant,
type: $operationType,
identityInputs: $identityInputs,
context: $context,
initiator: $initiator,
);
$run = $this->runs->finalizeBlockedRun(
$run,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
nextSteps: $this->nextStepsRegistry->forReason($tenant, $reasonCode, $connection),
message: $reasonMessage,
);
return ProviderOperationStartResult::blocked($run);
}
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
{
$ref = null;

View File

@ -26,9 +26,4 @@ public static function scopeBusy(OperationRun $run): self
{
return new self('scope_busy', $run, false);
}
public static function blocked(OperationRun $run): self
{
return new self('blocked', $run, false);
}
}

View File

@ -17,7 +17,6 @@ public function spec(mixed $value): BadgeSpec
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
OperationRunOutcome::Blocked->value => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),

View File

@ -14,7 +14,7 @@ final class RequiredPermissionsLinks
*/
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
{
$base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id));
$base = sprintf('/admin/t/%s/required-permissions', urlencode((string) $tenant->external_id));
if ($filters === []) {
return $base;

View File

@ -50,50 +50,50 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
$providerConnectionId = $context['provider_connection_id'] ?? null;
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant);
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant);
}
if ($run->type === 'inventory.sync') {
$links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant);
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
}
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
$policyId = $context['policy_id'] ?? null;
if (is_numeric($policyId)) {
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
}
}
if ($run->type === 'directory_groups.sync') {
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant);
}
if ($run->type === 'drift.generate') {
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
$links['Drift'] = DriftLanding::getUrl(tenant: $tenant);
}
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null;
if (is_numeric($backupSetId)) {
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
}
}
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
}
if ($run->type === 'restore.execute') {
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
$restoreRunId = $context['restore_run_id'] ?? null;
if (is_numeric($restoreRunId)) {
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
}
}

View File

@ -7,7 +7,6 @@ enum OperationRunOutcome: string
case Pending = 'pending';
case Succeeded = 'succeeded';
case PartiallySucceeded = 'partially_succeeded';
case Blocked = 'blocked';
case Failed = 'failed';
/**
@ -32,7 +31,6 @@ public static function uiLabels(bool $includeReserved = false): array
self::Pending->value => 'Pending',
self::Succeeded->value => 'Succeeded',
self::PartiallySucceeded->value => 'Partially succeeded',
self::Blocked->value => 'Blocked',
self::Failed->value => 'Failed',
];

View File

@ -2,8 +2,6 @@
namespace App\Support\OpsUx;
use App\Support\Providers\ProviderReasonCodes;
final class RunFailureSanitizer
{
public const string REASON_GRAPH_THROTTLED = 'graph_throttled';
@ -38,81 +36,66 @@ public static function normalizeReasonCode(string $candidate): string
$candidate = strtolower(trim($candidate));
if ($candidate === '') {
return ProviderReasonCodes::UnknownError;
return self::REASON_UNKNOWN_ERROR;
}
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
return $candidate;
}
if (str_starts_with($candidate, 'ext.')) {
return $candidate;
}
/**
* Appendix-A taxonomy mappings:
* - `graph_throttled`/`throttled` -> `rate_limited`
* - transport/transient/outage classes -> `network_unreachable`
* - auth failures -> `provider_auth_failed`
* - permission denied classes -> `provider_permission_denied`
* - generic missing/invalid configuration classes -> `provider_connection_*`
*/
// Compatibility mappings from existing codebase labels.
$candidate = match ($candidate) {
$allowed = [
self::REASON_GRAPH_THROTTLED,
'throttled' => ProviderReasonCodes::RateLimited,
self::REASON_GRAPH_TIMEOUT,
self::REASON_PROVIDER_OUTAGE,
'graph_transient',
'dependency_unreachable' => ProviderReasonCodes::NetworkUnreachable,
self::REASON_PERMISSION_DENIED,
'graph_forbidden',
'permission_denied' => ProviderReasonCodes::ProviderPermissionDenied,
self::REASON_PROVIDER_AUTH_FAILED,
'authentication_failed' => ProviderReasonCodes::ProviderAuthFailed,
self::REASON_PROVIDER_OUTAGE,
self::REASON_VALIDATION_ERROR,
self::REASON_CONFLICT_DETECTED,
'invalid_state' => ProviderReasonCodes::ProviderConnectionInvalid,
'missing_configuration' => ProviderReasonCodes::ProviderConnectionMissing,
'unknown',
self::REASON_UNKNOWN_ERROR => ProviderReasonCodes::UnknownError,
self::REASON_UNKNOWN_ERROR,
];
if (in_array($candidate, $allowed, true)) {
return $candidate;
}
// Compatibility mappings from existing codebase labels.
$candidate = match ($candidate) {
'graph_forbidden' => self::REASON_PERMISSION_DENIED,
'graph_transient' => self::REASON_GRAPH_TIMEOUT,
'unknown' => self::REASON_UNKNOWN_ERROR,
default => $candidate,
};
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
if (in_array($candidate, $allowed, true)) {
return $candidate;
}
// Heuristic normalization for ad-hoc codes used across jobs/services.
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
return ProviderReasonCodes::RateLimited;
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 ProviderReasonCodes::ProviderAuthFailed;
return self::REASON_PROVIDER_AUTH_FAILED;
}
if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) {
return ProviderReasonCodes::NetworkUnreachable;
return self::REASON_GRAPH_TIMEOUT;
}
if (str_contains($candidate, 'outage') || str_contains($candidate, '500') || str_contains($candidate, '502') || str_contains($candidate, 'bad_gateway')) {
return ProviderReasonCodes::NetworkUnreachable;
return self::REASON_PROVIDER_OUTAGE;
}
if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) {
return ProviderReasonCodes::ProviderPermissionDenied;
return self::REASON_PERMISSION_DENIED;
}
if (str_contains($candidate, 'validation') || str_contains($candidate, 'not_found') || str_contains($candidate, 'bad_request') || str_contains($candidate, '400') || str_contains($candidate, '422')) {
return ProviderReasonCodes::ProviderConnectionInvalid;
return self::REASON_VALIDATION_ERROR;
}
if (str_contains($candidate, 'conflict') || str_contains($candidate, '409')) {
return ProviderReasonCodes::ProviderConnectionInvalid;
return self::REASON_CONFLICT_DETECTED;
}
return ProviderReasonCodes::UnknownError;
return self::REASON_UNKNOWN_ERROR;
}
public static function sanitizeMessage(string $message): string

View File

@ -1,63 +0,0 @@
<?php
namespace App\Support\Providers;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
final class ProviderNextStepsRegistry
{
/**
* @return array<int, array{label: string, url: string}>
*/
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
{
return match ($reasonCode) {
ProviderReasonCodes::ProviderConnectionMissing,
ProviderReasonCodes::ProviderConnectionInvalid,
ProviderReasonCodes::TenantTargetMismatch => [
[
'label' => 'Manage Provider Connections',
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
ProviderReasonCodes::ProviderCredentialMissing,
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConsentMissing => [
[
'label' => $connection instanceof ProviderConnection ? 'Update Credentials' : 'Manage Provider Connections',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderPermissionRefreshFailed => [
[
'label' => 'Open Required Permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::RateLimited,
ProviderReasonCodes::UnknownError => [
[
'label' => 'Review Provider Connection',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
default => [
[
'label' => 'Manage Provider Connections',
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
};
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Support\Providers;
final class ProviderReasonCodes
{
public const string ProviderConnectionMissing = 'provider_connection_missing';
public const string ProviderConnectionInvalid = 'provider_connection_invalid';
public const string ProviderCredentialMissing = 'provider_credential_missing';
public const string ProviderCredentialInvalid = 'provider_credential_invalid';
public const string ProviderConsentMissing = 'provider_consent_missing';
public const string ProviderAuthFailed = 'provider_auth_failed';
public const string ProviderPermissionMissing = 'provider_permission_missing';
public const string ProviderPermissionDenied = 'provider_permission_denied';
public const string ProviderPermissionRefreshFailed = 'provider_permission_refresh_failed';
public const string TenantTargetMismatch = 'tenant_target_mismatch';
public const string NetworkUnreachable = 'network_unreachable';
public const string RateLimited = 'rate_limited';
public const string UnknownError = 'unknown_error';
/**
* @return array<int, string>
*/
public static function all(): array
{
return [
self::ProviderConnectionMissing,
self::ProviderConnectionInvalid,
self::ProviderCredentialMissing,
self::ProviderCredentialInvalid,
self::ProviderConsentMissing,
self::ProviderAuthFailed,
self::ProviderPermissionMissing,
self::ProviderPermissionDenied,
self::ProviderPermissionRefreshFailed,
self::TenantTargetMismatch,
self::NetworkUnreachable,
self::RateLimited,
self::UnknownError,
];
}
public static function isKnown(string $reasonCode): bool
{
return in_array($reasonCode, self::all(), true) || str_starts_with($reasonCode, 'ext.');
}
}

View File

@ -6,9 +6,6 @@
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
final class TenantPermissionCheckClusters
{
@ -37,7 +34,6 @@ public static function buildChecks(Tenant $tenant, array $permissions, ?array $i
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
? $inventoryReasonCode
: 'dependency_unreachable';
$inventoryReasonCode = RunFailureSanitizer::normalizeReasonCode($inventoryReasonCode);
$inventoryMessage = $inventory['message'] ?? null;
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
@ -184,7 +180,8 @@ private static function buildCheck(
string $inventoryReasonCode,
string $inventoryMessage,
array $inventoryEvidence,
): array {
): array
{
if (! $inventoryFresh) {
return [
'key' => $key,
@ -195,7 +192,12 @@ private static function buildCheck(
'reason_code' => $inventoryReasonCode,
'message' => $inventoryMessage,
'evidence' => $inventoryEvidence,
'next_steps' => self::nextSteps($tenant, $inventoryReasonCode),
'next_steps' => [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
];
}
@ -249,10 +251,15 @@ private static function buildCheck(
'status' => VerificationCheckStatus::Fail->value,
'severity' => VerificationCheckSeverity::Critical->value,
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'reason_code' => 'ext.missing_permission',
'message' => $message,
'evidence' => $evidence,
'next_steps' => self::nextSteps($tenant, ProviderReasonCodes::ProviderPermissionMissing),
'next_steps' => [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
];
}
@ -266,10 +273,15 @@ private static function buildCheck(
'status' => VerificationCheckStatus::Warn->value,
'severity' => VerificationCheckSeverity::Medium->value,
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'reason_code' => 'ext.missing_delegated_permission',
'message' => $message,
'evidence' => $evidence,
'next_steps' => self::nextSteps($tenant, ProviderReasonCodes::ProviderPermissionRefreshFailed),
'next_steps' => [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
];
}
@ -356,25 +368,6 @@ private static function inventoryEvidence(array $inventory): array
return $pointers;
}
/**
* @return array<int, array{label: string, url: string}>
*/
private static function nextSteps(Tenant $tenant, string $reasonCode): array
{
$steps = app(ProviderNextStepsRegistry::class)->forReason($tenant, $reasonCode);
if ($steps !== []) {
return $steps;
}
return [
[
'label' => 'Open required permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
];
}
/**
* @param array<string, mixed> $row
* @return TenantPermissionRow

View File

@ -242,7 +242,7 @@ private static function sanitizeChecks(array $checks): ?array
'reason_code' => $reasonCode,
'message' => self::sanitizeMessage($messageRaw),
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
'next_steps' => self::sanitizeNextStepsPayload($check['next_steps'] ?? null),
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
];
}
@ -301,18 +301,6 @@ private static function sanitizeEvidence(array $evidence): array
return $sanitized;
}
/**
* @return array<int, array{label: string, url: string}>
*/
public static function sanitizeNextStepsPayload(mixed $nextSteps): array
{
if (! is_array($nextSteps)) {
return [];
}
return self::sanitizeNextSteps($nextSteps);
}
/**
* @param array<int, mixed> $nextSteps
* @return array<int, array{label: string, url: string}>

View File

@ -5,11 +5,26 @@
namespace App\Support\Verification;
use App\Models\OperationRun;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderReasonCodes;
final class VerificationReportWriter
{
/**
* Baseline reason code taxonomy (v1).
*
* @var array<int, string>
*/
private const array BASELINE_REASON_CODES = [
'ok',
'not_applicable',
'missing_configuration',
'permission_denied',
'authentication_failed',
'throttled',
'dependency_unreachable',
'invalid_state',
'unknown_error',
];
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
@ -163,28 +178,29 @@ private static function normalizeCheckSeverity(mixed $severity): string
private static function normalizeReasonCode(mixed $reasonCode): string
{
if (! is_string($reasonCode)) {
return ProviderReasonCodes::UnknownError;
return 'unknown_error';
}
$reasonCode = strtolower(trim($reasonCode));
if ($reasonCode === '') {
return ProviderReasonCodes::UnknownError;
return 'unknown_error';
}
if (str_starts_with($reasonCode, 'ext.')) {
return $reasonCode;
}
$reasonCode = RunFailureSanitizer::normalizeReasonCode($reasonCode);
$reasonCode = match ($reasonCode) {
'graph_throttled' => 'throttled',
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
'provider_auth_failed' => 'authentication_failed',
'validation_error', 'conflict_detected' => 'invalid_state',
'unknown' => 'unknown_error',
default => $reasonCode,
};
if (ProviderReasonCodes::isKnown($reasonCode)) {
return $reasonCode;
}
return in_array($reasonCode, ['ok', 'not_applicable'], true)
? $reasonCode
: ProviderReasonCodes::UnknownError;
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
}
/**
@ -232,7 +248,31 @@ private static function normalizeEvidence(mixed $evidence): array
*/
private static function normalizeNextSteps(mixed $steps): array
{
return VerificationReportSanitizer::sanitizeNextStepsPayload($steps);
if (! is_array($steps)) {
return [];
}
$normalized = [];
foreach ($steps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$normalized[] = [
'label' => $label,
'url' => $url,
];
}
return $normalized;
}
/**

View File

@ -4,6 +4,5 @@
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\TenantPanelProvider::class,
App\Providers\Filament\SystemPanelProvider::class,
];

View File

@ -1,47 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
if ($driver !== 'pgsql') {
// SQLite doesn't enforce UUID types; other drivers are not supported in this app.
return;
}
DB::statement('ALTER TABLE inventory_links ALTER COLUMN source_id TYPE text USING source_id::text');
DB::statement('ALTER TABLE inventory_links ALTER COLUMN target_id TYPE text USING target_id::text');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$driver = DB::getDriverName();
if ($driver !== 'pgsql') {
return;
}
// Best-effort rollback: non-UUID identifiers are coerced.
$uuidRegex = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
$sentinel = '00000000-0000-0000-0000-000000000000';
DB::statement(
"ALTER TABLE inventory_links ALTER COLUMN source_id TYPE uuid USING (CASE WHEN source_id ~* '{$uuidRegex}' THEN source_id::uuid ELSE '{$sentinel}'::uuid END)"
);
DB::statement(
"ALTER TABLE inventory_links ALTER COLUMN target_id TYPE uuid USING (CASE WHEN target_id IS NULL THEN NULL WHEN target_id ~* '{$uuidRegex}' THEN target_id::uuid ELSE NULL END)"
);
}
};

View File

@ -1,9 +1,10 @@
@php
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Links\RequiredPermissionsLinks;
$tenant = $this->currentTenant();
$tenant = Tenant::current();
$vm = is_array($viewModel ?? null) ? $viewModel : [];
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];

View File

@ -44,25 +44,7 @@
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$path = '/'.ltrim(request()->path(), '/');
$route = request()->route();
$routeName = (string) ($route?->getName() ?? '');
$tenantQuery = request()->query('tenant');
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
if ($currentTenantName === null && $hasTenantQuery) {
$queriedTenant = $tenants->first(function ($tenant) use ($tenantQuery): bool {
return $tenant instanceof Tenant
&& ((string) $tenant->external_id === (string) $tenantQuery || (string) $tenant->getKey() === (string) $tenantQuery);
});
if ($queriedTenant instanceof Tenant) {
$currentTenantName = $queriedTenant->getFilamentName();
}
}
$isTenantScopedRoute = $route?->hasParameter('tenant')
|| str_starts_with($path, '/admin/t/')
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
$isTenantScopedRoute = request()->route()?->hasParameter('tenant') || str_starts_with($path, '/admin/t/');
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
@ -83,7 +65,7 @@
<x-filament::dropdown.list>
<a
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
href="{{ ChooseWorkspace::getUrl() }}"
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
Switch workspace

View File

@ -10,6 +10,7 @@
use App\Http\Controllers\TenantOnboardingController;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceResolver;
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
@ -34,6 +35,7 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
@ -72,7 +74,7 @@
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
}
@ -139,10 +141,26 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/t/{tenant}/operations', fn () => redirect()->route('admin.operations.index'))
->name('admin.operations.legacy-index');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
->name('admin.operations.index');
@ -151,10 +169,12 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
->name('admin.monitoring.alerts');
@ -163,10 +183,12 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
->name('admin.monitoring.audit-log');
@ -175,10 +197,11 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
->name('admin.operations.view');
@ -187,10 +210,12 @@
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
'ensure-filament-tenant-selected',
])
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
->name('admin.workspace.managed-tenants.index');

View File

@ -1,19 +0,0 @@
# Plan: Inventory links support non-UUID IDs
**Branch**: `079-inventory-links-non-uuid-ids`
**Date**: 2026-02-07
## Approach
- Add a PostgreSQL migration to change `inventory_links.source_id` and `inventory_links.target_id` from `uuid` to `text`.
- Add a pgsql-specific test that asserts the column types are `text` and that upserting an edge with a non-UUID `target_id` does not error.
## Safety
- Change is limited to `inventory_links` columns only.
- Unique constraint and indexes continue to function on `text` columns.
## Testing
- Pest feature test under `tests/Feature/Inventory/`.
- Run focused test + existing inventory extraction tests.

View File

@ -1,22 +0,0 @@
# Spec 079: Inventory links support non-UUID IDs
**Date**: 2026-02-07
## Problem
Inventory dependency extraction writes edges into `inventory_links`. Some Microsoft Graph / Intune identifiers (notably scope tag IDs) can be non-UUID strings (e.g. `"0"`). The current schema defines `inventory_links.source_id` and `inventory_links.target_id` as UUID columns, causing PostgreSQL failures when non-UUID identifiers are inserted.
## Goal
Allow storing non-UUID identifiers in `inventory_links` without crashing inventory sync/extraction.
## Requirements
- `inventory_links.source_id` and `inventory_links.target_id` must accept arbitrary string identifiers.
- Existing UUID identifiers must continue to work.
- Behavior must be covered by tests.
## Non-goals
- No redesign of the dependency graph model.
- No UI/Filament changes.

View File

@ -1,18 +0,0 @@
# Tasks: Inventory links support non-UUID IDs
## Phase 1: Spec + setup
- [X] T001 Create spec folder and docs
## Phase 2: Tests (TDD)
- [X] T002 Add pgsql schema regression test in `tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`
## Phase 3: Implementation
- [X] T003 Add migration to change `inventory_links.source_id` + `target_id` to `text` on PostgreSQL
## Phase 4: Validation
- [X] T004 Run tests: `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventoryLinksNonUuidIdsTest.php`
- [X] T005 Run Pint: `vendor/bin/sail bin pint --dirty`

View File

@ -1,45 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Admin Context APIs (Spec 080)
version: 0.1.0
description: |
Minimal HTTP contract for non-Filament endpoints involved in workspace/tenant context selection.
Filament page/resource routes are not fully described here because they are generated by Filament.
The specs primary contract for those is the route map in `routes.md`.
paths:
/admin/switch-workspace:
post:
summary: Switch the active workspace context
responses:
'204': { description: Workspace switched }
'302': { description: Redirect (if implemented) }
'401': { description: Unauthenticated }
'404': { description: Not a workspace member (deny-as-not-found) }
/admin/select-tenant:
post:
summary: Select the active tenant context within the selected workspace
responses:
'204': { description: Tenant selected }
'302': { description: Redirect (if implemented) }
'401': { description: Unauthenticated }
'404': { description: Not entitled to tenant (deny-as-not-found) }
/admin/clear-tenant-context:
post:
summary: Clear the active tenant context
responses:
'204': { description: Tenant context cleared }
'302': { description: Redirect (if implemented) }
'401': { description: Unauthenticated }
components:
securitySchemes:
SessionAuth:
type: apiKey
in: cookie
name: tenantpilot_session
security:
- SessionAuth: []

View File

@ -1,52 +0,0 @@
# Route Contract — Spec 080
This document defines the **expected user-facing route surfaces** and the **required 404/403 semantics**.
## Canonical Management (workspace-scoped)
All of the following are under `/admin/*` and require:
- selected workspace context
- workspace membership (non-member → 404)
Routes:
- `GET /admin/tenants`
- `GET /admin/tenants/{tenant}`
- `GET /admin/tenants/{tenant}/memberships`
- `GET /admin/tenants/{tenant}/provider-connections`
- `GET /admin/tenants/{tenant}/provider-connections/{connection}/edit`
- `GET /admin/tenants/{tenant}/required-permissions`
- (optional) `GET /admin/tenants/{tenant}/onboarding`
Identifier contract:
- `{tenant}` MUST be `Tenant.external_id` (Entra tenant GUID)
Authorization contract:
- member without capability:
- viewing pages: allowed
- mutating actions: 403
## Canonical Operate (tenant-scoped)
All of the following are under `/admin/t/{tenant}/*` and require:
- selected workspace context
- workspace membership
- tenant entitlement (non-entitled → 404)
Routes (contract targets for US2 tests):
- `GET /admin/t/{tenant}` (tenant dashboard root)
- `GET /admin/t/{tenant}/diagnostics` (operational diagnostics page)
## Removed Tenant-Scoped Management (must 404)
The following routes MUST NOT exist (no redirects in dev stage):
- `GET /admin/t/{tenant}/provider-connections*`
- `GET /admin/t/{tenant}/required-permissions*`
- `GET /admin/t/{tenant}/memberships*`
- `GET /admin/t/{tenant}/tenants*`
## Monitoring
- `GET /admin/operations`
- `GET /admin/operations/{run}`
Monitoring pages are DB-only at render time.

View File

@ -1,71 +0,0 @@
# Data Model — Spec 080 Workspace-Managed Tenant Administration Migration
This feature is primarily a **routing + panel registration** change. No new entities are required, but the plan relies on these existing domain objects and their relationships.
## Entities
### Workspace
- Represents the portfolio/customer context.
- Key fields (typical): `id`, `name`, `slug` or `uuid`, `archived_at`, timestamps.
### WorkspaceMembership
- Joins a `User` to a `Workspace` with a role.
- Key fields: `id`, `workspace_id`, `user_id`, `role`, timestamps.
- Rules:
- Workspace membership is an isolation boundary for `/admin/*` management.
### Tenant (Managed Tenant)
- Workspace-owned representation of an Entra/Intune tenant.
- Key fields (from usage in the codebase):
- `id`
- `workspace_id`
- `external_id` (canonical route identifier; Entra tenant GUID)
- `tenant_id` (Entra tenant ID / GUID — may be same domain meaning depending on model)
- `name`, `domain`, `environment`
- `metadata` (JSON)
- `archived_at` (if supported)
- timestamps
- Notes:
- `{tenant}` route parameter refers to `Tenant.external_id` in both `/admin/tenants/{tenant}` and `/admin/t/{tenant}`.
### TenantMembership
- Joins a `User` to a `Tenant` with a tenant role.
- Key fields: `id`, `tenant_id`, `user_id`, `role`, timestamps.
- Rules:
- Tenant membership is an isolation boundary for `/admin/t/{tenant}/*`.
- Guardrails: cannot remove/demote the last Owner (existing rule in constitution and code).
### ProviderConnection
- Stores provider integration configuration for a managed tenant.
- Key fields (from resource usage):
- `id`, `workspace_id`, `tenant_id`
- `provider`
- `display_name`
- `entra_tenant_id`
- `is_default`
- `status`, `health_status`
- timestamps
- Notes:
- Treated as workspace-managed configuration, but scoped to a specific managed tenant via FK.
### AuditLog
- Append-only record of security/management events.
- Required attributes (per spec): `workspace_id`, `tenant_id`, `actor_id`, `action_id`, redacted metadata, timestamp.
### OperationRun
- Existing observability record for long-running operations.
- This migration itself should not introduce new runs; management page renders must be DB-only.
## Relationships (high level)
- Workspace 1—* WorkspaceMembership
- Workspace 1—* Tenant
- Tenant 1—* TenantMembership
- Tenant 1—* ProviderConnection
- Workspace 1—* ProviderConnection
- Workspace/Tenant 1—* AuditLog
## State & Transitions
- This feature does not add new domain state transitions.
- Any existing onboarding/activation state changes remain workspace-managed in UI (per spec) and must continue to be audited.

View File

@ -1,188 +0,0 @@
# Implementation Plan: Spec 080 Workspace-Managed Tenant Administration Migration
**Branch**: `080-workspace-managed-tenant-admin` | **Date**: 2026-02-07 | **Spec**: [/specs/080-workspace-managed-tenant-admin/spec.md](spec.md)
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Migrate tenant administration surfaces out of tenant scope (`/admin/t/{tenant}/*`) into workspace scope (`/admin/*`) and keep tenant scope strictly for operational modules.
Implementation strategy:
- Introduce a second Filament panel for tenant operations at `/admin/t/{tenant}`.
- Convert the existing admin panel at `/admin` into a tenantless workspace management panel.
- Register management resources/pages only in the workspace panel, ensuring tenant-scoped management routes are not registered (404).
- Rewire internal CTAs/links (onboarding, required permissions, provider connection edit) to the new canonical workspace routes.
## Technical Context
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Tailwind v4
**Storage**: PostgreSQL (via Sail)
**Testing**: Pest v4 (PHPUnit v12 runner)
**Target Platform**: Web application (server-rendered Filament/Livewire)
**Project Type**: Laravel monolith
**Performance Goals**: No new performance targets; management viewers must remain DB-only at render time
**Constraints**:
- No external calls during render for management viewers (DB-only).
- Dev-stage removed routes must 404 (no redirects).
- 404 vs 403 semantics must follow RBAC-UX.
**Scale/Scope**: Enterprise SaaS IA separation across admin surfaces (route + navigation correctness)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
Assessment (pre-Phase 0): PASS
- No new Graph calls are introduced by this migration.
- No new long-running operations are introduced.
- Authorization behavior is being tightened via panel separation and route registration.
- Monitoring/management viewers remain DB-only.
Notes:
- This feature changes how routes are registered (which affects discovery, global search, and navigation). It must include regression tests ensuring removed tenant-scoped management routes do not exist.
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── 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/
│ ├── Pages/
│ │ ├── Workspaces/
│ │ ├── Monitoring/
│ │ └── …
│ ├── Resources/
│ └── Concerns/
├── Providers/
│ └── Filament/
│ ├── AdminPanelProvider.php
│ ├── TenantPanelProvider.php
│ └── SystemPanelProvider.php
├── Support/
│ └── Middleware/
routes/
└── web.php
tests/
├── Feature/
└── Unit/
bootstrap/
└── providers.php
```
**Structure Decision**: Laravel monolith. Panel providers define panel boundaries. Routes outside Filament are defined in `routes/web.php`.
## Complexity Tracking
No constitution violations requiring justification.
## Phase 0 — Outline & Research
Outputs (written during `/speckit.plan`):
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/research.md`
Research questions (all resolved):
- How to configure a tenancy panel whose URLs are `/admin/t/{tenant}` without duplicating `/t/`.
- How to enforce route removal: do not register resources/pages in the tenant panel.
- How to keep global search isolated by panel registration.
## Phase 1 — Design & Contracts
Outputs:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/contracts/`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/080-workspace-managed-tenant-admin/quickstart.md`
### Panel Design
**Workspace panel (Manage)**
- ID: `admin` (keep existing ID to avoid breaking `panel:admin` middleware usage)
- Path: `/admin`
- Tenancy: disabled (no `->tenant(...)`)
- Registered artifacts: tenant management resources/pages, monitoring pages.
**Tenant panel (Operate)**
- ID: `tenant` (new)
- Path: `/admin/t`
- Tenancy: enabled via `Tenant::class` with `slugAttribute: 'external_id'`
- Tenant route prefix: blank/`null` so tenant routes become `/admin/t/{tenant}/...`
- Registered artifacts: operational resources/pages only (inventory, drift, backups, policies, directory, etc.).
- Route-shape verification is mandatory: automated regression checks must assert canonical tenant URLs are `/admin/t/{tenant}` and `/admin/t/{tenant}/...` (never `/admin/t/t/{tenant}`).
Laravel 11+ provider registration requirement:
- Register the new tenant panel provider in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/bootstrap/providers.php`.
### Routing/Removal Mechanism
Tenant-scoped management routes return 404 by construction:
- Management resources/pages are **not registered** in the tenant panel.
- No redirects (dev-stage).
### Authorization Design
- Workspace management pages: require selected workspace + membership; non-member → 404.
- Tenant operational routes: require workspace membership + tenant entitlement; non-entitled → 404.
- Mutations: capability missing → 403 (server-side policy/gate); destructive-like actions require `->requiresConfirmation()`.
### Global Search Design
- Workspace panel: managed tenants are searchable (ensure resource has Edit/View page).
- Tenant panel: tenant-management entities are not registered, so they cannot appear in global search.
## Phase 2 — Implementation Plan (Code + Tests)
Stop condition for `/speckit.plan`: this section outlines implementation, but actual task breakdown happens in `/speckit.tasks`.
Planned steps:
1. Add new panel provider for tenant operations (e.g., `App\Providers\Filament\TenantPanelProvider`).
2. Register provider in `bootstrap/providers.php` (Laravel 11+ pattern).
3. Refactor `AdminPanelProvider` into a tenantless workspace panel:
- remove tenancy configuration (`->tenant(...)`, tenant menu, tenant route prefix)
- remove tenant-only middleware from the workspace panel pipeline
4. Move/register management pages/resources into workspace panel:
- `TenantResource` (managed tenants CRUD / manage view)
- `ProviderConnectionResource` (workspace-managed connections by tenant)
- required permissions viewer page
- membership management surfaces
- onboarding/activation surfaces
5. Move/register operational pages/resources into the tenant panel.
6. Rewire internal links/CTAs that currently build tenant-scoped management URLs to the new workspace-managed canonical URLs.
7. Add regression tests (Pest) to cover:
- workspace member can access `/admin/tenants*`
- non-member gets 404
- tenant entitlement required for `/admin/t/{tenant}/...`
- canonical tenant panel route shape is `/admin/t/{tenant}/...` (no duplicated `/t`)
- tenant-scoped management routes are missing (404)
- link rewiring expectations (where feasible)
8. Run targeted test pack and Pint:
- `vendor/bin/sail artisan test --compact tests/Feature/...`
- `vendor/bin/sail bin pint --dirty`

View File

@ -1,36 +0,0 @@
# Quickstart — Spec 080 Workspace-Managed Tenant Administration Migration
## Prereqs
- Laravel Sail is used for local dev.
## Run locally
- Start services: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
## What to verify manually
1. Select a workspace (existing flow)
2. Visit workspace-managed tenant admin:
- `/admin/tenants`
- `/admin/tenants/{tenant}`
3. Visit tenant operate routes only when entitled:
- `/admin/t/{tenant}/…`
4. Confirm removed tenant-scoped management URLs return 404:
- `/admin/t/{tenant}/provider-connections`
- `/admin/t/{tenant}/required-permissions`
## Run targeted tests
- Run Spec 080 test file (to be created in Phase 2):
- `vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php`
## Formatting
- Format touched files: `vendor/bin/sail bin pint --dirty`
## Deployment note
This feature changes route registration via Filament panel providers.
No migrations are expected.

View File

@ -1,45 +0,0 @@
# Research — Spec 080 Workspace-Managed Tenant Administration Migration
Date: 2026-02-07
## Decision 1 — Two Filament panels (workspace + tenant)
- Decision: Implement a workspace (tenantless) panel at `/admin` and a tenant (tenancy) panel at `/admin/t/{tenant}`.
- Rationale: This makes “Manage vs Operate” enforceable via route registration (removed routes 404), avoids tenant-context chicken-and-egg, and matches Filament-native separation.
- Alternatives considered:
- Keep a single panel and conditionally hide resources when tenant is selected: rejected because routes still exist and semantics are harder to enforce.
## Decision 2 — Tenant panel path configuration to achieve `/admin/t/{tenant}`
- Decision: Configure the tenant panel with `path('admin/t')`, `tenant(Tenant::class, slugAttribute: 'external_id')`, and **no tenant route prefix** (`tenantRoutePrefix(null)` / default).
- Rationale: Filaments tenancy routing adds `/{tenant}` after the panel path, and the optional `tenantRoutePrefix` is only prepended when it is “filled”. Leaving it blank yields `/admin/t/{tenant}` (not `/admin/t/t/{tenant}`).
- Alternatives considered:
- Keep the existing `path('admin') + tenantRoutePrefix('t')` for a tenant panel: rejected because it would conflict with the workspace panel at the same path.
## Decision 3 — Workspace context in URLs
- Decision: Workspace-managed tenant management uses `/admin/tenants*` (workspace selected in session/context; enforced by middleware).
- Rationale: Matches current app pattern (`ensure-workspace-selected`) and reduces URL churn.
- Alternatives considered:
- `/admin/w/{workspace}/tenants*`: rejected because its not canonical for this feature and increases link surface.
## Decision 4 — View vs mutation authorization in management scope
- Decision: Management pages are viewable for workspace members; **mutations** are capability-gated (403).
- Rationale: Aligns with RBAC-UX guidance: membership is isolation (404), capability is authorization (403).
- Alternatives considered:
- Require capability to view: rejected to avoid “mysterious forbidden” UX and because spec explicitly reserves 403 for mutations.
## Decision 5 — Global search isolation
- Decision: Managed tenants are searchable in the workspace panel only; the tenant panel must not expose tenant-management entities via global search.
- Rationale: Prevents cross-scope discovery leaks and aligns with “Manage is workspace-scoped”.
- Alternatives considered:
- Disable global search entirely: rejected (spec wants workspace search behavior).
## Decision 6 — How removed tenant-scoped management routes become 404
- Decision: Do not register tenant-management resources/pages in the tenant panel.
- Rationale: In Filament, unregistered resources/pages simply do not have routes; this is the cleanest dev-stage “no redirects” behavior.
- Alternatives considered:
- Redirect legacy tenant-scoped routes: rejected (explicit non-goal).

View File

@ -1,238 +0,0 @@
# Feature Specification: Workspace-Managed Tenant Administration Migration
**Feature Branch**: `080-workspace-managed-tenant-admin`
**Created**: 2026-02-07
**Status**: Draft (implementation-ready)
**Input**: User description: "Make Manage workspace-scoped (/admin) and Operate tenant-scoped (/admin/t/{tenant}). Eliminate management CRUD from /admin/t/*"
This feature migrates all tenant administration surfaces out of the Filament tenant scope (`/admin/t/{tenant}/*`) into workspace-scoped routes (`/admin/*`). Tenant scope is reserved strictly for operational modules.
**Separation rule (normative):**
- Workspace panel (`/admin/*`) = Manage (tenants, memberships, provider connections, required permissions, onboarding/activation, monitoring).
- Tenant panel (`/admin/t/{tenant}/*`) = Operate (inventory, drift, policies, backups, directory, etc.).
**Out of scope for this feature:** introducing a new tenant panel “Home” page at `/admin/t/{tenant}`.
## Clarifications
### Session 2026-02-07
- Q: Should management pages be viewable for workspace members without manage capabilities? → A: Yes. Management pages are viewable for workspace members; only mutations are capability-gated (403).
- Q: Should this feature include a dedicated tenant panel “Home” page at `/admin/t/{tenant}`? → A: No. Do not add a new tenant home page in this feature.
- Q: For workspace-managed routes like `/admin/tenants/{tenant}`, what should `{tenant}` be? → A: `Tenant.external_id` (Entra tenant GUID), same identifier used by Filament tenancy under `/admin/t/{tenant}`.
- Q: Should “Managed Tenants” appear in Filament Global Search? → A: Yes, in the workspace panel only; tenant panel must not expose tenant-management entities in search.
- Q: For workspace-managed tenant management routes, what is the canonical URL shape? → A: `/admin/tenants*` (workspace is selected in context/session; middleware enforces it), not `/admin/w/{workspace}/tenants*`.
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Manage tenants from workspace scope (Priority: P1)
As a workspace member, I can manage (view/create/configure) managed tenants under `/admin/tenants*` without needing to enter tenant scope.
**Why this priority**: It removes the “henne-ei” context issue and makes `/admin` the canonical management surface.
**Independent Test**: Fully testable via HTTP requests asserting 200/404 and basic page visibility under `/admin/tenants*`.
**Acceptance Scenarios**:
1. **Given** I am a workspace member, **When** I visit `/admin/tenants`, **Then** I can access the Tenants management list.
2. **Given** I am a workspace member, **When** I visit `/admin/tenants/{tenant}`, **Then** I can access the Tenant management overview.
3. **Given** I am not a workspace member, **When** I visit `/admin/tenants` or `/admin/tenants/{tenant}`, **Then** I receive a 404 (deny-as-not-found).
---
### User Story 2 - Operate tenant modules only when entitled (Priority: P2)
As a user, I can operate inside a managed tenant under `/admin/t/{tenant}/*` only when Im entitled to that tenant.
**Why this priority**: It preserves tenant isolation and makes 404/403 semantics predictable.
**Independent Test**: Fully testable via an operational page route asserting 200 for entitled users and 404 for non-entitled users.
**Acceptance Scenarios**:
1. **Given** I am a workspace member and entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I can access it.
2. **Given** I am a workspace member but not entitled to the selected tenant, **When** I visit an operational route under `/admin/t/{tenant}/*`, **Then** I receive a 404 (deny-as-not-found).
---
### User Story 3 - Tenant-scoped management routes are removed (Priority: P3)
As a user, I cannot access tenant-scoped management CRUD routes anymore; they are removed and should not resolve.
**Why this priority**: It enforces IA separation via route registration (not just UI hiding).
**Independent Test**: Fully testable via HTTP 404 assertions for a set of removed routes.
**Acceptance Scenarios**:
1. **Given** any user, **When** I request `/admin/t/{tenant}/provider-connections`, **Then** I receive 404 because the route does not exist.
2. **Given** any user, **When** I request `/admin/t/{tenant}/required-permissions`, **Then** I receive 404 because the route does not exist.
3. **Given** any user, **When** I request a tenant-scoped tenant-management route (e.g. `/admin/t/{tenant}/tenants/*`), **Then** I receive 404 because the route does not exist.
---
### User Story 4 - Management actions enforce capability semantics (Priority: P2)
As a workspace member, I can see management pages, but mutations are forbidden unless I have the required capability.
**Why this priority**: It preserves enterprise RBAC semantics (404 for non-membership; 403 for missing capability on mutation).
**Independent Test**: Test a representative management mutation and assert 403 when capability is missing.
**Acceptance Scenarios**:
1. **Given** I am a workspace member without the relevant manage capability, **When** I attempt a management mutation (e.g., change role, set default connection), **Then** the server responds 403.
---
### User Story 5 - Global search isolation (Priority: P2)
As a user, global search does not leak tenant management entities across scopes.
**Why this priority**: It prevents discovery leaks and aligns with deny-as-not-found semantics.
**Independent Test**: Test that workspace global search can find allowed tenants; tenant panel search does not expose tenant-management entities; non-members discover nothing.
**Acceptance Scenarios**:
1. **Given** I am a workspace member, **When** I use workspace global search, **Then** I can find tenants I can access.
2. **Given** I am in tenant panel, **When** I use global search, **Then** it does not expose tenant-management entities.
3. **Given** I am not a workspace member, **When** I use global search, **Then** I do not discover tenant existence.
---
### Edge Cases
- Direct navigation to removed tenant-scoped management URLs.
- Stale internal CTAs/links that previously pointed at `/admin/t/{tenant}` management screens.
- Tenant slug/identifier mismatch (requesting a tenant not belonging to the active workspace context).
- Cross-scope leakage via global search results or navigation items.
- Non-member users attempting to infer tenant/workspace existence.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
### Functional Requirements
**Principles (normative):**
- “Tenants” means the workspace-managed tenants list (CRUD under `/admin/tenants*`).
- “Switch tenant” is a context action in tenant scope; it must not behave like a CRUD list.
- Management must not depend on being in tenant scope; tenant scope must not expose tenant administration CRUD.
**Routing / IA (normative):**
- Workspace management is canonical under `/admin/*`:
- `/admin/tenants` (list/create)
- `/admin/tenants/{tenant}` (manage overview)
- `/admin/tenants/{tenant}/memberships`
- `/admin/tenants/{tenant}/provider-connections`
- `/admin/tenants/{tenant}/required-permissions`
- `/admin/tenants/{tenant}/onboarding` (optional entry)
- `/admin/operations` and `/admin/operations/{run}` (monitoring)
- Tenant scope is operate-only under `/admin/t/{tenant}/*` (inventory, drift, policies, backups/restore, directory, etc.).
**Workspace context:**
- Workspace-scoped management routes MUST use the `/admin/tenants*` shape and rely on the selected workspace context (e.g., middleware) rather than including `{workspace}` in every management URL.
**Route parameter identity (normative):**
- `{tenant}` in both workspace-managed routes (`/admin/tenants/{tenant}/*`) and tenant-scoped routes (`/admin/t/{tenant}/*`) MUST refer to the managed tenant identifier `Tenant.external_id` (Entra tenant GUID).
**Authorization semantics (mandatory):**
- Non-member / not entitled to scope: 404 (deny-as-not-found).
- Member lacking capability: 403 for management mutations.
- Workspace management pages are viewable for workspace members even without manage capabilities; only mutations are capability-gated.
- All checks are server-side via Policies/Gates, using the canonical capability registry (no raw strings).
**Panel structure (native):**
- Two Filament panels are used:
- Workspace panel is tenantless and mounted at `/admin`.
- Tenant panel is tenancy-mounted at `/admin/t/{tenant}`.
- Each resource/page is registered only in the appropriate panel.
**Global search isolation (mandatory):**
- Global search behavior is defined normatively in **FR-080-014**; this section captures intent only (no cross-scope discovery leaks).
**Route removal (dev-stage requirement):**
- Tenant-scoped management routes must not exist after migration (expected 404):
- `/admin/t/{tenant}/provider-connections*`
- `/admin/t/{tenant}/required-permissions*`
- `/admin/t/{tenant}/memberships*`
- `/admin/t/{tenant}/tenants*` (any management tenancy list/view/edit)
**FR-080-001**: The system MUST expose tenant administration surfaces only under workspace scope (`/admin/tenants*`).
**FR-080-002**: The system MUST expose tenant operations only under tenant scope (`/admin/t/{tenant}/*`).
**FR-080-003**: All workspace management pages MUST require active workspace context + workspace membership; non-member returns 404.
**FR-080-004**: All tenant operational routes MUST require workspace membership + tenant entitlement; non-entitled returns 404.
**FR-080-005**: Management mutations MUST return 403 when capability is missing (in addition to any UI disabling).
**Management surfaces (workspace-scoped):**
**FR-080-006**: Tenants list and tenant manage overview MUST exist under `/admin/tenants` and `/admin/tenants/{tenant}`.
**FR-080-007**: Provider Connections CRUD MUST exist only under `/admin/tenants/{tenant}/provider-connections*`.
**FR-080-008**: Required Permissions remediation UI MUST exist only under `/admin/tenants/{tenant}/required-permissions` and render DB-only.
**FR-080-009**: Tenant Memberships/Roles management MUST exist only under `/admin/tenants/{tenant}/memberships` and audit changes.
**FR-080-010**: Activation/onboarding controls MUST live under workspace-managed tenant pages/wizard entry (not in tenant scope).
**Navigation (enterprise):**
**FR-080-011**: Workspace navigation MUST include Tenants (manage) and Monitoring (Operations, Alerts, Audit Log).
**FR-080-012**: Tenant navigation MUST include only operational modules; no tenant CRUD entry appears in tenant sidebar.
**FR-080-014**: Workspace panel global search MAY return managed tenants only when the user can access them; tenant panel global search MUST NOT include tenant-management resources.
**FR-080-015**: Workspace-managed tenant management routes MUST be reachable under `/admin/tenants*` with a selected workspace context; `/admin/w/{workspace}` is not the canonical management route shape for this feature.
**Filament constraint (hard rule):** any globally searchable Resource MUST have an Edit or View page; otherwise global search will return no results.
**Observability & Audit (minimal, mandatory):**
**FR-080-013**: The system MUST emit audit events for management mutations (tenant changes, role changes, provider connection CRUD/default selection, activation/onboarding state changes) with redacted fields only.
### Key Entities *(include if feature involves data)*
- **Workspace**: Portfolio/customer context; primary security boundary for `/admin/*`.
- **ManagedTenant**: Workspace-owned representation of an Entra/Intune tenant (identified by Entra tenant GUID).
- **TenantMembership**: Assignment of a user to a managed tenant with a role (Owner/Manager/Operator/Readonly).
- **ProviderConnection**: Stored connection/config for provider integration, scoped to a managed tenant.
- **Capability**: Canonical capability registry entries used for authorization decisions.
- **AuditLog entry**: Append-only event record for management mutations (redacted).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-080-001**: Tenant management surfaces are reachable only under `/admin/tenants*`.
- **SC-080-002**: Tenant operational surfaces are reachable only under `/admin/t/{tenant}/*`.
- **SC-080-003**: Tenant-scoped management routes are not registered and return 404.
- **SC-080-004**: Authorization semantics are consistent: non-member 404, missing capability yields 403 on mutations.
- **SC-080-005**: Internal CTAs/links to manage provider connections/required permissions point to workspace-managed routes.

View File

@ -1,200 +0,0 @@
---
description: "Task breakdown for Spec 080 implementation"
---
# Tasks: Workspace-Managed Tenant Administration Migration (Spec 080)
**Input**: Design documents from `/specs/080-workspace-managed-tenant-admin/` (`plan.md`, `spec.md`, `contracts/routes.md`, `research.md`, `data-model.md`, `quickstart.md`)
**Non-negotiables (repo rules)**
- Filament v5 + Livewire v4.0+ only.
- Laravel 11+: Filament panel providers are registered in `bootstrap/providers.php`.
- RBAC-UX semantics: non-member/non-entitled → 404; member missing capability on mutation → 403.
- Removed tenant-scoped management routes must not be registered (404, no redirects).
- Tests are REQUIRED (Pest).
## Phase 1: Setup (Panel Scaffolding)
**Purpose**: Introduce the tenant operations panel without changing behavior yet.
- [X] T001 Create tenant operations panel provider in app/Providers/Filament/TenantPanelProvider.php
- [X] T002 Register new provider in bootstrap/providers.php (add App\Providers\Filament\TenantPanelProvider::class)
**Checkpoint**: App boots with both panels registered.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the manage vs operate separation mechanisms (routing + middleware + discovery boundaries).
- [X] T003 Refactor workspace panel to be tenantless in app/Providers/Filament/AdminPanelProvider.php (remove ->tenant(...), ->tenantRoutePrefix('t'), tenant menu/searchable menu)
- [X] T004 Update workspace panel middleware stack in app/Providers/Filament/AdminPanelProvider.php (remove ensure-filament-tenant-selected and App\Support\Middleware\DenyNonMemberTenantAccess for workspace panel)
- [X] T005 Configure tenant panel tenancy + middleware in app/Providers/Filament/TenantPanelProvider.php (enable ->tenant(App\Models\Tenant::class, slugAttribute: 'external_id'), enforce canonical path `/admin/t/{tenant}/...`, and add ensure-filament-tenant-selected + DenyNonMemberTenantAccess)
- [X] T006 Constrain resource/page discovery so management artifacts do not get registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (use dedicated discovery roots like app/Filament/Tenant/**)
- [X] T007 [P] Add a dedicated base feature test file tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (placeholder tests + factories usage notes)
- [X] T030 [P] [FOUNDATION] Add route-shape regression tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php asserting tenant routes resolve as `/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics` and never `/admin/t/t/{tenant}/...`
**Checkpoint**: Workspace panel does not require tenant context; tenant panel is isolated by discovery roots.
---
## Phase 3: User Story 1 — Manage tenants from workspace scope (Priority: P1) 🎯 MVP
**Goal**: Tenants management is canonical under `/admin/tenants*` without needing tenant scope.
**Independent Test**: As a workspace member, `GET /admin/tenants` returns 200; non-member returns 404.
### Tests (Pest) — US1
- [X] T008 [P] [US1] Add access tests for workspace-managed tenant list in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404)
- [X] T009 [P] [US1] Add access tests for workspace-managed tenant view route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (member 200, non-member 404)
- [X] T031 [P] [US1] Add access tests for workspace-managed memberships route in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/tenants/{tenant}/memberships`, member 200, non-member 404)
### Implementation — US1
- [X] T010 [US1] Move/rename management routes to match /admin/tenants* in app/Filament/Resources/TenantResource.php (ensure resource slug becomes tenants under workspace panel)
- [X] T011 [US1] Ensure TenantResource is registered only in workspace panel (update app/Providers/Filament/AdminPanelProvider.php registration/discovery strategy)
- [X] T012 [US1] Ensure tenant route parameter identity uses Tenant.external_id in app/Models/Tenant.php (route key name) OR in TenantResource route binding configuration
- [X] T032 [US1] Ensure memberships management surface exists only under workspace scope in app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php and app/Filament/Resources/TenantResource.php (`/admin/tenants/{tenant}/memberships`)
**Checkpoint**: `/admin/tenants` works in the workspace panel.
---
## Phase 4: User Story 2 — Operate inside tenant scope only when entitled (Priority: P2)
**Goal**: Operational pages remain under `/admin/t/{tenant}/*` and return 404 when user is not entitled.
**Independent Test**: Entitled user can access `GET /admin/t/{tenant}` and `GET /admin/t/{tenant}/diagnostics`; non-entitled user gets 404.
### Tests (Pest) — US2
- [X] T013 [P] [US2] Add tenant entitlement tests for concrete operational routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (`/admin/t/{tenant}` and `/admin/t/{tenant}/diagnostics`: entitled 200, non-entitled 404)
### Implementation — US2
- [X] T014 [US2] Register/relocate tenant operational dashboard page into tenant panel in app/Filament/Pages/TenantDashboard.php and app/Providers/Filament/TenantPanelProvider.php
- [X] T015 [US2] Ensure tenant selection redirects into tenant panel routes in app/Filament/Pages/ChooseTenant.php (redirect should target /admin/t/{tenant}/...)
**Checkpoint**: The contracted operational routes are reachable only when entitled.
---
## Phase 5: User Story 3 — Remove tenant-scoped management CRUD routes (Priority: P3)
**Goal**: Tenant-scoped management URLs do not resolve because resources/pages are not registered in tenant panel.
**Independent Test**: Requests to removed routes return 404 (route does not exist).
### Tests (Pest) — US3
- [X] T016 [P] [US3] Add 404 regression tests for removed routes in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (e.g. /admin/t/{tenant}/provider-connections, /admin/t/{tenant}/required-permissions)
- [X] T033 [P] [US3] Add tenant navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php ensuring tenant panel sidebar does not expose tenant-management entries (Tenants, Provider Connections, Memberships)
### Implementation — US3
- [X] T017 [US3] Ensure management resources are not discovered/registered in tenant panel (verify TenantResource + ProviderConnectionResource not in tenant discovery roots in app/Providers/Filament/TenantPanelProvider.php)
- [X] T018 [US3] Ensure required permissions page is not registered in tenant panel in app/Providers/Filament/TenantPanelProvider.php (and/or relocate page class under workspace-managed location)
- [X] T034 [US3] Ensure tenant panel navigation is operate-only by construction in app/Providers/Filament/TenantPanelProvider.php (no TenantResource/ProviderConnectionResource/TenantMemberships registration)
**Checkpoint**: Removed tenant-scoped management paths return 404 (no redirects).
---
## Phase 6: User Story 4 — Management actions enforce capability semantics (Priority: P2)
**Goal**: Workspace members can view management pages; mutations are forbidden (403) unless capability is present.
**Independent Test**: A representative management mutation returns 403 for a workspace member missing capability.
### Tests (Pest) — US4
- [X] T019 [P] [US4] Add mutation authorization test asserting 403 (member missing capability) in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
- [X] T035 [P] [US4] Add audit assertion test for tenant membership mutation in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (writes redacted AuditLog with stable action ID)
### Implementation — US4
- [X] T020 [US4] Audit management mutations in app/Services/Intune/AuditLogger.php (or existing audit service) for provider connection + membership changes with stable action IDs
- [X] T021 [US4] Ensure destructive-like actions use ->requiresConfirmation() in app/Filament/Resources/TenantResource.php and app/Filament/Resources/ProviderConnectionResource.php
**Checkpoint**: 403 vs 404 semantics match spec for mutations.
---
## Phase 7: User Story 5 — Global search isolation (Priority: P2)
**Goal**: Tenant management entities are searchable only in workspace panel; tenant panel global search does not expose them.
**Independent Test**: Workspace panel global search can resolve TenantResource results; tenant panel global search does not include TenantResource/ProviderConnectionResource.
### Tests (Pest) — US5
- [X] T022 [P] [US5] Add global search scoping tests in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php (workspace panel finds tenant; tenant panel does not expose management resources)
### Implementation — US5
- [X] T023 [US5] Ensure TenantResource has a View/Edit page for global search compliance in app/Filament/Resources/TenantResource/Pages/*
- [X] T024 [US5] Ensure tenant panel does not register tenant-management resources/pages (verify discovery roots + resource registration in app/Providers/Filament/TenantPanelProvider.php)
**Checkpoint**: Global search results do not leak across scopes.
---
## Phase 8: Polish & Cross-Cutting
**Purpose**: Link rewiring, consistency, formatting, and minimal regression validation.
- [X] T025 [P] Rewire internal CTAs from tenant-scoped management URLs to workspace-managed routes in app/Filament/Pages/Workspaces/ManagedTenantsLanding.php and app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [X] T026 [P] Update required permissions viewer to be workspace-managed + DB-only in app/Filament/Pages/TenantRequiredPermissions.php (accept tenant via route param, avoid Tenant::current())
- [X] T027 [P] Update provider connections to support workspace-managed per-tenant routes in app/Filament/Resources/ProviderConnectionResource.php (query should use route tenant param when not in tenancy)
- [X] T038 [P] Add Provider Connections CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (links to /admin/tenants/{tenant}/provider-connections)
- [X] T036 [P] Add workspace navigation regression test in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php to assert Tenants + Monitoring entries are present in workspace panel after panel split
- [X] T037 [P] Extend RBAC regression coverage for panel boundaries in tests/Feature/TenantRBAC/* (deny-as-not-found across workspace/tenant routing boundaries)
- [X] T028 Run formatting on touched files with vendor/bin/sail bin pint --dirty (formats app/** and tests/**)
- [X] T029 Run focused tests with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
- [X] T039 Run formatting on touched files again (post-T038) with vendor/bin/sail bin pint --dirty
- [X] T040 Re-run focused spec tests (post-T038) with vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
---
## Dependencies & Execution Order
### User Story Dependency Graph
- Setup (Phase 1) → Foundational (Phase 2) → US1 (MVP)
- US2 depends on Phase 2
- US3 depends on Phase 2 (route removal works once panel discovery boundaries are in place)
- US4 depends on US1 (needs workspace management routes/actions)
- US5 depends on Phase 2 + US1 (workspace management resources must exist)
- Polish tasks depend on US1US5 completion for stable regression assertions (T036, T037).
### Parallel Opportunities
- After Phase 2 completes:
- US1 tests (T008T009, T031) can be written in parallel with US1 implementation (T010T012, T032).
- US2 (T013T015) can proceed in parallel with US1.
- US3 tests (T016, T033) can be added early as regression guards.
---
## Parallel Example: US1
- Write tests: T008 + T009 in tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
- Implement routes: T010 in app/Filament/Resources/TenantResource.php
---
## Implementation Strategy
### MVP Scope
- Complete Phase 1 + Phase 2 + US1 (T001T012)
- Validate with T029 and manual checks from quickstart.md
### Incremental Delivery
- Add US2 + US3 next (routing guarantees + entitlement semantics)
- Then US4 (mutation semantics + audit) and US5 (global search isolation)

View File

@ -1,35 +0,0 @@
# Specification Quality Checklist: Provider Connection Full Cutover
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-07
**Feature**: [specs/081-provider-connection-cutover/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 performed on 2026-02-07.
- Reserved numbering (e.g., 900/999) exists in this repo; feature number was set explicitly to 081.

View File

@ -1,13 +0,0 @@
# Contracts (Spec 081)
This spec does not introduce a new HTTP API surface.
## Graph Contract Registry
All Microsoft Graph calls must continue to route through `GraphClientInterface` and must be modeled in `config/graph_contracts.php` (per constitution). Spec 081 changes how credentials are sourced (ProviderConnection/ProviderCredential) and how provider-backed operations are gated/recorded; it does not add new Graph endpoints.
## Planned Contract Work (Implementation)
- Verify all Graph calls made by cutover call sites are already represented in `config/graph_contracts.php`.
- If any missing endpoints are discovered while removing legacy tenant-credential reads, add them to the contract registry as part of the implementation PR(s).
- Add/adjust automated coverage so Spec 081 refactors fail tests if Graph call sites bypass or drift from the contract registry.

View File

@ -1,139 +0,0 @@
# Data Model: Provider Connection Full Cutover
**Feature**: [specs/081-provider-connection-cutover/spec.md](spec.md)
**Date**: 2026-02-07
This document describes the entities involved in Spec 081 using the repos current schema.
## Entities
### Tenant
**Represents**: A managed tenant target (Entra/Intune tenant) within a workspace.
**Relevant attributes (existing)**
- `id` (PK)
- `workspace_id` (FK)
- `name`
- `tenant_id` (GUID-ish, used as Entra tenant ID)
- `external_id` (alternate tenant identifier)
- Legacy (deprecated by this spec):
- `app_client_id`
- `app_client_secret` (encrypted)
- `app_certificate_thumbprint`
- `app_notes`
**Derived helpers (existing)**
- `graphTenantId(): ?string` returns `tenant_id` or `external_id`.
- `graphOptions(): array{tenant:?string,client_id:?string,client_secret:?string}` (to be deprecated/unused at runtime).
**Relationships (existing)**
- `providerConnections(): HasMany` → ProviderConnection
- `providerCredentials(): HasManyThrough` → ProviderCredential (via ProviderConnection)
- `operationRuns(): HasMany` (implicit via OperationRun.tenant_id)
### ProviderConnection
**Represents**: A workspace-owned integration connection for a tenant + provider (e.g., Microsoft).
**Table**: `provider_connections`
**Fields (existing)**
- `id` (PK)
- `workspace_id` (FK, NOT NULL after migration)
- `tenant_id` (FK)
- `provider` (string, e.g. `microsoft`)
- `entra_tenant_id` (string; expected to match the tenants Entra GUID)
- `display_name` (string)
- `is_default` (bool)
- `status` (string; default `needs_consent`)
- `health_status` (string; default `unknown`)
- `scopes_granted` (jsonb array)
- `last_health_check_at` (timestamp)
- `last_error_reason_code` (string, nullable)
- `last_error_message` (string, nullable; must be sanitized)
- `metadata` (jsonb)
- `created_at`, `updated_at`
**Relationships (existing)**
- `tenant(): BelongsTo`
- `workspace(): BelongsTo`
- `credential(): HasOne` → ProviderCredential
**Invariants (existing + required)**
- Uniqueness: `unique (tenant_id, provider, entra_tenant_id)`
- Exactly one default per (tenant_id, provider): enforced by partial unique index `provider_connections_default_unique`.
**Behaviors (existing)**
- `makeDefault()` clears other defaults and sets this record default in a DB transaction.
### ProviderCredential
**Represents**: Encrypted credential material for a provider connection.
**Table**: `provider_credentials`
**Fields (existing)**
- `id` (PK)
- `provider_connection_id` (FK, unique)
- `type` (string; default `client_secret`)
- `payload` (encrypted array; hidden from serialization)
- `created_at`, `updated_at`
**Payload contract (current)**
- For `type=client_secret`:
- `client_id` (string)
- `client_secret` (string)
- optional `tenant_id` (string) validated against `ProviderConnection.entra_tenant_id`
### OperationRun
**Represents**: A canonical record for a long-running or operationally relevant action.
**Table**: `operation_runs`
**Key fields (existing)**
- `id` (PK)
- `workspace_id` (FK)
- `tenant_id` (FK nullable in some cases)
- `type` (string)
- `status` (`queued`|`running`|`completed`)
- `outcome` (`pending`|`succeeded`|`partially_succeeded`|`failed` + reserved `cancelled`)
- `context` (json)
- `failure_summary` (json)
- `summary_counts` (json)
- `started_at`, `completed_at`
**Context contract (provider-backed runs)**
- `provider` (string)
- `provider_connection_id` (int)
- `target_scope.entra_tenant_id` (string)
- `module` (string; from ProviderOperationRegistry definition)
**Spec 081 extension (planned)**
- Introduce `outcome=blocked` and store `reason_code` + link-only `next_steps` in safe context/failure summary.
## State Transitions
### ProviderConnection default selection
- `is_default: false -> true` via `makeDefault()`.
- Invariant: only one default per (tenant_id, provider).
### Provider-backed operation starts
- Start surface enqueues work and creates/dedupes `OperationRun`.
- If blocked (missing default connection/credential), an `OperationRun` is still created and finalized as blocked.

View File

@ -1,139 +0,0 @@
# Implementation Plan: Provider Connection Full Cutover
**Branch**: `081-provider-connection-cutover` | **Date**: 2026-02-07 | **Spec**: [specs/081-provider-connection-cutover/spec.md](spec.md)
**Input**: Feature specification from `specs/081-provider-connection-cutover/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Cut over all provider-facing runtime flows from legacy tenant-stored app credentials (`tenants.app_*`) to a single, deterministic credential source: `ProviderConnection` + `ProviderCredential`.
Key behaviors:
- Runtime provider calls resolve a `ProviderConnection` (default per tenant/provider) and build Graph options via `ProviderGateway`.
- Missing/invalid configuration blocks provider-backed starts but still creates an `OperationRun` with stable `reason_code` + link-only next steps.
- A one-time, idempotent backfill ensures Microsoft default connections exist/are repaired using the decision tree in the spec.
## 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.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Socialite v5
**Storage**: PostgreSQL (via Laravel Sail)
**Testing**: Pest v4 + PHPUnit v12 (run via `vendor/bin/sail artisan test --compact`)
**Target Platform**: Laravel web application (Sail-first local dev; Dokploy container deploy)
**Project Type**: Web application (Filament admin + queued jobs)
**Performance Goals**: Deterministic provider operations; no remote calls during page render; avoid N+1 patterns in tables/global search
**Constraints**: Secrets must never appear in logs/audits/reports; Graph calls only via `GraphClientInterface` and contract registry; blocked starts must still create observable runs
**Scale/Scope**: Enterprise multi-tenant workspace; provider operations are queued and deduped via `OperationRun`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS (no changes to inventory/snapshot semantics; cutover affects provider auth only)
- Read/write separation: PASS (credential mutations remain confirmed + audited; provider operations remain `OperationRun`-tracked)
- Graph contract path: PASS (provider calls continue via `GraphClientInterface`; no new Graph endpoints introduced by this spec)
- Deterministic capabilities: PASS (no capability derivation changes required; enforce central resolver usage where needed)
- RBAC-UX planes + 404/403 semantics: PASS (provider connection management remains tenant-scoped; non-members 404, members missing capability 403)
- Destructive confirmation standard: PASS (credential rotation/mutations require confirmation; enforced server-side)
- Global search tenant safety: PASS (no new global search surfaces introduced in this spec)
- Tenant isolation: PASS (provider connections are workspace-owned + tenant-scoped; no cross-tenant shortcuts)
- Run observability: PASS (blocked starts still create runs; Monitoring remains DB-only)
- Automation: PASS (jobs remain queued + idempotent; throttling/backoff policies unchanged)
- Data minimization & safe logging: PASS (explicitly prohibit secrets in logs/audits/reports)
- Badge semantics (BADGE-001): PASS WITH WORK (if `OperationRunOutcome` gains `blocked`, update badge domain + tests)
## Project Structure
### Documentation (this feature)
```text
specs/081-provider-connection-cutover/
├── 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)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Console/Commands/
├── Filament/
│ ├── Pages/
│ └── Resources/
├── Jobs/
├── Models/
├── Policies/
├── Services/
│ ├── Graph/
│ ├── Intune/
│ ├── Inventory/
│ └── Providers/
└── Support/
config/
└── graph_contracts.php
database/
├── factories/
└── migrations/
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Laravel monolith with Filament admin and queued operations; provider integrations live under `app/Services/Providers/*` with Graph calls routed via `GraphClientInterface` + `config/graph_contracts.php`.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| N/A | N/A | N/A |
## Phase 0 — Outline & Research (complete)
- Output: [specs/081-provider-connection-cutover/research.md](research.md)
- Key repo facts captured: existing `ProviderConnection`/`ProviderCredential`, partial unique index for defaults, and identified legacy tenant-credential read hotspots.
## Phase 1 — Design & Contracts (complete)
- Output: [specs/081-provider-connection-cutover/data-model.md](data-model.md)
- Output: `specs/081-provider-connection-cutover/contracts/*`
- Output: `specs/081-provider-connection-cutover/quickstart.md`
### Post-design Constitution Re-check
- PASS: No render-time provider calls are introduced.
- PASS: Provider calls remain behind the Graph contract registry and gateway.
- PASS WITH WORK: Any new `OperationRunOutcome` value requires centralized badge mapping + tests.
## Phase 2 — Implementation Planning (next)
`tasks.md` will be produced by `/speckit.tasks` and should cover, at minimum:
- Runtime cutover: remove/replace all uses of `Tenant::graphOptions()` and `tenants.app_*` in provider-facing services/jobs.
- Resolution rules: default `ProviderConnection` lookup per (tenant, provider), with deterministic error/blocked semantics.
- Backfill command: `tenantpilot:provider-connections:backfill-microsoft-defaults` (idempotent, safe, no duplicates).
- Operations: represent blocked starts as `outcome=blocked` (status remains `completed`), with stable reason codes and link-only next steps.
- Filament UI: remove tenant credential fields and consolidate credential management under provider connections with confirmed actions.
- Regression guards: tests that fail on runtime legacy reads + tests for backfill, blocked runs, and badge mapping.

View File

@ -1,48 +0,0 @@
# Quickstart: Provider Connection Full Cutover (Spec 081)
**Branch**: `081-provider-connection-cutover`
**Spec**: [specs/081-provider-connection-cutover/spec.md](spec.md)
This quickstart is for validating the cutover implementation once tasks are executed.
## Prerequisites
- Docker + Laravel Sail installed.
- App dependencies installed (`vendor/bin/sail composer install`).
## Run the backfill (Microsoft defaults)
Run the idempotent backfill command (introduced by this feature) to ensure each managed tenant has a Microsoft default provider connection:
- `vendor/bin/sail artisan tenantpilot:provider-connections:backfill-microsoft-defaults --no-interaction`
Expected behavior:
- Does not create duplicates when re-run.
- If exactly one Microsoft connection exists and none is default, sets it default.
- If multiple Microsoft connections exist and none default, does not auto-select (tenant remains blocked until admin remediation).
## Smoke test key flows
After backfill and cutover changes are in place:
- Start an inventory/policy sync for a tenant with a default Microsoft connection → should proceed and record `provider_connection_id` on the `OperationRun`.
- Start the same flow for a tenant missing default connection/credential → should create an `OperationRun` and end as blocked with a stable `reason_code` and link-only next steps.
## Run the focused tests
Run the minimal set of tests introduced/updated by this spec:
- `vendor/bin/sail artisan test --compact --filter=Spec081`
- `vendor/bin/sail artisan test --compact --filter=ProviderConnection`
- `vendor/bin/sail artisan test --compact --filter=OperationRun`
## Code style
- `vendor/bin/sail bin pint --dirty`
## Deployment note
If any Filament assets are registered/changed as part of the implementation, ensure deploy includes:
- `vendor/bin/sail artisan filament:assets`

View File

@ -1,102 +0,0 @@
# Research: Provider Connection Full Cutover
**Feature**: [specs/081-provider-connection-cutover/spec.md](spec.md)
**Date**: 2026-02-07
## Goal
Resolve repo-specific unknowns for the full credential cutover, and document decisions with rationale and alternatives.
## Findings (Repo Reality)
### Existing ProviderConnection / ProviderCredential primitives
- `ProviderConnection` exists as a workspace-owned, tenant-scoped integration asset.
- Default invariant already exists at DB level via partial unique index:
- `provider_connections_default_unique` on `(tenant_id, provider)` where `is_default = true`.
- `ProviderCredential` exists and stores encrypted payload in `payload` (`encrypted:array`) and is hidden from serialization.
- `ProviderGateway::graphOptions(ProviderConnection $connection)` builds Graph options using `CredentialManager`.
### Existing runtime provider call patterns
- Some jobs are already ProviderConnection-first:
- Provider connection health check uses `ProviderGateway::graphOptions($connection)`.
- Provider operation start gate (`ProviderOperationStartGate`) uses `provider_connection_id` in operation run context and dedupe.
- Legacy tenant credential reads still exist in high-impact services and UI:
- Services: inventory sync, policy sync, policy snapshots/backups, restore, RBAC onboarding, scope tag resolver.
- UI: tenant registration + tenant resource form exposes `app_client_id` / `app_client_secret`.
### Operations / observability primitives
- `OperationRun` has:
- `status`: queued|running|completed
- `outcome`: pending|succeeded|partially_succeeded|failed (+ reserved cancelled)
- `context` JSON field used for identity and target scope.
- Provider operation start gate already writes `context.provider`, `context.provider_connection_id`, and `context.target_scope.entra_tenant_id`.
## Decisions
### D1 — Single Source of Truth: ProviderConnection + ProviderCredential
**Decision**: All runtime provider calls use `ProviderConnection` + `ProviderCredential` via `ProviderGateway`.
**Rationale**: Eliminates drift between verification vs restore and makes the suite deterministic and auditable.
**Alternatives considered**:
- Continue dual-source (tenant fields + provider connections): rejected due to drift and security risk.
- Allow runtime fallback to tenant fields: rejected; violates “single read path” and creates non-determinism.
### D2 — Default enforcement applies to all providers; backfill creates Microsoft defaults only
**Decision**: The invariant “exactly one default per (tenant, provider)” is generic for all providers, but the one-time backfill only creates/repairs defaults for provider `microsoft`.
**Rationale**: Keeps the suite future-proof while delivering Microsoft-only cutover now.
**Alternatives considered**:
- Microsoft-only invariant: rejected; forces future migrations and special cases.
### D3 — Blocked starts still create an OperationRun
**Decision**: Starting a provider-backed operation without usable configuration still creates an `OperationRun` record to preserve observability.
**Rationale**: Operators need a canonical record for “what was attempted” and why it is blocked.
**Alternatives considered**:
- UI-only blocked banner without a run: rejected; loses auditability/observability.
### D4 — Represent “blocked” runs as a distinct OperationRun outcome
**Decision**: Introduce a `blocked` outcome on operation runs (keep status lifecycle unchanged: `completed`).
**Rationale**: The repo currently has no “blocked” status/outcome for runs; representing it explicitly prevents conflating blocked with failed.
**Alternatives considered**:
- Encode blocked as `outcome=failed` + reason_code: rejected; UI semantics become inconsistent and ambiguous.
- Add a new status value (`blocked`): rejected; affects active-run dedupe and status badge expectations more broadly.
### D5 — Backfill selection rule for existing connections without a default
**Decision**: If exactly one Microsoft provider connection exists, set it default. If multiple exist, do not auto-select (requires admin remediation).
**Rationale**: Avoids accidental selection of the wrong app registration.
**Alternatives considered**:
- Always pick the oldest: rejected; unsafe in enterprise environments.
- Always create a new connection: rejected; increases clutter and may violate tenant/provider/entra uniqueness.
### D6 — Legacy tenant credential reads allowed only in explicit backfill tooling
**Decision**: Legacy tenant fields (`tenants.app_*`) are forbidden in runtime and permitted only in backfill command/migration.
**Rationale**: Tightens the security posture and makes cutover verifiable via guard tests.
**Alternatives considered**:
- Runtime fallback: rejected.
- No backfill reads: rejected; forces manual secret re-entry for all tenants.
## Open Points (to be handled in implementation)
- Centralize “next steps” as link keys (the repo currently embeds Filament URLs directly in verification checks).
- Determine the final reason_code taxonomy mapping for common exceptions (credential missing, auth failure, tenant mismatch).

View File

@ -1,199 +0,0 @@
# Feature Specification: Provider Connection Full Cutover
**Feature Branch**: `081-provider-connection-cutover`
**Created**: 2026-02-07
**Status**: Draft (implementation-ready)
**Input**: Spec 081 — Provider Connection Full Cutover (single source of truth, enterprise suite)
## Clarifications
### Session 2026-02-07
- Q: Provider scope for “default ProviderConnection” enforcement? → A: All providers (generic rule), but backfill only creates Microsoft defaults.
- Q: When a provider-backed operation is started but the connection/credential is missing, should we create an OperationRun? → A: Yes — create an OperationRun with a `blocked` outcome/state and store `reason_code` + link-only next steps.
- Q: Backfill behavior when Microsoft connections exist but none is default? → A: If exactly one exists, set it default; if multiple exist, do not auto-select (leave blocked + remediation).
- Q: Legacy tenant credential fields (`tenants.app_*`) after cutover? → A: Forbidden in runtime; allowed only in explicit backfill tooling for one-time copy.
- Q: Legacy tenant credential columns lifecycle? → A: Keep columns for now (deprecated/unused), defer dropping to a follow-up spec.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Deterministic provider operations (Priority: P1)
As an operator, I can run provider-backed operations (inventory, sync, backup, restore, verification) and the system always uses the same, workspace-managed provider connection for the selected managed tenant.
If the tenant is not configured, the system blocks the action with a clear reason and a guided path to remediation.
**Why this priority**: This removes “verify green, restore red” drift and makes the suite reliable and auditable.
**Independent Test**: Start a provider-backed operation for a managed tenant (a) with a default provider connection and (b) without one; verify the first runs using the default connection and the second is blocked with a stable reason and next-step links.
**Acceptance Scenarios**:
1. **Given** a managed tenant with exactly one default provider connection for a provider, **When** an operator starts a provider-backed operation, **Then** the operation uses that default connection and records the connection identity for traceability.
2. **Given** a managed tenant with no default provider connection for a provider, **When** an operator starts a provider-backed operation, **Then** the operation is blocked deterministically with reason code `provider_connection_missing` and a remediation link.
3. **Given** a managed tenant with more than one “default” provider connection (invalid configuration), **When** an operator starts a provider-backed operation, **Then** the operation is blocked/failed deterministically with reason code `provider_connection_invalid` (optional extension detail such as `ext.multiple_defaults_detected`) and does not proceed.
---
### User Story 2 - Safe credential management with audit (Priority: P2)
As an admin, I can manage provider connections and rotate credentials with explicit confirmation and complete auditability, without secrets ever being shown or stored in logs, reports, or audit payloads.
**Why this priority**: Credential handling is security-critical; enterprise operations require least privilege, safe UI flows, and reliable audit trails.
**Independent Test**: Update a provider credential and confirm (a) confirmation is required, (b) an audit event is created, and (c) secret values are never persisted outside the encrypted credential store.
**Acceptance Scenarios**:
1. **Given** an admin with the required capability, **When** they update provider credentials, **Then** the action requires explicit confirmation and produces an audit event with redacted metadata.
2. **Given** a non-member attempting to access provider connection management for a tenant, **When** they load the page, **Then** they receive deny-as-not-found behavior (404 semantics) with no tenant hints.
3. **Given** a member without the credential-management capability, **When** they attempt to update credentials, **Then** the system denies the mutation with a forbidden response (403 semantics).
---
### User Story 3 - Troubleshoot failures using stable reason codes (Priority: P3)
As an operator, I can understand why a provider-backed operation is blocked or failed through stable, machine-readable reason codes and consistent “next steps” links.
**Why this priority**: Stable reason codes enable predictable UX, support workflows, and long-term suite consistency.
**Independent Test**: Trigger a blocked/failing operation and verify reason codes and next steps appear consistently and contain no secrets.
**Acceptance Scenarios**:
1. **Given** a missing credential, **When** an operation runs, **Then** the outcome is blocked with reason code `provider_credential_missing` and a next-step link to update credentials.
2. **Given** an authentication failure at the provider, **When** an operation runs, **Then** the outcome is failed with reason code `provider_auth_failed` and a documentation link for troubleshooting.
### Edge Cases
- Default provider connection is missing for a tenant/provider pair.
- More than one default provider connection exists for the same tenant/provider pair.
- A provider connection exists but is disabled/unusable.
- Provider credential is missing.
- Provider credential is present but rejected by the provider.
- Admin consent is missing / cannot be detected.
- Required permissions are missing.
- Provider returns forbidden/insufficient privileges.
- Provider target tenant does not match the managed tenant target.
- Network is unreachable / timeouts occur.
- Rate limiting/throttling occurs.
- A user without membership tries to view tenant/provider connection details (deny-as-not-found).
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature affects provider calls, long-running operations, and credential management. The solution must preserve run observability, tenant isolation, safety/confirmations for sensitive actions, and auditable credential handling.
**Constitution alignment (RBAC-UX):** Authorization behavior must be explicit:
- Non-member / not entitled to tenant scope → deny-as-not-found (404 semantics)
- Member but missing capability → forbidden (403 semantics)
### Functional Requirements
- **FR-081-001 (Default required)**: For every managed tenant and provider, the system MUST have exactly one default provider connection OR block provider-backed flows with a clear “missing connection” reason and remediation link.
- **FR-081-002 (Single source of truth)**: All provider-facing runtime flows MUST use provider connections + credentials as the only authoritative credential source.
- **FR-081-003 (No tenant credential runtime use)**: Tenant-stored application credential fields MUST NOT be used at runtime for provider calls.
- **FR-081-003a (Legacy reads are tooling-only)**: Reads of legacy tenant credential fields are permitted only inside explicit backfill tooling (migration/command) for a one-time copy into provider credentials. Runtime flows MUST NOT read legacy tenant credential fields under any circumstances.
- **FR-081-004 (No tenant credential write path)**: The system MUST NOT provide any UI or service flow that writes provider secrets into tenant fields.
- **FR-081-005 (Single provider call entry point)**: All provider calls MUST go through a single, centralized provider gateway/factory layer that accepts a provider connection as the primary identifier.
- **FR-081-006 (Operation traceability)**: Every provider-backed operation MUST record, at minimum, provider identity, provider connection identity, managed tenant identity, and the provider tenant target scope so operators can trace runs.
- **FR-081-007 (Deterministic failure semantics)**: When a provider connection/credential is missing or invalid, operations MUST be blocked or failed deterministically with stable reason codes.
- **FR-081-007a (Blocked operations are observable)**: When an operator attempts to start a provider-backed operation but it cannot proceed due to configuration/credential reasons, the system MUST still create an operation run record in a `blocked` state and store a safe `reason_code` plus link-only next steps.
- **FR-081-008 (No secret leakage)**: Secrets MUST NOT appear in audit metadata, operation context, verification reports, application logs, or exception messages.
- **FR-081-009 (DB-only viewing)**: “View” pages MUST render only stored data and MUST NOT perform provider calls during rendering.
### Security & Authorization Requirements
- **SR-081-001 (Least privilege)**: The system MUST separate permissions for viewing vs managing provider connections/credentials and enforce them server-side.
- **SR-081-002 (Deny-as-not-found)**: Non-members MUST experience deny-as-not-found boundaries for tenant/provider-connection scoped resources.
- **SR-081-003 (Confirmed credential mutations)**: Credential changes MUST require explicit confirmation and generate auditable events with redacted payloads.
### Data & Migration Requirements
- **FR-081-010 (Backfill defaults, idempotent)**: The system MUST provide a one-time backfill that ensures every managed tenant has a default provider connection for the Microsoft provider.
- If a default provider connection already exists, backfill MUST leave it unchanged.
- If no default exists and exactly one Microsoft provider connection exists, backfill MUST set it as the default.
- If no default exists and multiple Microsoft provider connections exist, backfill MUST NOT auto-select a default and MUST leave the tenant in a blocked/remediation-required state.
- If no Microsoft provider connection exists, backfill MUST create one and set it default.
- If legacy tenant credentials exist and backfill creates a new Microsoft provider connection, it MUST copy those legacy credentials into the provider credential store for that new connection.
- Running the backfill multiple times MUST NOT create duplicates.
- **FR-081-011 (Uniqueness invariant)**: The system MUST enforce the invariant “exactly one default provider connection per (managed tenant, provider)” for all providers.
### UX Requirements (minimal changes)
- **UX-081-001 (Blocked state guidance)**: When blocked due to missing default provider connection, the UI MUST clearly state the blocked reason and provide a primary remediation link to manage provider connections.
- **UX-081-002 (Single management surface)**: Tenants MUST NOT have a second credential edit surface; provider connection management is the only supported place to manage provider credentials.
- **UX-081-003 (Link-only next steps)**: Verification “next steps” MUST be navigation-only (links), not server-side “fix-it” actions.
### Scope Boundaries
- **NG-081-001**: This spec does not introduce new credential types (e.g., certificates) or redesign token caching.
- **NG-081-002**: This spec does not change the canonical, tenantless operation run URL structure.
- **NG-081-003**: This spec does not drop legacy tenant credential columns; removal is deferred to a follow-up spec once cutover is proven stable.
### Key Entities *(include if feature involves data)*
- **Managed Tenant**: A workspace-owned tenant target used for provider operations.
- **Provider Connection**: A managed integration asset bound to a managed tenant and provider.
- **Provider Credential**: An encrypted credential payload owned by a provider connection.
- **Default Provider Connection**: The single connection designated as default for a managed tenant + provider.
- **Operation Run**: A canonical record representing a provider-backed operations identity, state, and outcome.
- **Audit Event**: An immutable record of credential changes and other sensitive actions.
- **Verification Report**: Stored results of readiness checks with stable reason codes and link-only next steps.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-081-001 (Eliminate drift)**: Provider-backed operations for a managed tenant never rely on tenant-stored credential fields at runtime; the system consistently uses the default provider connection.
- **SC-081-002 (Blocked determinism)**: 100% of attempts to start provider-backed operations without a default provider connection are blocked with a stable reason code and a remediation link.
- **SC-081-003 (Audit coverage)**: 100% of credential mutations produce auditable events with no secret material included.
- **SC-081-004 (No secret leakage)**: Secrets appear in 0 verification reports, 0 audit payloads, and 0 operator-visible error messages.
- **SC-081-005 (Backfill completeness)**: After backfill, every managed tenant either has exactly one default provider connection for the Microsoft provider or is left in an explicit remediation-required state (`provider_connection_missing` / `provider_connection_invalid`) per FR-081-010 decision rules.
## Appendix A — Reason Code Taxonomy (v1 baseline)
**Purpose:** Stable, machine-readable classification for provider/credential/auth/permission failures.
| Reason code | Category | Typical status | Meaning |
|---|---|---:|---|
| `provider_connection_missing` | configuration | `block` | No default provider connection configured for this managed tenant/provider. |
| `provider_connection_invalid` | configuration | `fail` | Provider connection exists but is inconsistent/disabled/cannot be used (including multi-default corruption). |
| `provider_credential_missing` | credentials | `block` | Connection exists, but no provider credential (secret) is present. |
| `provider_credential_invalid` | credentials | `fail` | Credential exists but is unusable (bad secret, wrong app, expired, etc.). |
| `provider_consent_missing` | consent | `block` | Admin consent not granted (or not detected). |
| `provider_auth_failed` | auth | `fail` | Authentication/token exchange failed. |
| `provider_permission_missing` | permissions | `block` | Required application permissions are not granted. |
| `provider_permission_denied` | permissions | `fail` | Provider denied access for an attempted call. |
| `provider_permission_refresh_failed` | permissions | `warn` | Permission refresh did not run or failed; observed permissions may be stale. |
| `tenant_target_mismatch` | integrity | `block` | Connection/credential is bound to a different tenant than the managed tenant target. |
| `network_unreachable` | transport | `fail` | Network/DNS/timeout prevents reaching provider endpoints. |
| `rate_limited` | transport | `warn` | Provider throttling / rate limiting encountered. |
| `unknown_error` | fallback | `fail` | Unclassified failure. |
### Extension Namespace (`ext.*`)
Extension codes MAY be added as secondary details without breaking consumers (e.g., provider-specific or error-code subtyping). Viewers MUST degrade gracefully for unknown codes.
## Appendix B — Next Steps Registry (link-only)
**Purpose:** Make remediation links consistent across onboarding, verification, and error screens.
**Rule (v1):** Next steps are navigation-only (links). They do not trigger server-side “fix” actions.
### Default next steps (examples)
- `provider_connection_missing`: Link to manage provider connections and set a default.
- `provider_credential_missing`: Link to update credentials.
- `provider_permission_missing`: Link to required permissions guidance.
- `provider_auth_failed`: Link to connection review and troubleshooting documentation.

View File

@ -1,146 +0,0 @@
# Tasks: Provider Connection Full Cutover (Spec 081)
**Input**: Design documents from `specs/081-provider-connection-cutover/`
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: REQUIRED (Pest) — this feature changes runtime credential sourcing, operations behavior, and admin UI.
## Phase 1: Setup (Shared Infrastructure)
- [X] T001 Create new Spec 081 test files `tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php` and `tests/Feature/Guards/NoTenantCredentialRuntimeReadsSpec081Test.php`
- [X] T002 Define Spec 081 test naming/tagging conventions in `tests/Pest.php` so `vendor/bin/sail artisan test --compact --filter=Spec081` is a stable focused command (quickstart aligns to this command)
---
## Phase 2: Foundational (Blocking Prerequisites)
**⚠️ CRITICAL**: Complete this phase before starting any user story work.
- [X] T003 Implement default ProviderConnection resolver `app/Services/Providers/ProviderConnectionResolver.php` (resolve default per tenant/provider; detect missing/multiple/invalid defaults; return deterministic reason_code, including `provider_connection_invalid` for multi-default corruption)
- [X] T004 [P] Add reason-code constants (baseline) in `app/Support/Providers/ProviderReasonCodes.php` (mirror Appendix A)
- [X] T005 Implement link-only “next steps” registry `app/Support/Providers/ProviderNextStepsRegistry.php` (maps reason_code → label + URL generator; no secrets)
- [X] T006 Add `blocked` outcome support in `app/Support/OperationRunOutcome.php` and update casts/validation in `app/Models/OperationRun.php`
- [X] T007 Update centralized outcome badge mapping for new `blocked` value in `app/Support/Badges/Domains/OperationRunOutcomeBadge.php` and add mapping tests in `tests/Feature/Badges/OperationRunOutcomeBadgeBlockedTest.php`
- [X] T008 Add OperationRunService helper to finalize blocked runs with sanitized failures in `app/Services/OperationRunService.php` (store `reason_code` + next_steps links; set `status=completed`, `outcome=blocked`)
- [X] T009 Update provider operation start gate to use resolver + blocked-run helper in `app/Services/Providers/ProviderOperationStartGate.php` (create run even when blocked; dedupe identity uses provider_connection_id when present)
- [X] T010 [P] Update verification report normalization to accept registry-produced next steps in `app/Support/Verification/VerificationReportWriter.php` and sanitizer `app/Support/Verification/VerificationReportSanitizer.php` (no behavior drift, just centralize usage)
- [X] T011 Add guard tests (runtime + static scan assertions) preventing reads/writes of legacy tenant credentials (`->app_client_id` / `->app_client_secret`) outside allowed backfill tooling in `tests/Feature/Guards/NoTenantCredentialRuntimeReadsSpec081Test.php`
- [X] T042 Add regression coverage for default uniqueness invariant in `tests/Feature/ProviderConnections/ProviderConnectionDefaultInvariantSpec081Test.php` (assert partial unique index exists for `(tenant_id, provider)` where `is_default=true`, and `ProviderConnection::makeDefault()` preserves invariant)
**Checkpoint**: Foundation ready — default resolution, blocked outcomes, badge mapping, next steps registry, guard test framework, and default uniqueness invariant coverage exist.
---
## Phase 3: User Story 1 — Deterministic provider operations (Priority: P1) 🎯 MVP
**Goal**: All provider-backed operation starts deterministically use default ProviderConnection; missing/invalid configuration blocks with stable reason codes and observable runs.
**Independent Test**: Start a provider-backed operation with (a) default connection and (b) missing default; verify run outcome and context include `provider_connection_id` and stable `reason_code`.
- [X] T012 [P] [US1] Add provider-connection resolution tests for missing default → blocked run in `tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php`
- [X] T013 [P] [US1] Add provider-connection resolution tests for missing credential → blocked run in `tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php`
- [X] T043 [P] [US1] Add provider-connection resolution tests for invalid multi-default data → deterministic blocked/fail result with reason code `provider_connection_invalid` (extension detail allowed, e.g. `ext.multiple_defaults_detected`) in `tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php`
- [X] T014 [US1] Introduce a single entry point for “Graph options from connection” use in `app/Services/Providers/ProviderGateway.php` (ensure all call sites accept `ProviderConnection`)
- [X] T015 [US1] Refactor `app/Services/Inventory/InventorySyncService.php` to resolve ProviderConnection and build Graph options via ProviderGateway (remove `$tenant->app_client_*` runtime reads)
- [X] T016 [US1] Refactor `app/Services/Intune/PolicySyncService.php` to resolve ProviderConnection and build Graph options via ProviderGateway (remove `$tenant->app_client_*` runtime reads)
- [X] T017 [US1] Refactor `app/Services/Intune/PolicySnapshotService.php` to resolve ProviderConnection and build Graph options via ProviderGateway (remove `$tenant->app_client_*` runtime reads)
- [X] T018 [US1] Refactor `app/Services/Intune/RestoreService.php` to resolve ProviderConnection and build Graph options via ProviderGateway (remove `$tenant->app_client_*` runtime reads)
- [X] T019 [US1] Refactor `app/Services/Graph/ScopeTagResolver.php` to resolve ProviderConnection and build Graph options via ProviderGateway (remove `$tenant->app_client_*` runtime reads)
- [X] T020 [US1] Refactor `app/Services/Intune/RbacOnboardingService.php` to use ProviderConnection + Graph options (remove `$tenant->app_client_id` checks; gate via resolver)
- [X] T021 [US1] Refactor `app/Services/Intune/RbacHealthService.php` to use ProviderConnection + Graph options (remove `$tenant->app_client_id` filter usage)
- [X] T022 [US1] Refactor `app/Http/Controllers/AdminConsentCallbackController.php` to resolve/persist consent outcomes against `ProviderConnection` records and stop all reads/writes of `tenants.app_*` for callback handling
- [X] T023 [P] [US1] Deprecate `Tenant::graphOptions()` and add a test ensuring it is unused in runtime services/jobs in `app/Models/Tenant.php` and `tests/Feature/Guards/NoTenantCredentialRuntimeReadsSpec081Test.php`
- [X] T044 [US1] Audit all Graph call sites touched by Spec 081 refactors and ensure each is represented in `config/graph_contracts.php`; add/update regression coverage in `tests/Feature/Graph/GraphContractRegistryCoverageSpec081Test.php`
- [X] T045 [P] [US1] Add DB-only render regression test for Monitoring → Operations surfaces in `tests/Feature/Monitoring/OperationsDbOnlyRenderingSpec081Test.php` (assert no provider/network call path is invoked during page render)
### Backfill (Microsoft default provider connections) — Out of scope for Spec 081 (won't do)
- [X] T024 [US1] Implement idempotent backfill command `app/Console/Commands/TenantpilotBackfillMicrosoftDefaultProviderConnections.php` with signature `tenantpilot:provider-connections:backfill-microsoft-defaults` (won't do for 081: no legacy tenant credential data migration required)
- [X] T025 [US1] Implement backfill logic service `app/Services/Providers/ProviderConnectionBackfillService.php` (decision tree from FR-081-010; allowed to read legacy `tenants.app_*` once) (won't do for 081: no legacy tenant credential data migration required)
- [X] T026 [P] [US1] Add feature tests for backfill decision tree in `tests/Feature/Console/ProviderConnectionsBackfillMicrosoftDefaultsCommandTest.php` (won't do for 081: no legacy tenant credential data migration required)
- [X] T027 [US1] Add a regression test that backfill is idempotent (no duplicates) in `tests/Feature/Console/ProviderConnectionsBackfillMicrosoftDefaultsCommandTest.php` (won't do for 081: no legacy tenant credential data migration required)
---
## Phase 4: User Story 2 — Safe credential management with audit (Priority: P2)
**Goal**: Credentials are managed only via ProviderConnection UI/actions with explicit confirmation, correct authorization semantics, and audit logging (no secret leakage).
**Independent Test**: Rotate credentials and confirm (a) confirmation required, (b) audit event exists with redacted payload, (c) secrets never appear outside encrypted payload.
- [X] T028 [P] [US2] Remove tenant credential fields from registration form in `app/Filament/Pages/Tenancy/RegisterTenant.php` and replace with helper text linking to Provider Connections management
- [X] T029 [P] [US2] Remove tenant credential fields from tenant form/infolist in `app/Filament/Resources/TenantResource.php` (no `app_client_id` / `app_client_secret` inputs or copyable entries)
- [X] T030 [US2] Ensure ProviderConnection management is the single credential surface by adding/confirming a navigation action from tenant view to provider connections in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
- [X] T031 [US2] Enforce confirmed, server-side authorized credential mutations for ProviderCredential updates in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` using `Action::make(...)->action(...)->requiresConfirmation()`
- [X] T032 [US2] Extend the existing audit pipeline so credential create/update/rotate emits stable, redacted audit events via `app/Observers/ProviderCredentialObserver.php`
- [X] T033 [P] [US2] Add authorization tests for ProviderConnection management: non-member 404, member missing capability 403 in `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php`
- [X] T034 [P] [US2] Add audit + redaction tests for credential rotation in `tests/Feature/Audit/ProviderCredentialAuditSpec081Test.php`
- [X] T046 [P] [US2] Add DB-only render regression tests for `TenantResource`/`ProviderConnectionResource` view pages in `tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php` (no Graph/provider calls during render)
- [X] T047 [US2] Implement and test blocked-state guidance on operation start surfaces (reason code + primary “Manage Provider Connections” link) in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` and `tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php`
---
## Phase 5: User Story 3 — Troubleshoot failures using stable reason codes (Priority: P3)
**Goal**: Blocked/failed provider operations consistently expose stable `reason_code` and link-only next steps across OperationRuns and Verification reports, with zero secret leakage.
**Independent Test**: Trigger blocked outcomes and assert reason_code + next_steps shape is consistent and secrets are absent.
- [X] T035 [P] [US3] Add tests asserting blocked OperationRuns store `reason_code` + next_steps links and never include secrets in context/failure summaries in `tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php`
- [X] T036 [US3] Update both provider health checks and permission check clusters to use the central registry for next steps in `app/Jobs/ProviderConnectionHealthCheckJob.php` and `app/Support/Verification/TenantPermissionCheckClusters.php`
- [X] T037 [US3] Normalize and document reason codes used by provider exceptions in `app/Support/RunFailureSanitizer.php` (map to Appendix A taxonomy where possible, document intentional extension/deviation mappings, unknown → `unknown_error`)
- [X] T038 [P] [US3] Add verification report schema tests for next_steps structure (label + url) in `tests/Feature/Verification/VerificationReportNextStepsSchemaSpec081Test.php`
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T039 [P] Run Pint formatting for all changed files via `vendor/bin/sail bin pint --dirty`
- [X] T040 Run focused test pack for Spec 081 via `vendor/bin/sail artisan test --compact --filter=Spec081`
- [X] T041 Run impacted test suites: `tests/Feature/ProviderConnections`, `tests/Feature/Guards`, `tests/Feature/Monitoring`, `tests/Feature/Verification` via `vendor/bin/sail artisan test --compact`
---
## Dependencies & Execution Order
### Dependency Graph (User Stories)
- Phase 1 → Phase 2 → US1 → (US2, US3) → Polish
US2 and US3 can run in parallel after US1 (they reuse the foundational blocked-run + registry primitives).
### Parallel Execution Examples
**US1 (P1)**
- In parallel after Phase 2:
- T043 `tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php`
- T015 `app/Services/Inventory/InventorySyncService.php`
- T016 `app/Services/Intune/PolicySyncService.php`
- T017 `app/Services/Intune/PolicySnapshotService.php`
- T018 `app/Services/Intune/RestoreService.php`
- T019 `app/Services/Graph/ScopeTagResolver.php`
- T045 `tests/Feature/Monitoring/OperationsDbOnlyRenderingSpec081Test.php`
**US2 (P2)**
- In parallel:
- T028 `app/Filament/Pages/Tenancy/RegisterTenant.php`
- T029 `app/Filament/Resources/TenantResource.php`
- T033 `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationSpec081Test.php`
- T046 `tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`
**US3 (P3)**
- In parallel:
- T035 `tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php`
- T038 `tests/Feature/Verification/VerificationReportNextStepsSchemaSpec081Test.php`
---
## Implementation Strategy
**MVP scope**: Phase 1 + Phase 2 + US1.
- Deliver deterministic starts, blocked runs, backfill command, and remove runtime legacy reads first.
- Then deliver admin UX + audit (US2) and next-step consistency across verification/monitoring (US3).

View File

@ -1,13 +1,11 @@
<?php
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores successful admin consent on provider connection status', function () {
it('marks tenant app status ok on successful admin consent ping', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Contoso',
@ -20,15 +18,8 @@
$response->assertOk();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenant->graphTenantId())
->first();
expect($connection)->not->toBeNull()
->and($connection?->status)->toBe('connected')
->and($connection?->last_error_reason_code)->toBeNull();
$tenant->refresh();
expect($tenant->app_status)->toBe('ok');
$this->assertDatabaseHas('audit_logs', [
'tenant_id' => $tenant->id,
@ -37,7 +28,7 @@
]);
});
it('creates tenant and provider connection when callback tenant does not exist', function () {
it('creates tenant if not existing and marks pending when onboarded without consent flag', function () {
$response = $this->get(route('admin.consent.callback', [
'tenant' => 'new-tenant',
'state' => 'state-456',
@ -47,19 +38,10 @@
$tenant = Tenant::where('tenant_id', 'new-tenant')->first();
expect($tenant)->not->toBeNull();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->id)
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenant->graphTenantId())
->first();
expect($connection)->not->toBeNull()
->and($connection?->status)->toBe('needs_consent')
->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConsentMissing);
expect($tenant->app_status)->toBe('pending');
});
it('records consent callback errors on provider connection state', function () {
it('records error when consent callback includes error query', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-2',
'name' => 'Fabrikam',
@ -72,16 +54,9 @@
$response->assertOk();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('entra_tenant_id', $tenant->graphTenantId())
->first();
expect($connection)->not->toBeNull()
->and($connection?->status)->toBe('error')
->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed)
->and($connection?->last_error_message)->toBe('access_denied');
$tenant->refresh();
expect($tenant->app_status)->toBe('error');
expect($tenant->app_notes)->toBe('access_denied');
$this->assertDatabaseHas('audit_logs', [
'tenant_id' => $tenant->id,

View File

@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Providers\CredentialManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('Spec081 audits credential creation with stable action and no secret leakage', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
]);
$secret = 'spec081-secret-created';
$this->actingAs($user);
app(CredentialManager::class)->upsertClientSecretCredential(
connection: $connection,
clientId: 'spec081-client-created',
clientSecret: $secret,
);
$log = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'provider_connection.credentials_created')
->latest('id')
->first();
expect($log)->not->toBeNull()
->and($log?->resource_type)->toBe('provider_connection')
->and($log?->resource_id)->toBe((string) $connection->getKey())
->and($log?->actor_id)->toBe((int) $user->getKey())
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
->and($log?->metadata['changed_fields'] ?? [])->toContain('client_id')
->and($log?->metadata['changed_fields'] ?? [])->toContain('client_secret')
->and($log?->metadata['redacted_fields'] ?? [])->toContain('client_secret')
->and((string) json_encode($log?->metadata ?? []))->not->toContain($secret);
});
it('Spec081 audits client id updates as credentials_updated without leaking secrets', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'spec081-client-before',
'client_secret' => 'spec081-secret-before',
],
]);
$this->actingAs($user);
app(CredentialManager::class)->updateClientIdPreservingSecret(
connection: $connection,
clientId: 'spec081-client-after',
);
$log = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'provider_connection.credentials_updated')
->latest('id')
->first();
expect($log)->not->toBeNull()
->and($log?->resource_type)->toBe('provider_connection')
->and($log?->resource_id)->toBe((string) $connection->getKey())
->and($log?->metadata['changed_fields'] ?? [])->toContain('client_id')
->and($log?->metadata['changed_fields'] ?? [])->not->toContain('client_secret')
->and((string) json_encode($log?->metadata ?? []))->not->toContain('spec081-secret-before');
});
it('Spec081 audits secret rotation as credentials_rotated with redacted metadata', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'spec081-client-stable',
'client_secret' => 'spec081-secret-before-rotate',
],
]);
$this->actingAs($user);
$rotatedSecret = 'spec081-secret-after-rotate';
app(CredentialManager::class)->upsertClientSecretCredential(
connection: $connection,
clientId: 'spec081-client-stable',
clientSecret: $rotatedSecret,
);
$log = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'provider_connection.credentials_rotated')
->latest('id')
->first();
expect($log)->not->toBeNull()
->and($log?->resource_type)->toBe('provider_connection')
->and($log?->resource_id)->toBe((string) $connection->getKey())
->and($log?->metadata['changed_fields'] ?? [])->toContain('client_secret')
->and($log?->metadata['redacted_fields'] ?? [])->toContain('client_secret')
->and((string) json_encode($log?->metadata ?? []))->not->toContain($rotatedSecret);
});

View File

@ -2,8 +2,6 @@
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
@ -71,6 +69,8 @@ public function request(string $method, string $path, array $options = []): Grap
'environment' => 'other',
'tenant_id' => 'tenant-guid',
'domain' => 'contoso.com',
'app_client_id' => 'client-123',
'app_notes' => 'Test tenant',
])
->call('create')
->assertHasNoFormErrors();
@ -78,24 +78,6 @@ public function request(string $method, string $path, array $options = []): Grap
$tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first();
expect($tenant)->not->toBeNull();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true,
'status' => 'enabled',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify');
@ -162,24 +144,6 @@ public function request(string $method, string $path, array $options = []): Grap
$this->actingAs($user);
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true,
'status' => 'enabled',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify');

View File

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
it('Spec081 keeps graph contract entries for provider-backed policy inventory types', function (): void {
$supported = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', []),
);
$types = collect($supported)
->filter(static fn (mixed $row): bool => is_array($row) && is_string($row['type'] ?? null))
->map(static fn (array $row): string => (string) $row['type'])
->unique()
->values()
->all();
foreach ($types as $type) {
$resource = config("graph_contracts.types.{$type}.resource");
expect($resource)
->toBeString("Missing graph contract resource for {$type}")
->not->toBe('');
}
});
it('Spec081 keeps required assignment contract paths for hydrated configuration policies', function (): void {
$assignmentHydrationTypes = [
'settingsCatalogPolicy',
'endpointSecurityPolicy',
'securityBaselinePolicy',
];
foreach ($assignmentHydrationTypes as $type) {
$path = config("graph_contracts.types.{$type}.assignments_list_path");
expect($path)
->toBeString("Missing assignments_list_path contract for {$type}")
->not->toBe('');
}
});

View File

@ -1,361 +0,0 @@
<?php
use Illuminate\Support\Collection;
it('Spec081 prevents legacy tenant credential writes outside approved maintenance surfaces', function (): void {
$root = base_path();
$self = realpath(__FILE__);
$directories = [
$root.'/app',
];
$excludedPaths = [
$root.'/vendor',
$root.'/storage',
$root.'/specs',
$root.'/spechistory',
$root.'/references',
$root.'/bootstrap/cache',
];
$allowlist = [
// Model cast declaration only (not a runtime write path).
'app/Models/Tenant.php',
];
$forbiddenPatterns = [
"/'app_client_id'\\s*=>/",
"/'app_client_secret'\\s*=>/",
'/->app_client_id\s*=/',
'/->app_client_secret\s*=/',
];
/** @var Collection<int, string> $files */
$files = collect($directories)
->filter(fn (string $dir): bool => is_dir($dir))
->flatMap(function (string $dir): array {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
$paths = [];
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = $file->getPathname();
if (! str_ends_with($path, '.php')) {
continue;
}
$paths[] = $path;
}
return $paths;
})
->filter(function (string $path) use ($excludedPaths, $self): bool {
if ($self && realpath($path) === $self) {
return false;
}
foreach ($excludedPaths as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
})
->values();
$hits = [];
foreach ($files as $path) {
$relative = str_replace($root.'/', '', $path);
if (in_array($relative, $allowlist, true)) {
continue;
}
$contents = file_get_contents($path);
if (! is_string($contents) || $contents === '') {
continue;
}
foreach ($forbiddenPatterns as $pattern) {
if (! preg_match($pattern, $contents)) {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($lines as $index => $line) {
if (preg_match($pattern, $line)) {
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
}
}
expect($hits)->toBeEmpty("Legacy tenant credential writes detected:\n".implode("\n", $hits));
});
it('Spec081 blocks new runtime reads of legacy tenant credentials outside the current cutover allowlist', function (): void {
$root = base_path();
$self = realpath(__FILE__);
$directories = [
$root.'/app',
];
$excludedPaths = [
$root.'/vendor',
$root.'/storage',
$root.'/specs',
$root.'/spechistory',
$root.'/references',
$root.'/bootstrap/cache',
];
$allowlist = [
// NOTE: Shrink this list while finishing Spec081 service cutovers.
'app/Models/Tenant.php',
'app/Filament/Resources/TenantResource.php',
'app/Services/Intune/TenantConfigService.php',
'app/Services/Intune/TenantPermissionService.php',
'app/Console/Commands/ReclassifyEnrollmentConfigurations.php',
'app/Console/Commands/TenantpilotBackfillMicrosoftDefaultProviderConnections.php',
'app/Services/Providers/ProviderConnectionBackfillService.php',
];
$forbiddenPatterns = [
'/->app_client_id\b/',
'/->app_client_secret\b/',
];
/** @var Collection<int, string> $files */
$files = collect($directories)
->filter(fn (string $dir): bool => is_dir($dir))
->flatMap(function (string $dir): array {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
$paths = [];
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = $file->getPathname();
if (! str_ends_with($path, '.php')) {
continue;
}
$paths[] = $path;
}
return $paths;
})
->filter(function (string $path) use ($excludedPaths, $self): bool {
if ($self && realpath($path) === $self) {
return false;
}
foreach ($excludedPaths as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
})
->values();
$hits = [];
foreach ($files as $path) {
$relative = str_replace($root.'/', '', $path);
if (in_array($relative, $allowlist, true)) {
continue;
}
$contents = file_get_contents($path);
if (! is_string($contents) || $contents === '') {
continue;
}
foreach ($forbiddenPatterns as $pattern) {
if (! preg_match($pattern, $contents)) {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($lines as $index => $line) {
if (preg_match($pattern, $line)) {
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
}
}
expect($hits)->toBeEmpty(
"Legacy tenant credential reads found outside allowlist (shrink allowlist over time):\n".implode("\n", $hits)
);
});
it('Spec081 blocks Tenant::graphOptions helper usage in cutover runtime services and workers', function (): void {
$root = base_path();
$files = [
'app/Services/Inventory/InventorySyncService.php',
'app/Services/Graph/ScopeTagResolver.php',
'app/Services/Intune/PolicySyncService.php',
'app/Services/Intune/PolicySnapshotService.php',
'app/Services/Intune/RestoreService.php',
'app/Services/Intune/RbacOnboardingService.php',
'app/Services/Intune/RbacHealthService.php',
'app/Jobs/SyncPoliciesJob.php',
'app/Jobs/Operations/TenantSyncWorkerJob.php',
'app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php',
'app/Jobs/ExecuteRestoreRunJob.php',
];
$hits = [];
foreach ($files as $relativePath) {
$absolutePath = $root.'/'.$relativePath;
if (! is_file($absolutePath)) {
continue;
}
$contents = file_get_contents($absolutePath);
if (! is_string($contents) || $contents === '') {
continue;
}
if (! preg_match('/\$tenant->graphOptions\(|Tenant::graphOptions\(/', $contents)) {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($lines as $index => $line) {
if (preg_match('/\$tenant->graphOptions\(|Tenant::graphOptions\(/', $line)) {
$hits[] = $relativePath.':'.($index + 1).' -> '.trim($line);
}
}
}
expect($hits)->toBeEmpty("Tenant::graphOptions usage detected in cutover runtime paths:\n".implode("\n", $hits));
});
it('Spec081 enforces ProviderGateway as the single runtime entry point for provider credential reads', function (): void {
$root = base_path();
$self = realpath(__FILE__);
$directories = [
$root.'/app',
];
$excludedPaths = [
$root.'/vendor',
$root.'/storage',
$root.'/specs',
$root.'/spechistory',
$root.'/references',
$root.'/bootstrap/cache',
];
$allowlist = [
'app/Services/Providers/CredentialManager.php',
'app/Services/Providers/ProviderGateway.php',
];
$forbiddenPattern = '/->getClientCredentials\(/';
/** @var Collection<int, string> $files */
$files = collect($directories)
->filter(fn (string $dir): bool => is_dir($dir))
->flatMap(function (string $dir): array {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
$paths = [];
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = $file->getPathname();
if (! str_ends_with($path, '.php')) {
continue;
}
$paths[] = $path;
}
return $paths;
})
->filter(function (string $path) use ($excludedPaths, $self): bool {
if ($self && realpath($path) === $self) {
return false;
}
foreach ($excludedPaths as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
})
->values();
$hits = [];
foreach ($files as $path) {
$relative = str_replace($root.'/', '', $path);
if (in_array($relative, $allowlist, true)) {
continue;
}
$contents = file_get_contents($path);
if (! is_string($contents) || $contents === '') {
continue;
}
if (! preg_match($forbiddenPattern, $contents)) {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($lines as $index => $line) {
if (preg_match($forbiddenPattern, $line)) {
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
}
expect($hits)->toBeEmpty(
"Direct CredentialManager::getClientCredentials usage detected outside ProviderGateway:\n".implode("\n", $hits)
);
});

View File

@ -1,46 +0,0 @@
<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Inventory\DependencyExtractionService;
use App\Support\Enums\RelationshipType;
use Illuminate\Support\Facades\DB;
it('stores non-UUID identifiers in inventory_links on PostgreSQL', function () {
$driver = DB::getDriverName();
$tenant = Tenant::factory()->create();
$item = InventoryItem::factory()->for($tenant)->create([
'external_id' => '11111111-1111-1111-1111-111111111111',
]);
/** @var DependencyExtractionService $service */
$service = app(DependencyExtractionService::class);
$service->extractForPolicyData($item, [
'id' => $item->external_id,
'roleScopeTagIds' => ['0'],
'assignments' => [],
]);
if ($driver === 'pgsql') {
$columnTypes = collect(DB::select(
"select column_name, data_type from information_schema.columns where table_name = 'inventory_links' and column_name in ('source_id', 'target_id')"
))
->mapWithKeys(fn (object $row) => [(string) $row->column_name => (string) $row->data_type]);
expect($columnTypes->get('source_id'))->toBe('text')
->and($columnTypes->get('target_id'))->toBe('text');
}
expect(
InventoryLink::query()
->where('tenant_id', $tenant->getKey())
->where('source_type', 'inventory_item')
->where('source_id', $item->external_id)
->where('relationship_type', RelationshipType::ScopedBy->value)
->where('target_id', '0')
->exists()
)->toBeTrue();
});

View File

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
it('Spec081 stores blocked operation runs with sanitized reason and link-only next steps', function (): void {
$tenant = Tenant::factory()->create();
$service = app(OperationRunService::class);
$run = $service->ensureRunWithIdentity(
tenant: $tenant,
type: 'provider.connection.check',
identityInputs: [
'provider_connection_id' => 99,
],
context: [
'provider' => 'microsoft',
'provider_connection_id' => 99,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
],
);
$finalized = $service->finalizeBlockedRun(
run: $run,
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
nextSteps: [
['label' => 'Update Credentials', 'url' => '/admin/tenants/demo/provider-connections'],
['label' => '', 'url' => '/invalid'],
],
message: 'client_secret=super-secret',
);
$finalized->refresh();
expect($finalized->status)->toBe(OperationRunStatus::Completed->value)
->and($finalized->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($finalized->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
->and($finalized->context['next_steps'] ?? [])->toBe([
['label' => 'Update Credentials', 'url' => '/admin/tenants/demo/provider-connections'],
])
->and($finalized->failure_summary[0]['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
->and((string) ($finalized->failure_summary[0]['message'] ?? ''))->not->toContain('secret');
});

View File

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
it('Spec081 renders operations index and detail from DB only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'initiator_name' => 'System',
'context' => [
'reason_code' => 'provider_connection_missing',
],
]);
$this->actingAs($user);
Bus::fake();
Filament::setTenant(null, true);
assertNoOutboundHttp(function () use ($tenant, $run): void {
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('All');
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Operation run');
});
Bus::assertNothingDispatched();
});

View File

@ -3,8 +3,6 @@
declare(strict_types=1);
use App\Models\Policy;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphLogger;
@ -13,36 +11,10 @@
use function Pest\Laravel\mock;
function tenantWithDefaultMicrosoftConnectionForPolicySyncReport(array $attributes = []): Tenant
{
$tenant = Tenant::factory()->create($attributes + [
'status' => 'active',
'app_client_id' => null,
'app_client_secret' => null,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'connected',
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'provider-client-'.$tenant->getKey(),
'client_secret' => 'provider-secret-'.$tenant->getKey(),
],
]);
return $tenant;
}
it('returns a report with failures when policy list calls fail', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySyncReport();
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();

View File

@ -2,8 +2,6 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphLogger;
@ -12,36 +10,10 @@
use function Pest\Laravel\mock;
function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = []): Tenant
{
$tenant = Tenant::factory()->create($attributes + [
'status' => 'active',
'app_client_id' => null,
'app_client_secret' => null,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'connected',
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'provider-client-'.$tenant->getKey(),
'client_secret' => 'provider-secret-'.$tenant->getKey(),
],
]);
return $tenant;
}
it('marks targeted managed app configurations as ignored during sync', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
@ -109,7 +81,9 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
});
it('syncs windows driver update profiles from Graph', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
@ -159,7 +133,9 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
});
it('syncs managed device app configurations from Graph', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
@ -197,7 +173,9 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
});
it('classifies configuration policies into settings catalog, endpoint security, and security baseline types', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
@ -277,7 +255,9 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
});
it('reclassifies configuration policies when canonical type changes', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,

View File

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
it('Spec081 returns 404 for non-members on provider connection management routes', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertNotFound();
});
it('Spec081 returns 403 for members without provider manage capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))
->assertForbidden();
});

View File

@ -15,7 +15,8 @@
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk();
->assertOk()
->assertSee(ProviderConnectionResource::getUrl('create', tenant: $tenant));
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))
@ -30,9 +31,6 @@
test('operators can view provider connections but cannot manage them', function () {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$createUrl = ProviderConnectionResource::getUrl('create', tenant: $tenant);
$createPath = parse_url($createUrl, PHP_URL_PATH) ?: $createUrl;
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
@ -40,7 +38,7 @@
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertDontSee($createPath);
->assertDontSee(ProviderConnectionResource::getUrl('create', tenant: $tenant));
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))
@ -54,9 +52,6 @@
test('readonly users can view provider connections but cannot manage them', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$createUrl = ProviderConnectionResource::getUrl('create', tenant: $tenant);
$createPath = parse_url($createUrl, PHP_URL_PATH) ?: $createUrl;
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
@ -64,7 +59,7 @@
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertDontSee($createPath);
->assertDontSee(ProviderConnectionResource::getUrl('create', tenant: $tenant));
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', tenant: $tenant))

View File

@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Facades\DB;
it('Spec081 blocks provider operation starts when default connection is missing', function (): void {
$tenant = Tenant::factory()->create();
$dispatched = 0;
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'provider.connection.check',
dispatcher: function () use (&$dispatched): void {
$dispatched++;
},
);
expect($dispatched)->toBe(0)
->and($result->status)->toBe('blocked')
->and($result->run->status)->toBe(OperationRunStatus::Completed->value)
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing)
->and($result->run->context['next_steps'] ?? [])->not->toBeEmpty();
});
it('Spec081 blocks provider operation starts when default connection has no credential', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'connected',
]);
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'provider.connection.check',
dispatcher: static function (): void {},
);
expect($result->status)->toBe('blocked')
->and($result->run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
});
it('Spec081 returns deterministic invalid reason when data corruption creates multiple defaults', function (): void {
$tenant = Tenant::factory()->create();
DB::statement('DROP INDEX IF EXISTS provider_connections_default_unique');
$first = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $first->getKey(),
]);
$second = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $second->getKey(),
]);
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'provider.connection.check',
dispatcher: static function (): void {},
);
expect($result->status)->toBe('blocked')
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionInvalid)
->and($result->run->context['reason_code_extension'] ?? null)->toBe('ext.multiple_defaults_detected')
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
});

View File

@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
it('Spec081 keeps the partial unique default index for provider connections', function (): void {
$driver = DB::connection()->getDriverName();
if ($driver === 'sqlite') {
$indexes = collect(DB::select("PRAGMA index_list('provider_connections')"))
->map(static fn (object $row): array => (array) $row);
expect($indexes->pluck('name')->all())->toContain('provider_connections_default_unique');
return;
}
$exists = DB::table('pg_indexes')
->where('tablename', 'provider_connections')
->where('indexname', 'provider_connections_default_unique')
->exists();
expect($exists)->toBeTrue();
});
it('Spec081 makeDefault preserves one default per tenant and provider', function (): void {
$tenant = Tenant::factory()->create();
$first = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
]);
$second = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => false,
]);
$second->makeDefault();
$defaults = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->pluck('id')
->all();
expect($defaults)->toHaveCount(1)
->and((int) $defaults[0])->toBe((int) $second->getKey());
$first->refresh();
expect((bool) $first->is_default)->toBeFalse();
});

View File

@ -5,6 +5,7 @@
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -98,5 +99,6 @@
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
->assertOk();
->assertOk()
->assertSee(OperationRunLinks::view($run, $tenant));
});

View File

@ -4,7 +4,6 @@
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
@ -33,10 +32,6 @@
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
@ -82,10 +77,6 @@
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);

View File

@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\ProviderConnection;
use App\Models\TenantPermission;
use Illuminate\Support\Facades\Bus;
it('Spec081 renders provider connection list/edit pages DB-only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Spec081 Connection',
'provider' => 'microsoft',
'status' => 'connected',
]);
$this->actingAs($user);
Bus::fake();
assertNoOutboundHttp(function () use ($tenant, $connection): void {
$this->get(ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'))
->assertOk()
->assertSee('Spec081 Connection');
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
->assertOk()
->assertSee('Spec081 Connection');
});
Bus::assertNothingDispatched();
});
it('Spec081 renders tenant view page DB-only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'DeviceManagementConfiguration.ReadWrite.All',
'status' => 'granted',
'details' => ['source' => 'spec081-test'],
]);
$this->actingAs($user);
Bus::fake();
assertNoOutboundHttp(function () use ($tenant): void {
$this->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
->assertOk()
->assertSee($tenant->name)
->assertSee('DeviceManagementConfiguration.ReadWrite.All');
});
Bus::assertNothingDispatched();
});

View File

@ -1,304 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\PolicySnapshotService;
use App\Services\Intune\PolicySyncService;
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\RestoreService;
use App\Services\Inventory\InventorySyncService;
use App\Support\RbacReason;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{tenant: Tenant, connection: ProviderConnection, client_id: string, client_secret: string}
*/
function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array
{
$tenant = Tenant::factory()->create([
'tenant_id' => $tenantId,
'status' => 'active',
'app_client_id' => null,
'app_client_secret' => null,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'status' => 'connected',
'entra_tenant_id' => $tenantId,
]);
$clientId = 'provider-client-'.$tenant->getKey();
$clientSecret = 'provider-secret-'.$tenant->getKey();
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => $clientId,
'client_secret' => $clientSecret,
],
]);
return [
'tenant' => $tenant,
'connection' => $connection,
'client_id' => $clientId,
'client_secret' => $clientSecret,
];
}
it('Spec081 smoke: inventory sync uses provider connection credentials with tenant secrets empty', function (): void {
$setup = spec081TenantWithDefaultMicrosoftConnection('tenant-spec081-inventory');
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('listPolicies')
->once()
->with(
'deviceConfiguration',
\Mockery::on(function (array $options) use ($setup): bool {
return ($options['tenant'] ?? null) === $setup['connection']->entra_tenant_id
&& ($options['client_id'] ?? null) === $setup['client_id']
&& ($options['client_secret'] ?? null) === $setup['client_secret'];
}),
)
->andReturn(new GraphResponse(success: true, data: []));
app()->instance(GraphClientInterface::class, $graph);
$run = app(InventorySyncService::class)->syncNow(
$setup['tenant'],
[
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
],
);
expect($run->status)->toBe('success');
});
it('Spec081 smoke: policy sync uses provider connection credentials with tenant secrets empty', function (): void {
$setup = spec081TenantWithDefaultMicrosoftConnection('tenant-spec081-policy-sync');
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('listPolicies')
->once()
->with(
'deviceConfiguration',
\Mockery::on(function (array $options) use ($setup): bool {
return ($options['tenant'] ?? null) === $setup['connection']->entra_tenant_id
&& ($options['client_id'] ?? null) === $setup['client_id']
&& ($options['client_secret'] ?? null) === $setup['client_secret']
&& ($options['platform'] ?? null) === 'windows';
}),
)
->andReturn(new GraphResponse(success: true, data: [
[
'id' => 'cfg-spec081',
'displayName' => 'Spec081 Config',
'@odata.type' => '#microsoft.graph.deviceConfiguration',
],
]));
app()->instance(GraphClientInterface::class, $graph);
$result = app(PolicySyncService::class)->syncPoliciesWithReport(
$setup['tenant'],
[['type' => 'deviceConfiguration', 'platform' => 'windows']],
);
expect($result['failures'])->toBeArray()->toBeEmpty()
->and($result['synced'])->toHaveCount(1);
});
it('Spec081 smoke: policy snapshot uses provider connection credentials with tenant secrets empty', function (): void {
$setup = spec081TenantWithDefaultMicrosoftConnection('tenant-spec081-snapshot');
$policy = Policy::factory()->create([
'tenant_id' => (int) $setup['tenant']->getKey(),
'external_id' => 'cfg-snapshot-spec081',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
]);
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('getPolicy')
->once()
->with(
'deviceConfiguration',
'cfg-snapshot-spec081',
\Mockery::on(function (array $options) use ($setup): bool {
return ($options['tenant'] ?? null) === $setup['connection']->entra_tenant_id
&& ($options['client_id'] ?? null) === $setup['client_id']
&& ($options['client_secret'] ?? null) === $setup['client_secret']
&& ($options['platform'] ?? null) === 'windows';
}),
)
->andReturn(new GraphResponse(success: true, data: [
'payload' => [
'id' => 'cfg-snapshot-spec081',
'displayName' => 'Snapshot Policy',
'@odata.type' => '#microsoft.graph.deviceConfiguration',
],
]));
app()->instance(GraphClientInterface::class, $graph);
$result = app(PolicySnapshotService::class)->fetch($setup['tenant'], $policy);
expect($result['payload']['id'] ?? null)->toBe('cfg-snapshot-spec081');
});
it('Spec081 smoke: restore execution uses provider connection credentials with tenant secrets empty', function (): void {
$setup = spec081TenantWithDefaultMicrosoftConnection('tenant-spec081-restore');
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $setup['tenant']->getKey(),
'status' => 'completed',
]);
$policy = Policy::factory()->create([
'tenant_id' => (int) $setup['tenant']->getKey(),
'external_id' => 'cfg-restore-spec081',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'display_name' => 'Restore Spec081',
]);
$backupItem = BackupItem::factory()->create([
'tenant_id' => (int) $setup['tenant']->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => 'cfg-restore-spec081',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'payload' => [
'id' => 'cfg-restore-spec081',
'displayName' => 'Restore Spec081',
'@odata.type' => '#microsoft.graph.deviceConfiguration',
],
'metadata' => ['displayName' => 'Restore Spec081'],
]);
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('applyPolicy')
->once()
->with(
'deviceConfiguration',
'cfg-restore-spec081',
\Mockery::type('array'),
\Mockery::on(function (array $options) use ($setup): bool {
return ($options['tenant'] ?? null) === $setup['connection']->entra_tenant_id
&& ($options['client_id'] ?? null) === $setup['client_id']
&& ($options['client_secret'] ?? null) === $setup['client_secret']
&& ($options['platform'] ?? null) === 'windows';
}),
)
->andReturn(new GraphResponse(success: true, data: []));
app()->instance(GraphClientInterface::class, $graph);
$restoreRun = app(RestoreService::class)->execute(
tenant: $setup['tenant'],
backupSet: $backupSet,
selectedItemIds: [(int) $backupItem->getKey()],
dryRun: false,
actorEmail: 'spec081@example.test',
actorName: 'Spec081',
);
expect($restoreRun->status)->toBe('completed');
});
it('Spec081 smoke: scope tag resolver uses provider connection credentials with tenant secrets empty', function (): void {
$setup = spec081TenantWithDefaultMicrosoftConnection('tenant-spec081-scope-tags');
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')
->once()
->with(
'GET',
'/deviceManagement/roleScopeTags',
\Mockery::on(function (array $options) use ($setup): bool {
return ($options['tenant'] ?? null) === $setup['connection']->entra_tenant_id
&& ($options['client_id'] ?? null) === $setup['client_id']
&& ($options['client_secret'] ?? null) === $setup['client_secret']
&& ($options['query']['$select'] ?? null) === 'id,displayName';
}),
)
->andReturn(new GraphResponse(
success: true,
data: [
'value' => [
['id' => '0', 'displayName' => 'Default'],
],
],
));
app()->instance(GraphClientInterface::class, $graph);
$tags = app(ScopeTagResolver::class)->resolve(['0'], $setup['tenant']);
expect($tags)->toBe([['id' => '0', 'displayName' => 'Default']]);
});
it('Spec081 smoke: RBAC health uses provider connection credentials with tenant secrets empty', function (): void {
$setup = spec081TenantWithDefaultMicrosoftConnection('tenant-spec081-rbac');
$tenant = $setup['tenant'];
$tenant->forceFill([
'rbac_group_id' => 'group-spec081',
'rbac_role_assignment_id' => null,
'rbac_status_reason' => null,
'rbac_last_warnings' => [],
])->save();
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldReceive('request')
->andReturnUsing(function (string $method, string $path, array $options = []) use ($setup): GraphResponse {
if ($method === 'GET' && $path === 'servicePrincipals') {
expect($options['tenant'] ?? null)->toBe($setup['connection']->entra_tenant_id)
->and($options['client_id'] ?? null)->toBe($setup['client_id'])
->and($options['client_secret'] ?? null)->toBe($setup['client_secret'])
->and($options['query']['$filter'] ?? null)->toBe("appId eq '{$setup['client_id']}'");
return new GraphResponse(success: true, data: ['value' => [['id' => 'sp-spec081']]]);
}
if ($method === 'GET' && $path === 'groups/group-spec081') {
return new GraphResponse(success: true, data: ['id' => 'group-spec081']);
}
if ($method === 'GET' && $path === 'groups/group-spec081/members') {
return new GraphResponse(success: true, data: [
'value' => [
['id' => 'sp-spec081'],
],
]);
}
throw new RuntimeException("Unexpected Graph request: {$method} {$path}");
});
app()->instance(GraphClientInterface::class, $graph);
$result = app(RbacHealthService::class)->check($tenant);
expect($result['status'])->toBe('missing')
->and($result['reason'])->toBe(RbacReason::AssignmentMissing->value);
});

View File

@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('Spec081 shows blocked guidance with reason and manage-connections link on start surfaces', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'status' => 'connected',
'is_default' => true,
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->callAction('check_connection');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull()
->and($run?->outcome)->toBe('blocked')
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing);
$notifications = collect(session('filament.notifications', []));
expect($notifications)->not->toBeEmpty();
$last = $notifications->last();
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::ProviderCredentialMissing);
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
expect($labels)->toContain('Manage Provider Connections');
Queue::assertNothingPushed();
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
});
it('Spec081 shows blocked guidance on tenant verify surface with manage-connections remediation link', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify');
$notifications = collect(session('filament.notifications', []));
expect($notifications)->not->toBeEmpty();
$last = $notifications->last();
expect((string) ($last['title'] ?? ''))->toContain('Verification blocked');
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::ProviderConnectionMissing);
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
expect($labels)->toContain('Manage Provider Connections');
});

View File

@ -5,7 +5,6 @@
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
@ -34,10 +33,6 @@
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);
@ -90,10 +85,6 @@
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);
@ -137,10 +128,6 @@
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$component = Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]);

View File

@ -1,282 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('allows workspace members to open the workspace-managed tenants index', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/tenants')
->assertOk();
});
it('returns 404 for non-members on the workspace-managed tenants index', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/tenants')
->assertNotFound();
});
it('allows workspace members to open the workspace-managed tenant view route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->assertOk();
});
it('exposes a provider connections link from the workspace-managed tenant view page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->assertOk()
->assertSee("/admin/tenants/{$tenant->external_id}/provider-connections", false);
});
it('returns 404 for non-members on the workspace-managed tenant view route', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->assertNotFound();
});
it('exposes memberships management under workspace scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}/memberships")
->assertOk();
});
it('requires tenant entitlement for the contracted tenant operational routes', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'external_id' => '11111111-1111-1111-1111-111111111111',
'tenant_id' => '11111111-1111-1111-1111-111111111111',
]);
[$entitledUser] = createUserWithTenant($tenant, role: 'readonly');
$nonEntitledUser = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $nonEntitledUser->getKey(),
'role' => 'owner',
]);
$this->actingAs($entitledUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get("/admin/t/{$tenant->external_id}")
->assertOk();
$this->actingAs($entitledUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get("/admin/t/{$tenant->external_id}/diagnostics")
->assertOk();
$this->actingAs($nonEntitledUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get("/admin/t/{$tenant->external_id}")
->assertNotFound();
$this->actingAs($nonEntitledUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get("/admin/t/{$tenant->external_id}/diagnostics")
->assertNotFound();
});
it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/{$tenant->external_id}/diagnostics")
->assertOk();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/t/{$tenant->external_id}/diagnostics")
->assertNotFound();
});
it('removes tenant-scoped management routes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/{$tenant->external_id}/provider-connections")
->assertNotFound();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/{$tenant->external_id}/required-permissions")
->assertNotFound();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/{$tenant->external_id}/memberships")
->assertNotFound();
});
it('serves provider connection management under workspace-managed tenant routes only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}/provider-connections")
->assertOk();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}/provider-connections/{$connection->getKey()}/edit")
->assertOk();
});
it('returns 403 for workspace members missing mutation capability on provider connections', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}/provider-connections")
->assertOk();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}/provider-connections/create")
->assertForbidden();
});
it('writes canonical membership audit entries for membership mutations', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$member = User::factory()->create();
/** @var TenantMembershipManager $manager */
$manager = app(TenantMembershipManager::class);
$membership = $manager->addMember(
tenant: $tenant,
actor: $owner,
member: $member,
role: 'readonly',
source: 'manual',
);
$manager->changeRole(
tenant: $tenant,
actor: $owner,
membership: $membership,
newRole: 'operator',
);
$manager->removeMember(
tenant: $tenant,
actor: $owner,
membership: $membership,
);
$actions = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->whereIn('action', [
AuditActionId::TenantMembershipAdd->value,
AuditActionId::TenantMembershipRoleChange->value,
AuditActionId::TenantMembershipRemove->value,
])
->pluck('action')
->all();
expect($actions)->toContain(AuditActionId::TenantMembershipAdd->value);
expect($actions)->toContain(AuditActionId::TenantMembershipRoleChange->value);
expect($actions)->toContain(AuditActionId::TenantMembershipRemove->value);
});
it('keeps workspace navigation entries after panel split', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/tenants')
->assertOk()
->assertSee('Tenants')
->assertSee('Operations')
->assertSee('Alerts')
->assertSee('Audit Log');
});
it('does not expose tenant-management resources in tenant panel registration or navigation URLs', function (): void {
$tenantPanelResources = Filament::getPanel('tenant')->getResources();
expect($tenantPanelResources)->not->toContain(TenantResource::class);
expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/t/{$tenant->external_id}")
->assertOk()
->assertDontSee("/admin/t/{$tenant->external_id}/provider-connections", false)
->assertDontSee("/admin/t/{$tenant->external_id}/tenants", false);
});
it('keeps global search scoped to workspace-managed tenant resources only', function (): void {
[$workspaceUser, $tenant] = createUserWithTenant(role: 'owner');
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
$this->actingAs($workspaceUser);
$results = TenantResource::getGlobalSearchResults((string) $tenant->name);
expect($results->count())->toBeGreaterThan(0);
$nonMember = User::factory()->create();
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
$this->actingAs($nonMember);
$nonMemberResults = TenantResource::getGlobalSearchResults((string) $tenant->name);
expect($nonMemberResults)->toHaveCount(0);
});

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