Compare commits
5 Commits
078-operat
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c73abd1d | |||
| 4db8030f2a | |||
| 3f09fd50f6 | |||
| ff671d8d4a | |||
| d56ba85755 |
@ -7,9 +7,12 @@ Thumbs.db
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
|
*.log*
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
|
|||||||
11
.github/agents/copilot-instructions.md
vendored
11
.github/agents/copilot-instructions.md
vendored
@ -16,6 +16,11 @@ ## Active Technologies
|
|||||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||||
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||||
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
- 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)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -35,9 +40,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
|
||||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
|
||||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,6 +6,7 @@
|
|||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
*.cache
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
@ -24,6 +25,7 @@ coverage/
|
|||||||
/storage/pail
|
/storage/pail
|
||||||
/storage/framework
|
/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
|
/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
/bootstrap/cache
|
/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.5.0 → 1.6.0
|
- Version change: 1.6.0 → 1.7.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
|
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate)
|
||||||
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
|
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- RBAC & UI Enforcement Standards (RBAC-UX)
|
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||||
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
@ -139,6 +138,31 @@ ### Operations / Run Observability Standard
|
|||||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
- 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
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
|
- Payload-heavy content belongs in immutable snapshots/backup storage, not Inventory.
|
||||||
@ -174,4 +198,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
|
||||||
|
|||||||
@ -43,6 +43,7 @@ ## Constitution Check
|
|||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- 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
|
- 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
|
- 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
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,10 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**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.
|
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.
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
Fill them out with the right functional requirements.
|
Fill them out with the right functional requirements.
|
||||||
@ -118,6 +122,17 @@ ### Functional Requirements
|
|||||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
- **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]
|
- **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)*
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||||
|
|||||||
@ -24,6 +24,14 @@ # Tasks: [FEATURE NAME]
|
|||||||
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
- at least one positive + one negative authorization test.
|
- 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 spec’s “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),
|
**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.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ public function selectTenant(int $tenantId): void
|
|||||||
|
|
||||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ChooseWorkspace extends Page
|
class ChooseWorkspace extends Page
|
||||||
{
|
{
|
||||||
@ -43,7 +42,7 @@ protected function getHeaderActions(): array
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $user instanceof User
|
return $user instanceof User
|
||||||
&& Gate::forUser($user)->check('create', Workspace::class);
|
&& $user->can('create', Workspace::class);
|
||||||
})
|
})
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
@ -124,7 +123,9 @@ public function createWorkspace(array $data): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
Gate::forUser($user)->authorize('create', Workspace::class);
|
if (! $user->can('create', Workspace::class)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$workspace = Workspace::query()->create([
|
$workspace = Workspace::query()->create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
@ -177,7 +178,7 @@ private function redirectAfterWorkspaceSelected(User $user): string
|
|||||||
$tenant = $tenantsQuery->first();
|
$tenant = $tenantsQuery->first();
|
||||||
|
|
||||||
if ($tenant !== null) {
|
if ($tenant !== null) {
|
||||||
return TenantDashboard::getUrl(tenant: $tenant);
|
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
class Alerts extends Page
|
class Alerts extends Page
|
||||||
{
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
class AuditLog extends Page
|
class AuditLog extends Page
|
||||||
{
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|||||||
@ -4,18 +4,22 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Operations;
|
namespace App\Filament\Pages\Operations;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class TenantlessOperationRunViewer extends Page
|
class TenantlessOperationRunViewer extends Page
|
||||||
{
|
{
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
@ -26,8 +30,10 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
|
|
||||||
public OperationRun $run;
|
public OperationRun $run;
|
||||||
|
|
||||||
|
public bool $opsUxIsTabHidden = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
@ -36,32 +42,39 @@ protected function getHeaderActions(): array
|
|||||||
->label('Refresh')
|
->label('Refresh')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (): string => url()->current()),
|
->url(fn (): string => isset($this->run)
|
||||||
|
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||||
|
: route('admin.operations.index')),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
||||||
return $actions;
|
$tenant = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
$related = OperationRunLinks::related($this->run, $tenant);
|
||||||
return $actions;
|
|
||||||
|
$relatedActions = [];
|
||||||
|
|
||||||
|
foreach ($related as $label => $url) {
|
||||||
|
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
||||||
|
->label((string) $label)
|
||||||
|
->url((string) $url)
|
||||||
|
->openUrlInNewTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('admin_details')
|
if ($relatedActions !== []) {
|
||||||
->label('Admin details')
|
$actions[] = ActionGroup::make($relatedActions)
|
||||||
|
->label('Open')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray');
|
||||||
->url(fn (): string => route('filament.admin.resources.operations.view', [
|
}
|
||||||
'tenant' => (int) $tenant->getKey(),
|
|
||||||
'record' => (int) $this->run->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
@ -91,4 +104,23 @@ public function mount(OperationRun $run): void
|
|||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return OperationRunResource::infolist($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultInfolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->record($this->run)
|
||||||
|
->columns(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
EmbeddedSchema::make('infolist'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,21 +84,8 @@ public function form(Schema $schema): Schema
|
|||||||
->unique(ignoreRecord: true),
|
->unique(ignoreRecord: true),
|
||||||
Forms\Components\TextInput::make('domain')
|
Forms\Components\TextInput::make('domain')
|
||||||
->label('Primary domain')
|
->label('Primary domain')
|
||||||
->maxLength(255),
|
->maxLength(255)
|
||||||
Forms\Components\TextInput::make('app_client_id')
|
->helperText('Credentials are managed after tenant creation in Provider connections.'),
|
||||||
->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),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,9 +11,18 @@
|
|||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class TenantDashboard extends Dashboard
|
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>
|
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,16 +8,18 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
class TenantRequiredPermissions extends Page
|
class TenantRequiredPermissions extends Page
|
||||||
{
|
{
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static ?string $slug = 'required-permissions';
|
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
||||||
|
|
||||||
protected static ?string $title = 'Required permissions';
|
protected static ?string $title = 'Required permissions';
|
||||||
|
|
||||||
@ -41,17 +43,28 @@ class TenantRequiredPermissions extends Page
|
|||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -134,7 +147,7 @@ public function resetFilters(): void
|
|||||||
|
|
||||||
private function refreshViewModel(): void
|
private function refreshViewModel(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->viewModel = [];
|
$this->viewModel = [];
|
||||||
@ -163,7 +176,7 @@ private function refreshViewModel(): void
|
|||||||
|
|
||||||
public function reRunVerificationUrl(): ?string
|
public function reRunVerificationUrl(): ?string
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
@ -176,9 +189,26 @@ public function reRunVerificationUrl(): ?string
|
|||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
if (! is_int($connectionId)) {
|
if (! is_int($connectionId)) {
|
||||||
return ProviderConnectionResource::getUrl('index', tenant: $tenant);
|
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnectionResource::getUrl('edit', ['record' => $connectionId], 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,6 @@
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Support\Enums\Width;
|
use Filament\Support\Enums\Width;
|
||||||
use Filament\Support\Exceptions\Halt;
|
use Filament\Support\Exceptions\Halt;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -1837,7 +1836,7 @@ public function completeOnboarding(): void
|
|||||||
resourceId: (string) $tenant->getKey(),
|
resourceId: (string) $tenant->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function verificationRun(): ?OperationRun
|
private function verificationRun(): ?OperationRun
|
||||||
|
|||||||
@ -74,6 +74,6 @@ public function openTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||||
use App\Filament\Support\VerificationReportViewer;
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -91,6 +90,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('target_scope_empty_state')
|
||||||
|
->label('Target')
|
||||||
|
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
|
||||||
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
|
||||||
|
->columnSpanFull(),
|
||||||
TextEntry::make('elapsed')
|
TextEntry::make('elapsed')
|
||||||
->label('Elapsed')
|
->label('Elapsed')
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
||||||
@ -385,6 +389,7 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
|
->label('View run')
|
||||||
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
@ -392,10 +397,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
'index' => Pages\ListOperationRuns::route('/'),
|
|
||||||
'view' => Pages\ViewOperationRun::route('/r/{record}'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ListOperationRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
OperationsKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, Tab>
|
|
||||||
*/
|
|
||||||
public function getTabs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'all' => Tab::make(),
|
|
||||||
'active' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])),
|
|
||||||
'succeeded' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value)),
|
|
||||||
'partial' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
|
|
||||||
'failed' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTablePollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class ViewOperationRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRun $run */
|
|
||||||
$run = $this->getRecord();
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($run, $tenant);
|
|
||||||
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
foreach ($related as $label => $url) {
|
|
||||||
$actions[] = Actions\Action::make(Str::slug($label, '_'))
|
|
||||||
->label($label)
|
|
||||||
->url($url)
|
|
||||||
->openUrlInNewTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($actions)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make($actions)
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
@ -32,16 +31,21 @@
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
{
|
{
|
||||||
use ScopesGlobalSearchToTenant;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $model = ProviderConnection::class;
|
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|BackedEnum|null $navigationIcon = 'heroicon-o-link';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Providers';
|
protected static string|UnitEnum|null $navigationGroup = 'Providers';
|
||||||
@ -52,7 +56,7 @@ class ProviderConnectionResource extends Resource
|
|||||||
|
|
||||||
protected static function hasTenantCapability(string $capability): bool
|
protected static function hasTenantCapability(string $capability): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -66,6 +70,23 @@ protected static function hasTenantCapability(string $capability): bool
|
|||||||
&& $resolver->can($user, $tenant, $capability);
|
&& $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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -101,7 +122,7 @@ public static function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$tenantId = static::resolveScopedTenant()?->getKey();
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
if ($workspaceId === null) {
|
||||||
return $query->whereRaw('1 = 0');
|
return $query->whereRaw('1 = 0');
|
||||||
@ -184,7 +205,7 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -210,6 +231,9 @@ public static function table(Table $table): Table
|
|||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
Actions\Action::make('manage_connections')
|
||||||
|
->label('Manage Provider Connections')
|
||||||
|
->url(static::getUrl('index', tenant: $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -225,6 +249,31 @@ public static function table(Table $table): Table
|
|||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->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();
|
->send();
|
||||||
|
|
||||||
@ -254,7 +303,7 @@ public static function table(Table $table): Table
|
|||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -308,6 +357,25 @@ public static function table(Table $table): Table
|
|||||||
return;
|
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()
|
Notification::make()
|
||||||
->title('Inventory sync queued')
|
->title('Inventory sync queued')
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
->body('Inventory sync was queued and will run in the background.')
|
||||||
@ -331,7 +399,7 @@ public static function table(Table $table): Table
|
|||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
@ -385,6 +453,25 @@ public static function table(Table $table): Table
|
|||||||
return;
|
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()
|
Notification::make()
|
||||||
->title('Compliance snapshot queued')
|
->title('Compliance snapshot queued')
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
->body('Compliance snapshot was queued and will run in the background.')
|
||||||
@ -408,7 +495,7 @@ public static function table(Table $table): Table
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -453,6 +540,7 @@ public static function table(Table $table): Table
|
|||||||
->label('Update credentials')
|
->label('Update credentials')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
@ -465,8 +553,8 @@ public static function table(Table $table): Table
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -478,28 +566,6 @@ public static function table(Table $table): Table
|
|||||||
clientSecret: (string) $data['client_secret'],
|
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()
|
Notification::make()
|
||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
@ -516,7 +582,7 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -587,7 +653,7 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -642,7 +708,7 @@ public static function table(Table $table): Table
|
|||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$tenantId = static::resolveScopedTenant()?->getKey();
|
||||||
|
|
||||||
$query = parent::getEloquentQuery();
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
@ -664,4 +730,30 @@ public static function getPages(): array
|
|||||||
'edit' => Pages\EditProviderConnection::route('/{record}/edit'),
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,11 @@ class CreateProviderConnection extends CreateRecord
|
|||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
|
|
||||||
@ -33,7 +37,12 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
|||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$record = $this->getRecord();
|
$record = $this->getRecord();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -72,4 +81,21 @@ protected function afterCreate(): void
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ protected function mutateFormDataBeforeSave(array $data): array
|
|||||||
|
|
||||||
protected function afterSave(): void
|
protected function afterSave(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$record = $this->getRecord();
|
$record = $this->getRecord();
|
||||||
|
|
||||||
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
||||||
@ -109,7 +109,7 @@ protected function afterSave(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make()
|
Actions\DeleteAction::make()
|
||||||
@ -128,7 +128,7 @@ protected function getHeaderActions(): array
|
|||||||
->where('context->provider_connection_id', (int) $record->getKey())
|
->where('context->provider_connection_id', (int) $record->getKey())
|
||||||
->exists())
|
->exists())
|
||||||
->url(function (ProviderConnection $record): ?string {
|
->url(function (ProviderConnection $record): ?string {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
@ -159,7 +159,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(function (ProviderConnection $record): bool {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
@ -168,7 +168,7 @@ protected function getHeaderActions(): array
|
|||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -200,6 +200,9 @@ protected function getHeaderActions(): array
|
|||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
Action::make('manage_connections')
|
||||||
|
->label('Manage Provider Connections')
|
||||||
|
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -215,6 +218,31 @@ protected function getHeaderActions(): array
|
|||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->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();
|
->send();
|
||||||
|
|
||||||
@ -242,6 +270,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Update credentials')
|
->label('Update credentials')
|
||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant)
|
->visible(fn (): bool => $tenant instanceof Tenant)
|
||||||
->form([
|
->form([
|
||||||
@ -255,8 +284,8 @@ protected function getHeaderActions(): array
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
])
|
])
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -268,28 +297,6 @@ protected function getHeaderActions(): array
|
|||||||
clientSecret: (string) $data['client_secret'],
|
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()
|
Notification::make()
|
||||||
->title('Credentials updated')
|
->title('Credentials updated')
|
||||||
->success()
|
->success()
|
||||||
@ -314,7 +321,7 @@ protected function getHeaderActions(): array
|
|||||||
->where('provider', $record->provider)
|
->where('provider', $record->provider)
|
||||||
->count() > 1)
|
->count() > 1)
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -361,7 +368,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(function (ProviderConnection $record): bool {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
@ -370,7 +377,7 @@ protected function getHeaderActions(): array
|
|||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -432,6 +439,25 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
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()
|
Notification::make()
|
||||||
->title('Inventory sync queued')
|
->title('Inventory sync queued')
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
->body('Inventory sync was queued and will run in the background.')
|
||||||
@ -455,7 +481,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(function (ProviderConnection $record): bool {
|
->visible(function (ProviderConnection $record): bool {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
@ -464,7 +490,7 @@ protected function getHeaderActions(): array
|
|||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -526,6 +552,25 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
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()
|
Notification::make()
|
||||||
->title('Compliance snapshot queued')
|
->title('Compliance snapshot queued')
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
->body('Compliance snapshot was queued and will run in the background.')
|
||||||
@ -550,7 +595,7 @@ protected function getHeaderActions(): array
|
|||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -622,7 +667,7 @@ protected function getHeaderActions(): array
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -676,7 +721,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
protected function getFormActions(): array
|
protected function getFormActions(): array
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -699,7 +744,7 @@ protected function getFormActions(): array
|
|||||||
|
|
||||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -719,4 +764,21 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
|||||||
|
|
||||||
return parent::handleRecordUpdate($record, $data);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,8 +58,14 @@ class TenantResource extends Resource
|
|||||||
// ... [Properties Omitted for Brevity] ...
|
// ... [Properties Omitted for Brevity] ...
|
||||||
protected static ?string $model = Tenant::class;
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = 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|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
@ -168,21 +174,8 @@ public static function form(Schema $schema): Schema
|
|||||||
->unique(ignoreRecord: true),
|
->unique(ignoreRecord: true),
|
||||||
Forms\Components\TextInput::make('domain')
|
Forms\Components\TextInput::make('domain')
|
||||||
->label('Primary domain')
|
->label('Primary domain')
|
||||||
->maxLength(255),
|
->maxLength(255)
|
||||||
Forms\Components\TextInput::make('app_client_id')
|
->helperText('Credentials are managed in Provider connections.'),
|
||||||
->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),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,7 +279,7 @@ public static function table(Table $table): Table
|
|||||||
Actions\Action::make('view')
|
Actions\Action::make('view')
|
||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
@ -405,13 +398,13 @@ public static function table(Table $table): Table
|
|||||||
->label('Open')
|
->label('Open')
|
||||||
->icon('heroicon-o-arrow-right')
|
->icon('heroicon-o-arrow-right')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
||||||
->visible(fn (Tenant $record) => $record->isActive()),
|
->visible(fn (Tenant $record) => $record->isActive()),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
->icon('heroicon-o-pencil-square')
|
->icon('heroicon-o-pencil-square')
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
@ -734,7 +727,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Infolists\Components\TextEntry::make('name'),
|
Infolists\Components\TextEntry::make('name'),
|
||||||
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
|
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
|
||||||
Infolists\Components\TextEntry::make('domain')->copyable(),
|
Infolists\Components\TextEntry::make('domain')->copyable(),
|
||||||
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
|
|
||||||
Infolists\Components\TextEntry::make('status')
|
Infolists\Components\TextEntry::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||||
@ -747,7 +739,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(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('created_at')->dateTime(),
|
||||||
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
|
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
|
||||||
Infolists\Components\TextEntry::make('rbac_status')
|
Infolists\Components\TextEntry::make('rbac_status')
|
||||||
@ -776,7 +767,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->copyable(),
|
->copyable(),
|
||||||
Infolists\Components\RepeatableEntry::make('permissions')
|
Infolists\Components\RepeatableEntry::make('permissions')
|
||||||
->label('Required permissions')
|
->label('Required permissions')
|
||||||
->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false, useConfiguredStub: false)['permissions'])
|
->state(fn (Tenant $record) => static::storedPermissionSnapshot($record))
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
|
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
|
||||||
Infolists\Components\TextEntry::make('type')->badge(),
|
Infolists\Components\TextEntry::make('type')->badge(),
|
||||||
@ -794,6 +785,42 @@ 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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -801,6 +828,7 @@ public static function getPages(): array
|
|||||||
'create' => Pages\CreateTenant::route('/create'),
|
'create' => Pages\CreateTenant::route('/create'),
|
||||||
'view' => Pages\ViewTenant::route('/{record}'),
|
'view' => Pages\ViewTenant::route('/{record}'),
|
||||||
'edit' => Pages\EditTenant::route('/{record}/edit'),
|
'edit' => Pages\EditTenant::route('/{record}/edit'),
|
||||||
|
'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -811,6 +839,16 @@ 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
|
public static function rbacAction(): Actions\Action
|
||||||
{
|
{
|
||||||
// ... [RBAC Action Omitted - No Change] ...
|
// ... [RBAC Action Omitted - No Change] ...
|
||||||
@ -942,7 +980,6 @@ public static function rbacAction(): Actions\Action
|
|||||||
->url(route('admin.rbac.start', [
|
->url(route('admin.rbac.start', [
|
||||||
'tenant' => $record->graphTenantId(),
|
'tenant' => $record->graphTenantId(),
|
||||||
'return' => route('filament.admin.resources.tenants.view', [
|
'return' => route('filament.admin.resources.tenants.view', [
|
||||||
'tenant' => $record->external_id,
|
|
||||||
'record' => $record,
|
'record' => $record,
|
||||||
]),
|
]),
|
||||||
])),
|
])),
|
||||||
@ -1082,7 +1119,6 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti
|
|||||||
->url(route('admin.rbac.start', [
|
->url(route('admin.rbac.start', [
|
||||||
'tenant' => $tenant->graphTenantId(),
|
'tenant' => $tenant->graphTenantId(),
|
||||||
'return' => route('filament.admin.resources.tenants.view', [
|
'return' => route('filament.admin.resources.tenants.view', [
|
||||||
'tenant' => $tenant->external_id,
|
|
||||||
'record' => $tenant,
|
'record' => $tenant,
|
||||||
]),
|
]),
|
||||||
]));
|
]));
|
||||||
@ -1272,7 +1308,6 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
|
|||||||
->url(route('admin.rbac.start', [
|
->url(route('admin.rbac.start', [
|
||||||
'tenant' => $tenant->graphTenantId(),
|
'tenant' => $tenant->graphTenantId(),
|
||||||
'return' => route('filament.admin.resources.tenants.view', [
|
'return' => route('filament.admin.resources.tenants.view', [
|
||||||
'tenant' => $tenant->external_id,
|
|
||||||
'record' => $tenant,
|
'record' => $tenant,
|
||||||
]),
|
]),
|
||||||
]));
|
]));
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
class ManageTenantMemberships extends ViewTenant
|
||||||
|
{
|
||||||
|
protected static ?string $title = 'Tenant memberships';
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
@ -10,7 +11,9 @@
|
|||||||
use App\Services\Intune\RbacHealthService;
|
use App\Services\Intune\RbacHealthService;
|
||||||
use App\Services\Intune\TenantConfigService;
|
use App\Services\Intune\TenantConfigService;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -32,6 +35,14 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ActionGroup::make([
|
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(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
@ -62,8 +73,47 @@ protected function getHeaderActions(): array
|
|||||||
TenantConfigService $configService,
|
TenantConfigService $configService,
|
||||||
TenantPermissionService $permissionService,
|
TenantPermissionService $permissionService,
|
||||||
RbacHealthService $rbacHealthService,
|
RbacHealthService $rbacHealthService,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger,
|
||||||
|
ProviderConnectionResolver $connectionResolver,
|
||||||
|
ProviderNextStepsRegistry $nextStepsRegistry,
|
||||||
) {
|
) {
|
||||||
|
$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::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
}),
|
}),
|
||||||
TenantResource::rbacAction(),
|
TenantResource::rbacAction(),
|
||||||
|
|||||||
@ -40,12 +40,7 @@ protected function getStats(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return [];
|
||||||
Stat::make('Total Runs (30 days)', 0),
|
|
||||||
Stat::make('Active Runs', 0),
|
|
||||||
Stat::make('Failed/Partial (7 days)', 0),
|
|
||||||
Stat::make('Avg Duration (7 days)', '—'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|||||||
@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\TenantConfigService;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
|
||||||
|
|
||||||
@ -20,9 +18,6 @@ class AdminConsentCallbackController extends Controller
|
|||||||
public function __invoke(
|
public function __invoke(
|
||||||
Request $request,
|
Request $request,
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
TenantConfigService $configService,
|
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
GraphClientInterface $graphClient
|
|
||||||
): View {
|
): View {
|
||||||
$expectedState = $request->session()->pull('tenant_onboard_state');
|
$expectedState = $request->session()->pull('tenant_onboard_state');
|
||||||
$tenantKey = $request->string('tenant')->toString();
|
$tenantKey = $request->string('tenant')->toString();
|
||||||
@ -35,23 +30,7 @@ public function __invoke(
|
|||||||
|
|
||||||
abort_if(empty($tenantIdentifier), 404);
|
abort_if(empty($tenantIdentifier), 404);
|
||||||
|
|
||||||
$tenant = Tenant::withTrashed()
|
$tenant = $this->resolveTenant($tenantIdentifier);
|
||||||
->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;
|
$error = $request->string('error')->toString() ?: null;
|
||||||
$consentGranted = $request->has('admin_consent')
|
$consentGranted = $request->has('admin_consent')
|
||||||
@ -65,10 +44,11 @@ public function __invoke(
|
|||||||
default => 'pending',
|
default => 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
$tenant->update([
|
$connection = $this->upsertProviderConnectionForConsent(
|
||||||
'app_status' => $status,
|
tenant: $tenant,
|
||||||
'app_notes' => $error,
|
status: $status,
|
||||||
]);
|
error: $error,
|
||||||
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -79,6 +59,7 @@ public function __invoke(
|
|||||||
'state' => $state,
|
'state' => $state,
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'consent' => $consentGranted,
|
'consent' => $consentGranted,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
status: $status === 'ok' ? 'success' : 'error',
|
status: $status === 'ok' ? 'success' : 'error',
|
||||||
@ -94,133 +75,71 @@ public function __invoke(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAuthorizationCodeFlow(
|
private function resolveTenant(string $tenantIdentifier): Tenant
|
||||||
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 */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = Tenant::withTrashed()
|
$tenant = Tenant::withTrashed()
|
||||||
->forTenant($tenantId)
|
->forTenant($tenantIdentifier)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($tenant?->trashed()) {
|
if ($tenant?->trashed()) {
|
||||||
$tenant->restore();
|
$tenant->restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
$tenant = Tenant::create([
|
return $tenant;
|
||||||
'tenant_id' => $tenantId,
|
}
|
||||||
|
|
||||||
|
return Tenant::create([
|
||||||
|
'tenant_id' => $tenantIdentifier,
|
||||||
'name' => 'New Tenant',
|
'name' => 'New Tenant',
|
||||||
'app_client_id' => config('graph.client_id'),
|
|
||||||
'app_client_secret' => config('graph.client_secret'),
|
|
||||||
'app_status' => 'pending',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$orgResponse = $graphClient->getOrganization([
|
private function upsertProviderConnectionForConsent(Tenant $tenant, string $status, ?string $error): ProviderConnection
|
||||||
'tenant' => $tenant->graphTenantId(),
|
{
|
||||||
'client_id' => $tenant->app_client_id,
|
$hasDefault = ProviderConnection::query()
|
||||||
'client_secret' => $tenant->app_client_secret,
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
]);
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->exists();
|
||||||
|
|
||||||
if ($orgResponse->successful()) {
|
$connectionStatus = match ($status) {
|
||||||
$org = $orgResponse->data ?? [];
|
'ok' => 'connected',
|
||||||
$tenant->update([
|
'error' => 'error',
|
||||||
'name' => $org['displayName'] ?? $tenant->name,
|
'consent_denied' => 'needs_consent',
|
||||||
'domain' => $org['verifiedDomains'][0]['name'] ?? $tenant->domain,
|
default => 'needs_consent',
|
||||||
]);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
$configResult = $configService->testConnectivity($tenant);
|
$reasonCode = match ($status) {
|
||||||
$permissionService->compare($tenant);
|
'ok' => null,
|
||||||
|
'consent_denied' => ProviderReasonCodes::ProviderConsentMissing,
|
||||||
|
'error' => ProviderReasonCodes::ProviderAuthFailed,
|
||||||
|
default => ProviderReasonCodes::ProviderConsentMissing,
|
||||||
|
};
|
||||||
|
|
||||||
$status = $configResult['success'] ? 'ok' : 'error';
|
$connection = ProviderConnection::query()->updateOrCreate(
|
||||||
|
[
|
||||||
$tenant->update([
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'app_status' => $status,
|
'provider' => 'microsoft',
|
||||||
'app_notes' => $configResult['error_message'],
|
'entra_tenant_id' => (string) ($tenant->graphTenantId() ?? $tenant->tenant_id ?? $tenant->external_id),
|
||||||
]);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.consent.callback',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'status' => $status,
|
|
||||||
'error' => $configResult['error_message'],
|
|
||||||
'from' => 'authorization_code',
|
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'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,
|
||||||
],
|
],
|
||||||
status: $status === 'ok' ? 'success' : 'error',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return view('admin-consent-callback', [
|
if (! $hasDefault && ! $connection->is_default) {
|
||||||
'tenant' => $tenant,
|
$connection->makeDefault();
|
||||||
'status' => $status,
|
|
||||||
'error' => $configResult['error_message'],
|
|
||||||
'consentGranted' => $status === 'ok',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return $connection;
|
||||||
* @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
|
private function parseState(?string $state): ?string
|
||||||
|
|||||||
@ -51,7 +51,7 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
|
|
||||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||||
|
|
||||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function persistLastTenant(User $user, Tenant $tenant): void
|
private function persistLastTenant(User $user, Tenant $tenant): void
|
||||||
|
|||||||
@ -65,7 +65,7 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
$tenant = $tenantsQuery->first();
|
$tenant = $tenantsQuery->first();
|
||||||
|
|
||||||
if ($tenant !== null) {
|
if ($tenant !== null) {
|
||||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||||
|
|
||||||
|
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
||||||
? '/admin/choose-workspace'
|
? '/admin/choose-workspace'
|
||||||
: '/admin/no-access';
|
: '/admin/no-access';
|
||||||
|
|||||||
@ -10,12 +10,13 @@
|
|||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\ProviderGateway;
|
|
||||||
use App\Services\Providers\Contracts\HealthResult;
|
use App\Services\Providers\Contracts\HealthResult;
|
||||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||||
|
use App\Services\Providers\ProviderGateway;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -201,12 +202,13 @@ public function handle(
|
|||||||
])),
|
])),
|
||||||
'next_steps' => $result->healthy
|
'next_steps' => $result->healthy
|
||||||
? []
|
? []
|
||||||
: [[
|
: app(ProviderNextStepsRegistry::class)->forReason(
|
||||||
'label' => 'Review provider connection',
|
$tenant,
|
||||||
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
is_string($result->reasonCode) && $result->reasonCode !== ''
|
||||||
'record' => (int) $connection->getKey(),
|
? $result->reasonCode
|
||||||
], tenant: $tenant),
|
: 'unknown_error',
|
||||||
]],
|
$connection,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
...$permissionChecks,
|
...$permissionChecks,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Monitoring;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class OperationsDetail extends Component implements HasForms
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
|
|
||||||
public OperationRun $run;
|
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
|
||||||
{
|
|
||||||
// Ensure tenant scope
|
|
||||||
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
|
|
||||||
$this->run = $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('livewire.monitoring.operations-detail');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -177,6 +177,11 @@ public static function currentOrFail(): self
|
|||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'external_id';
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveRouteBinding($value, $field = null): ?Model
|
public function resolveRouteBinding($value, $field = null): ?Model
|
||||||
{
|
{
|
||||||
$field ??= $this->getRouteKeyName();
|
$field ??= $this->getRouteKeyName();
|
||||||
@ -286,6 +291,8 @@ public function graphTenantId(): ?string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @deprecated Runtime provider calls must resolve ProviderConnection + ProviderGateway.
|
||||||
|
*
|
||||||
* @return array{tenant:?string,client_id:?string,client_secret:?string}
|
* @return array{tenant:?string,client_id:?string,client_secret:?string}
|
||||||
*/
|
*/
|
||||||
public function graphOptions(): array
|
public function graphOptions(): array
|
||||||
|
|||||||
145
app/Observers/ProviderCredentialObserver.php
Normal file
145
app/Observers/ProviderCredentialObserver.php
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<?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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ public function viewAny(User $user): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||||
@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool
|
|||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
@ -64,7 +64,7 @@ public function create(User $user): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
|
||||||
@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo
|
|||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
|
|||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||||
return Response::denyAsNotFound();
|
return Response::denyAsNotFound();
|
||||||
@ -135,4 +135,21 @@ private function currentWorkspace(): ?Workspace
|
|||||||
? Workspace::query()->whereKey($workspaceId)->first()
|
? Workspace::query()->whereKey($workspaceId)->first()
|
||||||
: null;
|
: 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,12 @@
|
|||||||
use App\Models\EntraGroupSyncRun;
|
use App\Models\EntraGroupSyncRun;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Observers\ProviderCredentialObserver;
|
||||||
use App\Observers\RestoreRunObserver;
|
use App\Observers\RestoreRunObserver;
|
||||||
use App\Policies\BackupSchedulePolicy;
|
use App\Policies\BackupSchedulePolicy;
|
||||||
use App\Policies\EntraGroupPolicy;
|
use App\Policies\EntraGroupPolicy;
|
||||||
@ -89,6 +91,7 @@ public function boot(): void
|
|||||||
});
|
});
|
||||||
|
|
||||||
RestoreRun::observe(RestoreRunObserver::class);
|
RestoreRun::observe(RestoreRunObserver::class);
|
||||||
|
ProviderCredential::observe(ProviderCredentialObserver::class);
|
||||||
|
|
||||||
Event::listen(TenantSet::class, function (TenantSet $event): void {
|
Event::listen(TenantSet::class, function (TenantSet $event): void {
|
||||||
static $hasPreferencesTable;
|
static $hasPreferencesTable;
|
||||||
|
|||||||
@ -6,15 +6,14 @@
|
|||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -38,7 +37,6 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
public function panel(Panel $panel): Panel
|
public function panel(Panel $panel): Panel
|
||||||
{
|
{
|
||||||
$panel = $panel
|
$panel = $panel
|
||||||
->default()
|
|
||||||
->id('admin')
|
->id('admin')
|
||||||
->path('admin')
|
->path('admin')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
@ -49,10 +47,6 @@ public function panel(Panel $panel): Panel
|
|||||||
|
|
||||||
WorkspaceResource::registerRoutes($panel);
|
WorkspaceResource::registerRoutes($panel);
|
||||||
})
|
})
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
|
||||||
->tenantRoutePrefix('t')
|
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
|
||||||
->searchableTenantMenu()
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
@ -108,13 +102,13 @@ public function panel(Panel $panel): Panel
|
|||||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
->resources([
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
TenantResource::class,
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
ProviderConnectionResource::class,
|
||||||
->pages([
|
])
|
||||||
TenantDashboard::class,
|
->pages([
|
||||||
|
TenantRequiredPermissions::class,
|
||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
|
||||||
->widgets([
|
->widgets([
|
||||||
AccountWidget::class,
|
AccountWidget::class,
|
||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
@ -130,8 +124,6 @@ public function panel(Panel $panel): Panel
|
|||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
'ensure-workspace-selected',
|
'ensure-workspace-selected',
|
||||||
'ensure-filament-tenant-selected',
|
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
])
|
])
|
||||||
|
|||||||
94
app/Providers/Filament/TenantPanelProvider.php
Normal file
94
app/Providers/Filament/TenantPanelProvider.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,13 +3,15 @@
|
|||||||
namespace App\Services\Graph;
|
namespace App\Services\Graph;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
|
use App\Services\Providers\ProviderGateway;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class ScopeTagResolver
|
class ScopeTagResolver
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MicrosoftGraphClient $graphClient,
|
private readonly ProviderConnectionResolver $providerConnections,
|
||||||
private readonly GraphLogger $logger,
|
private readonly ProviderGateway $providerGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,23 +44,30 @@ public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
|
|||||||
*/
|
*/
|
||||||
private function fetchAllScopeTags(?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';
|
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
|
||||||
|
|
||||||
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
|
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
|
||||||
try {
|
try {
|
||||||
$options = ['query' => ['$select' => 'id,displayName']];
|
$resolution = $this->providerConnections->resolveDefault($tenant, 'microsoft');
|
||||||
|
|
||||||
// Add tenant credentials if provided
|
if (! $resolution->resolved || $resolution->connection === null) {
|
||||||
if ($tenant) {
|
\Log::warning('Scope tag fetch blocked: provider connection unavailable', [
|
||||||
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
|
'tenant_id' => $tenant->id,
|
||||||
$options['client_id'] = $tenant->app_client_id;
|
'reason_code' => $resolution->effectiveReasonCode(),
|
||||||
$options['client_secret'] = $tenant->app_client_secret;
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$graphResponse = $this->graphClient->request(
|
$graphResponse = $this->providerGateway->request(
|
||||||
|
$resolution->connection,
|
||||||
'GET',
|
'GET',
|
||||||
'/deviceManagement/roleScopeTags',
|
'/deviceManagement/roleScopeTags',
|
||||||
$options
|
['query' => ['$select' => 'id,displayName']]
|
||||||
);
|
);
|
||||||
|
|
||||||
$scopeTags = $graphResponse->data['value'] ?? [];
|
$scopeTags = $graphResponse->data['value'] ?? [];
|
||||||
|
|||||||
@ -3,13 +3,18 @@
|
|||||||
namespace App\Services\Intune;
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
use App\Services\Graph\GraphResponse;
|
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 Illuminate\Support\Arr;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class PolicySnapshotService
|
class PolicySnapshotService
|
||||||
@ -20,6 +25,8 @@ public function __construct(
|
|||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
private readonly SnapshotValidator $snapshotValidator,
|
private readonly SnapshotValidator $snapshotValidator,
|
||||||
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
|
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
|
||||||
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,6 +37,8 @@ public function __construct(
|
|||||||
public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null): array
|
public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null): array
|
||||||
{
|
{
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
||||||
|
$connection = null;
|
||||||
|
$graphOptions = [];
|
||||||
|
|
||||||
$context = [
|
$context = [
|
||||||
'tenant' => $tenantIdentifier,
|
'tenant' => $tenantIdentifier,
|
||||||
@ -40,12 +49,13 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
$this->graphLogger->logRequest('get_policy', $context);
|
$this->graphLogger->logRequest('get_policy', $context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$options = [
|
$connection = $this->resolveProviderConnection($tenant);
|
||||||
'tenant' => $tenantIdentifier,
|
$tenantIdentifier = (string) $connection->entra_tenant_id;
|
||||||
'client_id' => $tenant->app_client_id,
|
$context['tenant'] = $tenantIdentifier;
|
||||||
'client_secret' => $tenant->app_client_secret,
|
$context['provider_connection_id'] = (int) $connection->getKey();
|
||||||
'platform' => $policy->platform,
|
$graphOptions = $this->providerGateway()->graphOptions($connection);
|
||||||
];
|
|
||||||
|
$options = ['platform' => $policy->platform] + $graphOptions;
|
||||||
|
|
||||||
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
||||||
$select = $this->metadataOnlySelect($policy->policy_type);
|
$select = $this->metadataOnlySelect($policy->policy_type);
|
||||||
@ -85,8 +95,7 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
|
|
||||||
if ($policy->policy_type === 'windowsUpdateRing') {
|
if ($policy->policy_type === 'windowsUpdateRing') {
|
||||||
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
|
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
|
||||||
tenantIdentifier: $tenantIdentifier,
|
graphOptions: $graphOptions,
|
||||||
tenant: $tenant,
|
|
||||||
policyId: $policy->external_id,
|
policyId: $policy->external_id,
|
||||||
payload: is_array($payload) ? $payload : [],
|
payload: is_array($payload) ? $payload : [],
|
||||||
metadata: $metadata,
|
metadata: $metadata,
|
||||||
@ -96,8 +105,7 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
|
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
|
||||||
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
|
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
|
||||||
policyType: $policy->policy_type,
|
policyType: $policy->policy_type,
|
||||||
tenantIdentifier: $tenantIdentifier,
|
graphOptions: $graphOptions,
|
||||||
tenant: $tenant,
|
|
||||||
policyId: $policy->external_id,
|
policyId: $policy->external_id,
|
||||||
payload: is_array($payload) ? $payload : [],
|
payload: is_array($payload) ? $payload : [],
|
||||||
metadata: $metadata
|
metadata: $metadata
|
||||||
@ -106,8 +114,7 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
|
|
||||||
if ($policy->policy_type === 'groupPolicyConfiguration') {
|
if ($policy->policy_type === 'groupPolicyConfiguration') {
|
||||||
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
|
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
|
||||||
tenantIdentifier: $tenantIdentifier,
|
graphOptions: $graphOptions,
|
||||||
tenant: $tenant,
|
|
||||||
policyId: $policy->external_id,
|
policyId: $policy->external_id,
|
||||||
payload: is_array($payload) ? $payload : [],
|
payload: is_array($payload) ? $payload : [],
|
||||||
metadata: $metadata
|
metadata: $metadata
|
||||||
@ -116,8 +123,7 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
|
|
||||||
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
||||||
[$payload, $metadata] = $this->hydrateComplianceActions(
|
[$payload, $metadata] = $this->hydrateComplianceActions(
|
||||||
tenantIdentifier: $tenantIdentifier,
|
graphOptions: $graphOptions,
|
||||||
tenant: $tenant,
|
|
||||||
policyId: $policy->external_id,
|
policyId: $policy->external_id,
|
||||||
payload: is_array($payload) ? $payload : [],
|
payload: is_array($payload) ? $payload : [],
|
||||||
metadata: $metadata
|
metadata: $metadata
|
||||||
@ -126,8 +132,7 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
|
|||||||
|
|
||||||
if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
|
if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
|
||||||
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
|
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
|
||||||
tenantIdentifier: $tenantIdentifier,
|
graphOptions: $graphOptions,
|
||||||
tenant: $tenant,
|
|
||||||
payload: is_array($payload) ? $payload : [],
|
payload: is_array($payload) ? $payload : [],
|
||||||
metadata: $metadata
|
metadata: $metadata
|
||||||
);
|
);
|
||||||
@ -230,7 +235,7 @@ private function formatGraphFailureReason(GraphResponse $response): string
|
|||||||
*
|
*
|
||||||
* @return array{0:array,1:array}
|
* @return array{0:array,1:array}
|
||||||
*/
|
*/
|
||||||
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
private function hydrateWindowsUpdateRing(array $graphOptions, string $policyId, array $payload, array $metadata): array
|
||||||
{
|
{
|
||||||
$odataType = $payload['@odata.type'] ?? null;
|
$odataType = $payload['@odata.type'] ?? null;
|
||||||
$castSegment = $this->deriveTypeCastSegment($odataType);
|
$castSegment = $this->deriveTypeCastSegment($odataType);
|
||||||
@ -243,11 +248,7 @@ private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tena
|
|||||||
|
|
||||||
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
|
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
|
||||||
|
|
||||||
$response = $this->graphClient->request('GET', $castPath, [
|
$response = $this->graphClient->request('GET', $castPath, Arr::except($graphOptions, ['platform']));
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed() || ! is_array($response->data)) {
|
if ($response->failed() || ! is_array($response->data)) {
|
||||||
$metadata['properties_hydration'] = 'failed';
|
$metadata['properties_hydration'] = 'failed';
|
||||||
@ -329,7 +330,7 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload):
|
|||||||
*
|
*
|
||||||
* @return array{0:array,1:array}
|
* @return array{0:array,1:array}
|
||||||
*/
|
*/
|
||||||
private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
private function hydrateConfigurationPolicySettings(string $policyType, array $graphOptions, string $policyId, array $payload, array $metadata): array
|
||||||
{
|
{
|
||||||
$strategy = $this->contracts->memberHydrationStrategy($policyType);
|
$strategy = $this->contracts->memberHydrationStrategy($policyType);
|
||||||
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
|
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
|
||||||
@ -343,11 +344,7 @@ private function hydrateConfigurationPolicySettings(string $policyType, string $
|
|||||||
$hydrationStatus = 'complete';
|
$hydrationStatus = 'complete';
|
||||||
|
|
||||||
while ($nextPath) {
|
while ($nextPath) {
|
||||||
$response = $this->graphClient->request('GET', $nextPath, [
|
$response = $this->graphClient->request('GET', $nextPath, Arr::except($graphOptions, ['platform']));
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
$hydrationStatus = 'failed';
|
$hydrationStatus = 'failed';
|
||||||
@ -389,7 +386,7 @@ private function hydrateConfigurationPolicySettings(string $policyType, string $
|
|||||||
*
|
*
|
||||||
* @return array{0:array,1:array}
|
* @return array{0:array,1:array}
|
||||||
*/
|
*/
|
||||||
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
private function hydrateGroupPolicyConfiguration(array $graphOptions, string $policyId, array $payload, array $metadata): array
|
||||||
{
|
{
|
||||||
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
|
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
|
||||||
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
|
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
|
||||||
@ -407,11 +404,7 @@ private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenan
|
|||||||
$hydrationStatus = 'complete';
|
$hydrationStatus = 'complete';
|
||||||
|
|
||||||
while ($nextPath) {
|
while ($nextPath) {
|
||||||
$response = $this->graphClient->request('GET', $nextPath, [
|
$response = $this->graphClient->request('GET', $nextPath, Arr::except($graphOptions, ['platform']));
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
$hydrationStatus = 'failed';
|
$hydrationStatus = 'failed';
|
||||||
@ -482,11 +475,7 @@ private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenan
|
|||||||
$presentationNext = $presentationValuesPath;
|
$presentationNext = $presentationValuesPath;
|
||||||
|
|
||||||
while ($presentationNext) {
|
while ($presentationNext) {
|
||||||
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
|
$pvResponse = $this->graphClient->request('GET', $presentationNext, Arr::except($graphOptions, ['platform']));
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($pvResponse->failed()) {
|
if ($pvResponse->failed()) {
|
||||||
$metadata['warnings'] = array_values(array_unique(array_merge(
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
||||||
@ -559,7 +548,7 @@ private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenan
|
|||||||
*
|
*
|
||||||
* @return array{0:array,1:array}
|
* @return array{0:array,1:array}
|
||||||
*/
|
*/
|
||||||
private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
private function hydrateComplianceActions(array $graphOptions, string $policyId, array $payload, array $metadata): array
|
||||||
{
|
{
|
||||||
$existingActions = $payload['scheduledActionsForRule'] ?? null;
|
$existingActions = $payload['scheduledActionsForRule'] ?? null;
|
||||||
|
|
||||||
@ -570,11 +559,7 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena
|
|||||||
}
|
}
|
||||||
|
|
||||||
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
|
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
|
||||||
$options = [
|
$options = Arr::except($graphOptions, ['platform']);
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
];
|
|
||||||
|
|
||||||
$actions = [];
|
$actions = [];
|
||||||
$nextPath = $path;
|
$nextPath = $path;
|
||||||
@ -621,7 +606,7 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena
|
|||||||
*
|
*
|
||||||
* @return array{0:array,1:array}
|
* @return array{0:array,1:array}
|
||||||
*/
|
*/
|
||||||
private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array
|
private function hydrateEnrollmentNotificationTemplates(array $graphOptions, array $payload, array $metadata): array
|
||||||
{
|
{
|
||||||
$existing = $payload['notificationTemplateSnapshots'] ?? null;
|
$existing = $payload['notificationTemplateSnapshots'] ?? null;
|
||||||
|
|
||||||
@ -639,11 +624,7 @@ private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier
|
|||||||
return [$payload, $metadata];
|
return [$payload, $metadata];
|
||||||
}
|
}
|
||||||
|
|
||||||
$options = [
|
$options = Arr::except($graphOptions, ['platform']);
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
];
|
|
||||||
|
|
||||||
$snapshots = [];
|
$snapshots = [];
|
||||||
$failures = 0;
|
$failures = 0;
|
||||||
@ -766,6 +747,34 @@ private function extractDefinitionIds(array $settings): array
|
|||||||
return array_unique($definitionIds);
|
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
|
private function stripGraphBaseUrl(string $nextLink): string
|
||||||
{
|
{
|
||||||
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/');
|
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/');
|
||||||
|
|||||||
@ -4,10 +4,15 @@
|
|||||||
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
use App\Services\Graph\GraphLogger;
|
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 Illuminate\Support\Arr;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@ -17,6 +22,9 @@ class PolicySyncService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GraphClientInterface $graphClient,
|
private readonly GraphClientInterface $graphClient,
|
||||||
private readonly GraphLogger $graphLogger,
|
private readonly GraphLogger $graphLogger,
|
||||||
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
|
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -46,7 +54,18 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
||||||
$synced = [];
|
$synced = [];
|
||||||
$failures = [];
|
$failures = [];
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
||||||
|
$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;
|
||||||
|
|
||||||
foreach ($types as $typeConfig) {
|
foreach ($types as $typeConfig) {
|
||||||
$policyType = $typeConfig['type'];
|
$policyType = $typeConfig['type'];
|
||||||
@ -61,10 +80,7 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->graphClient->listPolicies($policyType, [
|
$response = $this->providerGateway()->listPolicies($connection, $policyType, [
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
'platform' => $platform,
|
'platform' => $platform,
|
||||||
'filter' => $filter,
|
'filter' => $filter,
|
||||||
]);
|
]);
|
||||||
@ -73,6 +89,7 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'tenant_identifier' => $tenantIdentifier,
|
'tenant_identifier' => $tenantIdentifier,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,7 +446,17 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
|
|||||||
throw new RuntimeException('Tenant is archived or inactive.');
|
throw new RuntimeException('Tenant is archived or inactive.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
$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;
|
||||||
|
|
||||||
$this->graphLogger->logRequest('get_policy', [
|
$this->graphLogger->logRequest('get_policy', [
|
||||||
'tenant' => $tenantIdentifier,
|
'tenant' => $tenantIdentifier,
|
||||||
@ -439,18 +466,21 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [
|
$response = $this->providerGateway()->getPolicy(
|
||||||
'tenant' => $tenantIdentifier,
|
connection: $connection,
|
||||||
'client_id' => $tenant->app_client_id,
|
policyType: $policy->policy_type,
|
||||||
'client_secret' => $tenant->app_client_secret,
|
policyId: $policy->external_id,
|
||||||
|
options: [
|
||||||
'platform' => $policy->platform,
|
'platform' => $policy->platform,
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
throw GraphErrorMapper::fromThrowable($throwable, [
|
throw GraphErrorMapper::fromThrowable($throwable, [
|
||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'policy_id' => $policy->external_id,
|
'policy_id' => $policy->external_id,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'tenant_identifier' => $tenantIdentifier,
|
'tenant_identifier' => $tenantIdentifier,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,4 +513,68 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
|
|||||||
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||||
])->save();
|
])->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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Services\Intune;
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
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 App\Support\RbacReason;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class RbacHealthService
|
class RbacHealthService
|
||||||
{
|
{
|
||||||
public function __construct(private readonly GraphClientInterface $graph) {}
|
public function __construct(
|
||||||
|
private readonly GraphClientInterface $graph,
|
||||||
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{status:string,reason:?string,used_artifacts:bool}
|
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||||
@ -26,9 +35,20 @@ public function check(Tenant $tenant): array
|
|||||||
return $this->record($tenant, 'missing', RbacReason::MissingArtifacts->value, false);
|
return $this->record($tenant, 'missing', RbacReason::MissingArtifacts->value, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $tenant->graphOptions();
|
try {
|
||||||
|
$connection = $this->resolveProviderConnection($tenant);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
|
||||||
|
}
|
||||||
|
|
||||||
$spId = $this->resolveServicePrincipalId($tenant, $context);
|
$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);
|
||||||
if (! $spId) {
|
if (! $spId) {
|
||||||
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
|
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
|
||||||
}
|
}
|
||||||
@ -99,11 +119,11 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveServicePrincipalId(Tenant $tenant, array $context): ?string
|
private function resolveServicePrincipalId(string $appClientId, array $context): ?string
|
||||||
{
|
{
|
||||||
$response = $this->graph->request('GET', 'servicePrincipals', [
|
$response = $this->graph->request('GET', 'servicePrincipals', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'$filter' => "appId eq '{$tenant->app_client_id}'",
|
'$filter' => "appId eq '{$appClientId}'",
|
||||||
],
|
],
|
||||||
] + $context);
|
] + $context);
|
||||||
|
|
||||||
@ -163,4 +183,32 @@ private function assignmentIncludesGroup(array $assignment, Tenant $tenant): boo
|
|||||||
|
|
||||||
return collect($members)->contains($expected);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Services\Intune;
|
namespace App\Services\Intune;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
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 App\Support\RbacReason;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -17,6 +21,8 @@ class RbacOnboardingService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GraphClientInterface $graph,
|
private readonly GraphClientInterface $graph,
|
||||||
private readonly AuditLogger $auditLogger,
|
private readonly AuditLogger $auditLogger,
|
||||||
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,10 +37,6 @@ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $
|
|||||||
return $this->failure($tenant, 'Tenant is not active', $actor);
|
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)) {
|
if (empty($accessToken)) {
|
||||||
return $this->failure($tenant, 'Delegated access token missing. Please sign in first.', $actor);
|
return $this->failure($tenant, 'Delegated access token missing. Please sign in first.', $actor);
|
||||||
}
|
}
|
||||||
@ -46,8 +48,32 @@ 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);
|
return $this->failure($tenant, 'Select an Intune RBAC role (roleDefinitionId required). Login to load roles.', $actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $tenant->graphOptions();
|
$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['access_token'] = $accessToken;
|
$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 = [
|
$result = [
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'warnings' => [],
|
'warnings' => [],
|
||||||
@ -64,10 +90,11 @@ public function run(Tenant $tenant, array $input, ?User $actor = null, ?string $
|
|||||||
'role_definition_id' => $roleDefinitionId,
|
'role_definition_id' => $roleDefinitionId,
|
||||||
'role_display_name' => $roleDisplayName,
|
'role_display_name' => $roleDisplayName,
|
||||||
'scope' => $input['scope'] ?? null,
|
'scope' => $input['scope'] ?? null,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
], 'success', $actor);
|
], 'success', $actor);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$servicePrincipal = $this->resolveServicePrincipal($tenant->app_client_id, $context);
|
$servicePrincipal = $this->resolveServicePrincipal($appClientId, $context);
|
||||||
$result['service_principal_id'] = $servicePrincipal['id'];
|
$result['service_principal_id'] = $servicePrincipal['id'];
|
||||||
$result['steps'][] = 'service_principal_resolved';
|
$result['steps'][] = 'service_principal_resolved';
|
||||||
|
|
||||||
@ -180,7 +207,7 @@ private function resolveServicePrincipal(string $appClientId, array $context): a
|
|||||||
$servicePrincipal = $response->data['value'][0] ?? null;
|
$servicePrincipal = $response->data['value'][0] ?? null;
|
||||||
|
|
||||||
if (! $servicePrincipal || empty($servicePrincipal['id'])) {
|
if (! $servicePrincipal || empty($servicePrincipal['id'])) {
|
||||||
throw new RuntimeException('Service principal not found for app_client_id');
|
throw new RuntimeException('Service principal not found for provider connection client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $servicePrincipal;
|
return $servicePrincipal;
|
||||||
@ -740,6 +767,16 @@ 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
|
private function audit(Tenant $tenant, string $action, array $context, string $status, ?User $actor = null): void
|
||||||
{
|
{
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\AssignmentRestoreService;
|
use App\Services\AssignmentRestoreService;
|
||||||
@ -13,9 +14,13 @@
|
|||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphErrorMapper;
|
use App\Services\Graph\GraphErrorMapper;
|
||||||
use App\Services\Graph\GraphLogger;
|
use App\Services\Graph\GraphLogger;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
|
use App\Services\Providers\ProviderGateway;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class RestoreService
|
class RestoreService
|
||||||
@ -30,6 +35,8 @@ public function __construct(
|
|||||||
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
|
||||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||||
private readonly FoundationMappingService $foundationMappingService,
|
private readonly FoundationMappingService $foundationMappingService,
|
||||||
|
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||||
|
private readonly ?ProviderGateway $providerGateway = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -253,6 +260,14 @@ public function execute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
$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);
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||||
[$foundationItems, $policyItems] = $this->splitItems($items);
|
[$foundationItems, $policyItems] = $this->splitItems($items);
|
||||||
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
|
||||||
@ -435,12 +450,7 @@ public function execute(
|
|||||||
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
|
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
|
||||||
$payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping);
|
$payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping);
|
||||||
|
|
||||||
$graphOptions = [
|
$graphOptions = ['platform' => $item->platform] + $baseGraphOptions;
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
'client_id' => $tenant->app_client_id,
|
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
'platform' => $item->platform,
|
|
||||||
];
|
|
||||||
$updateMethod = $this->resolveUpdateMethod($item->policy_type);
|
$updateMethod = $this->resolveUpdateMethod($item->policy_type);
|
||||||
|
|
||||||
$settingsApply = null;
|
$settingsApply = null;
|
||||||
@ -2688,6 +2698,34 @@ 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
|
private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void
|
||||||
{
|
{
|
||||||
if (! $tenant->isActive()) {
|
if (! $tenant->isActive()) {
|
||||||
|
|||||||
@ -4,25 +4,30 @@
|
|||||||
|
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
|
use App\Services\Providers\ProviderGateway;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Contracts\Cache\Lock;
|
use Illuminate\Contracts\Cache\Lock;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class InventorySyncService
|
class InventorySyncService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GraphClientInterface $graphClient,
|
|
||||||
private readonly PolicyTypeResolver $policyTypeResolver,
|
private readonly PolicyTypeResolver $policyTypeResolver,
|
||||||
private readonly InventorySelectionHasher $selectionHasher,
|
private readonly InventorySelectionHasher $selectionHasher,
|
||||||
private readonly InventoryMetaSanitizer $metaSanitizer,
|
private readonly InventoryMetaSanitizer $metaSanitizer,
|
||||||
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
|
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
|
||||||
|
private readonly ProviderConnectionResolver $providerConnections,
|
||||||
|
private readonly ProviderGateway $providerGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -243,6 +248,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
$warnings = [];
|
$warnings = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$connection = $this->resolveProviderConnection($tenant);
|
||||||
$typesConfig = $this->supportedTypeConfigByType();
|
$typesConfig = $this->supportedTypeConfigByType();
|
||||||
|
|
||||||
$policyTypes = $normalizedSelection['policy_types'] ?? [];
|
$policyTypes = $normalizedSelection['policy_types'] ?? [];
|
||||||
@ -266,13 +272,14 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->listPoliciesWithRetry($policyType, [
|
$response = $this->listPoliciesWithRetry(
|
||||||
'tenant' => $tenant->tenant_id ?? $tenant->external_id,
|
$policyType,
|
||||||
'client_id' => $tenant->app_client_id,
|
[
|
||||||
'client_secret' => $tenant->app_client_secret,
|
|
||||||
'platform' => $typeConfig['platform'] ?? null,
|
'platform' => $typeConfig['platform'] ?? null,
|
||||||
'filter' => $typeConfig['filter'] ?? null,
|
'filter' => $typeConfig['filter'] ?? null,
|
||||||
]);
|
],
|
||||||
|
$connection
|
||||||
|
);
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
$hadErrors = true;
|
$hadErrors = true;
|
||||||
@ -305,7 +312,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
if ($includeDeps && $this->shouldHydrateAssignments($policyType)) {
|
if ($includeDeps && $this->shouldHydrateAssignments($policyType)) {
|
||||||
$existingAssignments = $policyData['assignments'] ?? null;
|
$existingAssignments = $policyData['assignments'] ?? null;
|
||||||
if (! is_array($existingAssignments) || count($existingAssignments) === 0) {
|
if (! is_array($existingAssignments) || count($existingAssignments) === 0) {
|
||||||
$hydratedAssignments = $this->fetchAssignmentsForPolicyType($policyType, $tenant, $externalId, $warnings);
|
$hydratedAssignments = $this->fetchAssignmentsForPolicyType($policyType, $connection, $externalId, $warnings);
|
||||||
if (is_array($hydratedAssignments)) {
|
if (is_array($hydratedAssignments)) {
|
||||||
$policyData['assignments'] = $hydratedAssignments;
|
$policyData['assignments'] = $hydratedAssignments;
|
||||||
}
|
}
|
||||||
@ -416,7 +423,7 @@ private function shouldHydrateAssignments(string $policyType): bool
|
|||||||
* @param array<int, array<string, mixed>> $warnings
|
* @param array<int, array<string, mixed>> $warnings
|
||||||
* @return null|array<int, mixed>
|
* @return null|array<int, mixed>
|
||||||
*/
|
*/
|
||||||
private function fetchAssignmentsForPolicyType(string $policyType, Tenant $tenant, string $externalId, array &$warnings): ?array
|
private function fetchAssignmentsForPolicyType(string $policyType, ProviderConnection $connection, string $externalId, array &$warnings): ?array
|
||||||
{
|
{
|
||||||
$pathTemplate = config("graph_contracts.types.{$policyType}.assignments_list_path");
|
$pathTemplate = config("graph_contracts.types.{$policyType}.assignments_list_path");
|
||||||
if (! is_string($pathTemplate) || $pathTemplate === '') {
|
if (! is_string($pathTemplate) || $pathTemplate === '') {
|
||||||
@ -425,16 +432,10 @@ private function fetchAssignmentsForPolicyType(string $policyType, Tenant $tenan
|
|||||||
|
|
||||||
$path = str_replace('{id}', $externalId, $pathTemplate);
|
$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;
|
$maxAttempts = 3;
|
||||||
|
|
||||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
$response = $this->graphClient->request('GET', $path, $options);
|
$response = $this->providerGateway->request($connection, 'GET', $path);
|
||||||
|
|
||||||
if (! $response->failed()) {
|
if (! $response->failed()) {
|
||||||
$data = $response->data;
|
$data = $response->data;
|
||||||
@ -611,12 +612,12 @@ private function mapGraphFailureToErrorCode(GraphResponse $response): string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function listPoliciesWithRetry(string $policyType, array $options): GraphResponse
|
private function listPoliciesWithRetry(string $policyType, array $options, ProviderConnection $connection): GraphResponse
|
||||||
{
|
{
|
||||||
$maxAttempts = 3;
|
$maxAttempts = 3;
|
||||||
|
|
||||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
$response = $this->graphClient->listPolicies($policyType, $options);
|
$response = $this->providerGateway->listPolicies($connection, $policyType, $options);
|
||||||
|
|
||||||
if (! $response->failed()) {
|
if (! $response->failed()) {
|
||||||
return $response;
|
return $response;
|
||||||
@ -639,6 +640,27 @@ private function listPoliciesWithRetry(string $policyType, array $options): Grap
|
|||||||
return new GraphResponse(false, [], null, ['error' => ['code' => 'unexpected_exception', 'message' => 'retry loop failed']]);
|
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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -557,6 +557,44 @@ 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
|
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||||
{
|
{
|
||||||
$ref = null;
|
$ref = null;
|
||||||
@ -683,4 +721,29 @@ protected function sanitizeSummaryCounts(array $summaryCounts): array
|
|||||||
{
|
{
|
||||||
return SummaryCountsNormalizer::normalize($summaryCounts);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Services\Providers\Contracts\HealthResult;
|
use App\Services\Providers\Contracts\HealthResult;
|
||||||
use App\Services\Providers\Contracts\ProviderHealthCheck;
|
use App\Services\Providers\Contracts\ProviderHealthCheck;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
|
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
|
||||||
@ -56,14 +57,15 @@ public function check(ProviderConnection $connection): HealthResult
|
|||||||
|
|
||||||
private function reasonCodeForResponse(GraphResponse $response): string
|
private function reasonCodeForResponse(GraphResponse $response): string
|
||||||
{
|
{
|
||||||
return match ((int) ($response->status ?? 0)) {
|
$candidate = match ((int) ($response->status ?? 0)) {
|
||||||
401 => RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
|
401 => ProviderReasonCodes::ProviderAuthFailed,
|
||||||
403 => RunFailureSanitizer::REASON_PERMISSION_DENIED,
|
403 => ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
429 => RunFailureSanitizer::REASON_GRAPH_THROTTLED,
|
429 => ProviderReasonCodes::RateLimited,
|
||||||
500, 502 => RunFailureSanitizer::REASON_PROVIDER_OUTAGE,
|
500, 502, 503, 504 => ProviderReasonCodes::NetworkUnreachable,
|
||||||
503, 504 => RunFailureSanitizer::REASON_GRAPH_TIMEOUT,
|
default => ProviderReasonCodes::UnknownError,
|
||||||
default => RunFailureSanitizer::REASON_UNKNOWN_ERROR,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return RunFailureSanitizer::normalizeReasonCode($candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function messageForResponse(GraphResponse $response): string
|
private function messageForResponse(GraphResponse $response): string
|
||||||
@ -90,8 +92,9 @@ private function messageForResponse(GraphResponse $response): string
|
|||||||
private function statusForReason(string $reasonCode): string
|
private function statusForReason(string $reasonCode): string
|
||||||
{
|
{
|
||||||
return match ($reasonCode) {
|
return match ($reasonCode) {
|
||||||
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
|
ProviderReasonCodes::ProviderAuthFailed,
|
||||||
RunFailureSanitizer::REASON_PERMISSION_DENIED => 'needs_consent',
|
ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
|
ProviderReasonCodes::ProviderConsentMissing => 'needs_consent',
|
||||||
default => 'error',
|
default => 'error',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -99,11 +102,10 @@ private function statusForReason(string $reasonCode): string
|
|||||||
private function healthForReason(string $reasonCode): string
|
private function healthForReason(string $reasonCode): string
|
||||||
{
|
{
|
||||||
return match ($reasonCode) {
|
return match ($reasonCode) {
|
||||||
RunFailureSanitizer::REASON_GRAPH_THROTTLED => 'degraded',
|
ProviderReasonCodes::RateLimited => 'degraded',
|
||||||
RunFailureSanitizer::REASON_GRAPH_TIMEOUT,
|
ProviderReasonCodes::NetworkUnreachable,
|
||||||
RunFailureSanitizer::REASON_PROVIDER_OUTAGE => 'down',
|
ProviderReasonCodes::ProviderAuthFailed,
|
||||||
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED,
|
ProviderReasonCodes::ProviderPermissionDenied => 'down',
|
||||||
RunFailureSanitizer::REASON_PERMISSION_DENIED => 'down',
|
|
||||||
default => 'down',
|
default => 'down',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/Services/Providers/ProviderConnectionResolution.php
Normal file
48
app/Services/Providers/ProviderConnectionResolution.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/Services/Providers/ProviderConnectionResolver.php
Normal file
124
app/Services/Providers/ProviderConnectionResolver.php
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,11 +19,31 @@ public function getOrganization(ProviderConnection $connection): GraphResponse
|
|||||||
return $this->graph->getOrganization($this->graphOptions($connection));
|
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
|
public function listPolicies(ProviderConnection $connection, string $policyType, array $options = []): GraphResponse
|
||||||
{
|
{
|
||||||
return $this->graph->listPolicies($policyType, $this->graphOptions($connection, $options));
|
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
|
public function request(ProviderConnection $connection, string $method, string $path, array $options = []): GraphResponse
|
||||||
{
|
{
|
||||||
return $this->graph->request($method, $path, $this->graphOptions($connection, $options));
|
return $this->graph->request($method, $path, $this->graphOptions($connection, $options));
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use ReflectionFunction;
|
use ReflectionFunction;
|
||||||
@ -17,6 +19,8 @@ final class ProviderOperationStartGate
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly ProviderOperationRegistry $registry,
|
private readonly ProviderOperationRegistry $registry,
|
||||||
|
private readonly ProviderConnectionResolver $resolver,
|
||||||
|
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,19 +28,39 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function start(
|
public function start(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
ProviderConnection $connection,
|
?ProviderConnection $connection,
|
||||||
string $operationType,
|
string $operationType,
|
||||||
callable $dispatcher,
|
callable $dispatcher,
|
||||||
?User $initiator = null,
|
?User $initiator = null,
|
||||||
array $extraContext = [],
|
array $extraContext = [],
|
||||||
): ProviderOperationStartResult {
|
): ProviderOperationStartResult {
|
||||||
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
|
$definition = $this->registry->get($operationType);
|
||||||
throw new InvalidArgumentException('ProviderConnection does not belong to the given tenant.');
|
$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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$definition = $this->registry->get($operationType);
|
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()
|
$lockedConnection = ProviderConnection::query()
|
||||||
->whereKey($connection->getKey())
|
->whereKey($connection->getKey())
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@ -87,6 +111,62 @@ 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
|
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||||
{
|
{
|
||||||
$ref = null;
|
$ref = null;
|
||||||
|
|||||||
@ -26,4 +26,9 @@ public static function scopeBusy(OperationRun $run): self
|
|||||||
{
|
{
|
||||||
return new self('scope_busy', $run, false);
|
return new self('scope_busy', $run, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function blocked(OperationRun $run): self
|
||||||
|
{
|
||||||
|
return new self('blocked', $run, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
||||||
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
||||||
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
|
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::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
|
|||||||
@ -14,7 +14,7 @@ final class RequiredPermissionsLinks
|
|||||||
*/
|
*/
|
||||||
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
|
public static function requiredPermissions(Tenant $tenant, array $filters = []): string
|
||||||
{
|
{
|
||||||
$base = sprintf('/admin/t/%s/required-permissions', urlencode((string) $tenant->external_id));
|
$base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id));
|
||||||
|
|
||||||
if ($filters === []) {
|
if ($filters === []) {
|
||||||
return $base;
|
return $base;
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
final class OperationRunLinks
|
final class OperationRunLinks
|
||||||
{
|
{
|
||||||
public static function index(Tenant $tenant): string
|
public static function index(?Tenant $tenant = null): string
|
||||||
{
|
{
|
||||||
return route('admin.operations.index');
|
return route('admin.operations.index');
|
||||||
}
|
}
|
||||||
@ -35,7 +35,7 @@ public static function view(OperationRun|int $run, Tenant $tenant): string
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function related(OperationRun $run, Tenant $tenant): array
|
public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
@ -43,53 +43,57 @@ public static function related(OperationRun $run, Tenant $tenant): array
|
|||||||
|
|
||||||
$links['Operations'] = self::index($tenant);
|
$links['Operations'] = self::index($tenant);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
||||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', tenant: $tenant);
|
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['record' => (int) $providerConnectionId], tenant: $tenant);
|
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'inventory.sync') {
|
if ($run->type === 'inventory.sync') {
|
||||||
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
|
$links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
||||||
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
|
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$policyId = $context['policy_id'] ?? null;
|
$policyId = $context['policy_id'] ?? null;
|
||||||
if (is_numeric($policyId)) {
|
if (is_numeric($policyId)) {
|
||||||
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
|
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'directory_groups.sync') {
|
if ($run->type === 'directory_groups.sync') {
|
||||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant);
|
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'drift.generate') {
|
if ($run->type === 'drift.generate') {
|
||||||
$links['Drift'] = DriftLanding::getUrl(tenant: $tenant);
|
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
|
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$backupSetId = $context['backup_set_id'] ?? null;
|
$backupSetId = $context['backup_set_id'] ?? null;
|
||||||
if (is_numeric($backupSetId)) {
|
if (is_numeric($backupSetId)) {
|
||||||
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
|
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
|
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
|
||||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
|
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'restore.execute') {
|
if ($run->type === 'restore.execute') {
|
||||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
|
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
$restoreRunId = $context['restore_run_id'] ?? null;
|
||||||
if (is_numeric($restoreRunId)) {
|
if (is_numeric($restoreRunId)) {
|
||||||
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
|
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ enum OperationRunOutcome: string
|
|||||||
case Pending = 'pending';
|
case Pending = 'pending';
|
||||||
case Succeeded = 'succeeded';
|
case Succeeded = 'succeeded';
|
||||||
case PartiallySucceeded = 'partially_succeeded';
|
case PartiallySucceeded = 'partially_succeeded';
|
||||||
|
case Blocked = 'blocked';
|
||||||
case Failed = 'failed';
|
case Failed = 'failed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,6 +32,7 @@ public static function uiLabels(bool $includeReserved = false): array
|
|||||||
self::Pending->value => 'Pending',
|
self::Pending->value => 'Pending',
|
||||||
self::Succeeded->value => 'Succeeded',
|
self::Succeeded->value => 'Succeeded',
|
||||||
self::PartiallySucceeded->value => 'Partially succeeded',
|
self::PartiallySucceeded->value => 'Partially succeeded',
|
||||||
|
self::Blocked->value => 'Blocked',
|
||||||
self::Failed->value => 'Failed',
|
self::Failed->value => 'Failed',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
final class RunFailureSanitizer
|
final class RunFailureSanitizer
|
||||||
{
|
{
|
||||||
public const string REASON_GRAPH_THROTTLED = 'graph_throttled';
|
public const string REASON_GRAPH_THROTTLED = 'graph_throttled';
|
||||||
@ -36,66 +38,81 @@ public static function normalizeReasonCode(string $candidate): string
|
|||||||
$candidate = strtolower(trim($candidate));
|
$candidate = strtolower(trim($candidate));
|
||||||
|
|
||||||
if ($candidate === '') {
|
if ($candidate === '') {
|
||||||
return self::REASON_UNKNOWN_ERROR;
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowed = [
|
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
||||||
self::REASON_GRAPH_THROTTLED,
|
|
||||||
self::REASON_GRAPH_TIMEOUT,
|
|
||||||
self::REASON_PERMISSION_DENIED,
|
|
||||||
self::REASON_PROVIDER_AUTH_FAILED,
|
|
||||||
self::REASON_PROVIDER_OUTAGE,
|
|
||||||
self::REASON_VALIDATION_ERROR,
|
|
||||||
self::REASON_CONFLICT_DETECTED,
|
|
||||||
self::REASON_UNKNOWN_ERROR,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (in_array($candidate, $allowed, true)) {
|
|
||||||
return $candidate;
|
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.
|
// Compatibility mappings from existing codebase labels.
|
||||||
$candidate = match ($candidate) {
|
$candidate = match ($candidate) {
|
||||||
'graph_forbidden' => self::REASON_PERMISSION_DENIED,
|
self::REASON_GRAPH_THROTTLED,
|
||||||
'graph_transient' => self::REASON_GRAPH_TIMEOUT,
|
'throttled' => ProviderReasonCodes::RateLimited,
|
||||||
'unknown' => self::REASON_UNKNOWN_ERROR,
|
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_VALIDATION_ERROR,
|
||||||
|
self::REASON_CONFLICT_DETECTED,
|
||||||
|
'invalid_state' => ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
'missing_configuration' => ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
|
'unknown',
|
||||||
|
self::REASON_UNKNOWN_ERROR => ProviderReasonCodes::UnknownError,
|
||||||
default => $candidate,
|
default => $candidate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (in_array($candidate, $allowed, true)) {
|
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
||||||
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
||||||
return self::REASON_GRAPH_THROTTLED;
|
return ProviderReasonCodes::RateLimited;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($candidate, 'invalid_client') || str_contains($candidate, 'invalid_grant') || str_contains($candidate, '401') || str_contains($candidate, 'aadsts')) {
|
if (str_contains($candidate, 'invalid_client') || str_contains($candidate, 'invalid_grant') || str_contains($candidate, '401') || str_contains($candidate, 'aadsts')) {
|
||||||
return self::REASON_PROVIDER_AUTH_FAILED;
|
return ProviderReasonCodes::ProviderAuthFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) {
|
if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) {
|
||||||
return self::REASON_GRAPH_TIMEOUT;
|
return ProviderReasonCodes::NetworkUnreachable;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($candidate, 'outage') || str_contains($candidate, '500') || str_contains($candidate, '502') || str_contains($candidate, 'bad_gateway')) {
|
if (str_contains($candidate, 'outage') || str_contains($candidate, '500') || str_contains($candidate, '502') || str_contains($candidate, 'bad_gateway')) {
|
||||||
return self::REASON_PROVIDER_OUTAGE;
|
return ProviderReasonCodes::NetworkUnreachable;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) {
|
if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) {
|
||||||
return self::REASON_PERMISSION_DENIED;
|
return ProviderReasonCodes::ProviderPermissionDenied;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($candidate, 'validation') || str_contains($candidate, 'not_found') || str_contains($candidate, 'bad_request') || str_contains($candidate, '400') || str_contains($candidate, '422')) {
|
if (str_contains($candidate, 'validation') || str_contains($candidate, 'not_found') || str_contains($candidate, 'bad_request') || str_contains($candidate, '400') || str_contains($candidate, '422')) {
|
||||||
return self::REASON_VALIDATION_ERROR;
|
return ProviderReasonCodes::ProviderConnectionInvalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($candidate, 'conflict') || str_contains($candidate, '409')) {
|
if (str_contains($candidate, 'conflict') || str_contains($candidate, '409')) {
|
||||||
return self::REASON_CONFLICT_DETECTED;
|
return ProviderReasonCodes::ProviderConnectionInvalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::REASON_UNKNOWN_ERROR;
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function sanitizeMessage(string $message): string
|
public static function sanitizeMessage(string $message): string
|
||||||
|
|||||||
63
app/Support/Providers/ProviderNextStepsRegistry.php
Normal file
63
app/Support/Providers/ProviderNextStepsRegistry.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Support/Providers/ProviderReasonCodes.php
Normal file
59
app/Support/Providers/ProviderReasonCodes.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
final class TenantPermissionCheckClusters
|
final class TenantPermissionCheckClusters
|
||||||
{
|
{
|
||||||
@ -34,6 +37,7 @@ public static function buildChecks(Tenant $tenant, array $permissions, ?array $i
|
|||||||
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
|
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
|
||||||
? $inventoryReasonCode
|
? $inventoryReasonCode
|
||||||
: 'dependency_unreachable';
|
: 'dependency_unreachable';
|
||||||
|
$inventoryReasonCode = RunFailureSanitizer::normalizeReasonCode($inventoryReasonCode);
|
||||||
|
|
||||||
$inventoryMessage = $inventory['message'] ?? null;
|
$inventoryMessage = $inventory['message'] ?? null;
|
||||||
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
|
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
|
||||||
@ -180,8 +184,7 @@ private static function buildCheck(
|
|||||||
string $inventoryReasonCode,
|
string $inventoryReasonCode,
|
||||||
string $inventoryMessage,
|
string $inventoryMessage,
|
||||||
array $inventoryEvidence,
|
array $inventoryEvidence,
|
||||||
): array
|
): array {
|
||||||
{
|
|
||||||
if (! $inventoryFresh) {
|
if (! $inventoryFresh) {
|
||||||
return [
|
return [
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
@ -192,12 +195,7 @@ private static function buildCheck(
|
|||||||
'reason_code' => $inventoryReasonCode,
|
'reason_code' => $inventoryReasonCode,
|
||||||
'message' => $inventoryMessage,
|
'message' => $inventoryMessage,
|
||||||
'evidence' => $inventoryEvidence,
|
'evidence' => $inventoryEvidence,
|
||||||
'next_steps' => [
|
'next_steps' => self::nextSteps($tenant, $inventoryReasonCode),
|
||||||
[
|
|
||||||
'label' => 'Open required permissions',
|
|
||||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,15 +249,10 @@ private static function buildCheck(
|
|||||||
'status' => VerificationCheckStatus::Fail->value,
|
'status' => VerificationCheckStatus::Fail->value,
|
||||||
'severity' => VerificationCheckSeverity::Critical->value,
|
'severity' => VerificationCheckSeverity::Critical->value,
|
||||||
'blocking' => true,
|
'blocking' => true,
|
||||||
'reason_code' => 'ext.missing_permission',
|
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'evidence' => $evidence,
|
'evidence' => $evidence,
|
||||||
'next_steps' => [
|
'next_steps' => self::nextSteps($tenant, ProviderReasonCodes::ProviderPermissionMissing),
|
||||||
[
|
|
||||||
'label' => 'Open required permissions',
|
|
||||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,15 +266,10 @@ private static function buildCheck(
|
|||||||
'status' => VerificationCheckStatus::Warn->value,
|
'status' => VerificationCheckStatus::Warn->value,
|
||||||
'severity' => VerificationCheckSeverity::Medium->value,
|
'severity' => VerificationCheckSeverity::Medium->value,
|
||||||
'blocking' => false,
|
'blocking' => false,
|
||||||
'reason_code' => 'ext.missing_delegated_permission',
|
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'evidence' => $evidence,
|
'evidence' => $evidence,
|
||||||
'next_steps' => [
|
'next_steps' => self::nextSteps($tenant, ProviderReasonCodes::ProviderPermissionRefreshFailed),
|
||||||
[
|
|
||||||
'label' => 'Open required permissions',
|
|
||||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,6 +356,25 @@ private static function inventoryEvidence(array $inventory): array
|
|||||||
return $pointers;
|
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
|
* @param array<string, mixed> $row
|
||||||
* @return TenantPermissionRow
|
* @return TenantPermissionRow
|
||||||
|
|||||||
@ -242,7 +242,7 @@ private static function sanitizeChecks(array $checks): ?array
|
|||||||
'reason_code' => $reasonCode,
|
'reason_code' => $reasonCode,
|
||||||
'message' => self::sanitizeMessage($messageRaw),
|
'message' => self::sanitizeMessage($messageRaw),
|
||||||
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
|
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
|
||||||
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
|
'next_steps' => self::sanitizeNextStepsPayload($check['next_steps'] ?? null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,6 +301,18 @@ private static function sanitizeEvidence(array $evidence): array
|
|||||||
return $sanitized;
|
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
|
* @param array<int, mixed> $nextSteps
|
||||||
* @return array<int, array{label: string, url: string}>
|
* @return array<int, array{label: string, url: string}>
|
||||||
|
|||||||
@ -5,26 +5,11 @@
|
|||||||
namespace App\Support\Verification;
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
final class VerificationReportWriter
|
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<int, array<string, mixed>> $checks
|
||||||
* @param array<string, mixed> $identity
|
* @param array<string, mixed> $identity
|
||||||
@ -178,29 +163,28 @@ private static function normalizeCheckSeverity(mixed $severity): string
|
|||||||
private static function normalizeReasonCode(mixed $reasonCode): string
|
private static function normalizeReasonCode(mixed $reasonCode): string
|
||||||
{
|
{
|
||||||
if (! is_string($reasonCode)) {
|
if (! is_string($reasonCode)) {
|
||||||
return 'unknown_error';
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonCode = strtolower(trim($reasonCode));
|
$reasonCode = strtolower(trim($reasonCode));
|
||||||
|
|
||||||
if ($reasonCode === '') {
|
if ($reasonCode === '') {
|
||||||
return 'unknown_error';
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($reasonCode, 'ext.')) {
|
if (str_starts_with($reasonCode, 'ext.')) {
|
||||||
return $reasonCode;
|
return $reasonCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonCode = match ($reasonCode) {
|
$reasonCode = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
'graph_throttled' => 'throttled',
|
|
||||||
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
|
|
||||||
'provider_auth_failed' => 'authentication_failed',
|
|
||||||
'validation_error', 'conflict_detected' => 'invalid_state',
|
|
||||||
'unknown' => 'unknown_error',
|
|
||||||
default => $reasonCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
|
if (ProviderReasonCodes::isKnown($reasonCode)) {
|
||||||
|
return $reasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($reasonCode, ['ok', 'not_applicable'], true)
|
||||||
|
? $reasonCode
|
||||||
|
: ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -248,31 +232,7 @@ private static function normalizeEvidence(mixed $evidence): array
|
|||||||
*/
|
*/
|
||||||
private static function normalizeNextSteps(mixed $steps): array
|
private static function normalizeNextSteps(mixed $steps): array
|
||||||
{
|
{
|
||||||
if (! is_array($steps)) {
|
return VerificationReportSanitizer::sanitizeNextStepsPayload($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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\AuthServiceProvider::class,
|
App\Providers\AuthServiceProvider::class,
|
||||||
App\Providers\Filament\AdminPanelProvider::class,
|
App\Providers\Filament\AdminPanelProvider::class,
|
||||||
|
App\Providers\Filament\TenantPanelProvider::class,
|
||||||
App\Providers\Filament\SystemPanelProvider::class,
|
App\Providers\Filament\SystemPanelProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,137 +1,3 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@php
|
{{ $this->infolist }}
|
||||||
$context = is_array($this->run->context ?? null) ? $this->run->context : [];
|
|
||||||
$targetScope = $context['target_scope'] ?? [];
|
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
|
||||||
|
|
||||||
$failures = is_array($this->run->failure_summary ?? null) ? $this->run->failure_summary : [];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<x-filament::section heading="Summary">
|
|
||||||
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700 dark:text-gray-200 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (int) $this->run->getKey() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Workspace:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) ($this->run->workspace?->name ?? '—') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Operation:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->type }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Initiator:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->initiator_name }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Status:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Outcome:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->outcome }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Started:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->started_at?->format('Y-m-d H:i') ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->completed_at?->format('Y-m-d H:i') ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<x-filament::section heading="Target scope" :collapsed="false">
|
|
||||||
@php
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
|
||||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
|
||||||
|
|
||||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
|
||||||
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($entraTenantId === null && $entraTenantName === null)
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
No target scope details were recorded for this run.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
@if ($entraTenantName !== null)
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($entraTenantId !== null)
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<x-filament::section heading="Report">
|
|
||||||
@if ((string) $this->run->status !== 'completed')
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
|
|
||||||
</div>
|
|
||||||
@elseif ((string) $this->run->outcome === 'succeeded')
|
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
No failures were reported.
|
|
||||||
</div>
|
|
||||||
@elseif ($failures === [])
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Report unavailable. The run completed, but no failure details were recorded.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
Findings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
@foreach ($failures as $failure)
|
|
||||||
@php
|
|
||||||
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
|
|
||||||
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
|
|
||||||
|
|
||||||
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
|
||||||
$message = is_string($message) && $message !== '' ? $message : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($reasonCode !== null || $message !== null)
|
|
||||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
|
||||||
@if ($reasonCode !== null)
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $reasonCode }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@if ($message !== null)
|
|
||||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
{{ $message }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</li>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
</div>
|
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
@php
|
@php
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
$vm = is_array($viewModel ?? null) ? $viewModel : [];
|
$vm = is_array($viewModel ?? null) ? $viewModel : [];
|
||||||
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
||||||
|
|||||||
@ -44,7 +44,25 @@
|
|||||||
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||||
|
|
||||||
$path = '/'.ltrim(request()->path(), '/');
|
$path = '/'.ltrim(request()->path(), '/');
|
||||||
$isTenantScopedRoute = request()->route()?->hasParameter('tenant') || str_starts_with($path, '/admin/t/');
|
$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.'));
|
||||||
|
|
||||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||||
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
||||||
@ -65,7 +83,7 @@
|
|||||||
|
|
||||||
<x-filament::dropdown.list>
|
<x-filament::dropdown.list>
|
||||||
<a
|
<a
|
||||||
href="{{ ChooseWorkspace::getUrl() }}"
|
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
|
||||||
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Switch workspace
|
Switch workspace
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
<div class="space-y-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Summary</h3>
|
|
||||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
||||||
<dt class="text-gray-600">Type:</dt>
|
|
||||||
<dd>{{ $run->type }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Status:</dt>
|
|
||||||
<dd>{{ $run->status }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Outcome:</dt>
|
|
||||||
<dd>{{ $run->outcome }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Initiator:</dt>
|
|
||||||
<dd>{{ $run->initiator_name }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Timing</h3>
|
|
||||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
||||||
<dt class="text-gray-600">Created:</dt>
|
|
||||||
<dd>{{ $run->created_at }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Started:</dt>
|
|
||||||
<dd>{{ $run->started_at ?? '-' }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Completed:</dt>
|
|
||||||
<dd>{{ $run->completed_at ?? '-' }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if(!empty($run->summary_counts))
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Counts</h3>
|
|
||||||
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}</pre>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(!empty($run->failure_summary))
|
|
||||||
<div class="bg-white p-4 rounded shadow border-l-4 border-red-500">
|
|
||||||
<h3 class="font-bold text-lg mb-2 text-red-700">Failures</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@foreach($run->failure_summary as $failure)
|
|
||||||
<div class="bg-red-50 p-2 rounded">
|
|
||||||
<div class="font-mono text-xs text-red-800">{{ $failure['code'] ?? 'UNKNOWN' }}</div>
|
|
||||||
<div class="text-sm text-red-900">{{ $failure['message'] ?? 'Unknown error' }}</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Context</h3>
|
|
||||||
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->context, JSON_PRETTY_PRINT) }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -10,7 +10,6 @@
|
|||||||
use App\Http\Controllers\TenantOnboardingController;
|
use App\Http\Controllers\TenantOnboardingController;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use App\Support\Workspaces\WorkspaceResolver;
|
use App\Support\Workspaces\WorkspaceResolver;
|
||||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||||
@ -35,7 +34,6 @@
|
|||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
@ -74,7 +72,7 @@
|
|||||||
$tenant = $tenantsQuery->first();
|
$tenant = $tenantsQuery->first();
|
||||||
|
|
||||||
if ($tenant !== null) {
|
if ($tenant !== null) {
|
||||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,12 +139,10 @@
|
|||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
'ensure-workspace-selected',
|
'ensure-workspace-selected',
|
||||||
'ensure-filament-tenant-selected',
|
|
||||||
])
|
])
|
||||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||||
->name('admin.operations.index');
|
->name('admin.operations.index');
|
||||||
@ -155,12 +151,10 @@
|
|||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
'ensure-workspace-selected',
|
'ensure-workspace-selected',
|
||||||
'ensure-filament-tenant-selected',
|
|
||||||
])
|
])
|
||||||
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||||
->name('admin.monitoring.alerts');
|
->name('admin.monitoring.alerts');
|
||||||
@ -169,12 +163,10 @@
|
|||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
'ensure-workspace-selected',
|
'ensure-workspace-selected',
|
||||||
'ensure-filament-tenant-selected',
|
|
||||||
])
|
])
|
||||||
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
||||||
->name('admin.monitoring.audit-log');
|
->name('admin.monitoring.audit-log');
|
||||||
@ -183,10 +175,10 @@
|
|||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
|
'ensure-workspace-selected',
|
||||||
])
|
])
|
||||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||||
->name('admin.operations.view');
|
->name('admin.operations.view');
|
||||||
@ -195,12 +187,10 @@
|
|||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
DenyNonMemberTenantAccess::class,
|
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
'ensure-workspace-member',
|
'ensure-workspace-member',
|
||||||
'ensure-filament-tenant-selected',
|
|
||||||
])
|
])
|
||||||
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
||||||
->name('admin.workspace.managed-tenants.index');
|
->name('admin.workspace.managed-tenants.index');
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-06
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs) — Implementation Notes section is clearly marked non-normative; FR-078-002 mentions trait names as implementation guidance only
|
||||||
|
- [x] Focused on user value and business needs — all user stories describe user outcomes, not system internals
|
||||||
|
- [x] Written for non-technical stakeholders — principles and requirements use domain language
|
||||||
|
- [x] All mandatory sections completed — User Scenarios, Requirements, Success Criteria all present
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain — all decisions resolved (302 vs 301, KPI deferral, infolist approach)
|
||||||
|
- [x] Requirements are testable and unambiguous — each FR has specific verifiable behavior
|
||||||
|
- [x] Success criteria are measurable — SC-001 through SC-006 all have concrete pass/fail conditions
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details) — criteria reference URLs and user outcomes, not code
|
||||||
|
- [x] All acceptance scenarios are defined — 4 user stories with given/when/then scenarios
|
||||||
|
- [x] Edge cases are identified — 5 edge cases documented including null workspace, non-numeric record, null tenant
|
||||||
|
- [x] Scope is clearly bounded — Non-Goals section explicitly excludes KPI workspace-scoping, alerts engine, capability-gating
|
||||||
|
- [x] Dependencies and assumptions identified — baseline routes, existing link helpers, constitution alignment documented
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria — FR-078-001 through FR-078-012 each specify observable behavior
|
||||||
|
- [x] User scenarios cover primary flows — canonical view, legacy redirects, contextual nav, list regression
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria — SC-001 (one canonical URL), SC-003 (secure redirects), SC-005 (verification report tenantless)
|
||||||
|
- [x] No implementation details leak into specification — Implementation Notes section is non-normative; core spec is behavior-focused
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- FR-078-002 includes implementation guidance (trait names) as a non-normative hint for planners; the normative requirement is "reuse infolist schema" regardless of approach.
|
||||||
|
- Open decision on 301 vs 302 documented; 302 chosen as Phase 1 default with clear promotion path.
|
||||||
|
- KPI workspace-scoping explicitly deferred (Non-Goals + FR-078-008) — keeps migration scope focused.
|
||||||
109
specs/078-operations-tenantless-canonical/contracts/routes.md
Normal file
109
specs/078-operations-tenantless-canonical/contracts/routes.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# Route Contracts: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical Routes (Retained — No Changes)
|
||||||
|
|
||||||
|
### GET /admin/operations
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `admin.operations.index` |
|
||||||
|
| **Handler** | `App\Filament\Pages\Monitoring\Operations` |
|
||||||
|
| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware, `ensure-workspace-selected`, `ensure-filament-tenant-selected` |
|
||||||
|
| **Auth** | Requires authentication + workspace membership |
|
||||||
|
| **Scope** | Workspace-level (shows all runs in workspace) |
|
||||||
|
| **Response** | 200 HTML (Livewire page) |
|
||||||
|
|
||||||
|
### GET /admin/operations/{run}
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `admin.operations.view` |
|
||||||
|
| **Handler** | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` |
|
||||||
|
| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware |
|
||||||
|
| **Auth** | Requires authentication + workspace membership for `$run->workspace_id` |
|
||||||
|
| **Model binding** | `{run}` resolves to `OperationRun` by ID |
|
||||||
|
| **Non-member** | 404 (deny-as-not-found) |
|
||||||
|
| **Not found** | 404 (Laravel model binding) |
|
||||||
|
| **Response** | 200 HTML (Livewire page with infolist) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decommissioned Routes (Resource-Generated Routes Removed After Migration)
|
||||||
|
|
||||||
|
### GET /admin/t/{tenant}/operations/r/{record}
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `filament.admin.resources.operations.view` |
|
||||||
|
| **Status** | ❌ **REMOVED** — route no longer registered |
|
||||||
|
| **After migration** | Natural 404 |
|
||||||
|
| **Previously** | `ViewOperationRun` (Filament ViewRecord page) |
|
||||||
|
|
||||||
|
### GET /admin/t/{tenant}/operations
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `filament.admin.resources.operations.index` |
|
||||||
|
| **Status** | ❌ **REMOVED** — route no longer registered |
|
||||||
|
| **After migration** | Replaced by explicit convenience route `admin.operations.legacy-index` that redirects 302 → `/admin/operations` |
|
||||||
|
| **Previously** | `ListOperationRuns` (Filament ListRecords page) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Link Generation Contracts
|
||||||
|
|
||||||
|
### OperationRunLinks::view($run, $tenant)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | `route('admin.operations.view', ['run' => $run])` |
|
||||||
|
| **Delegates to** | `OperationRunLinks::tenantlessView($run)` |
|
||||||
|
| **Tenant parameter** | Ignored (no-op) |
|
||||||
|
| **Change** | None — already canonical |
|
||||||
|
|
||||||
|
### OperationRunLinks::tenantlessView($run)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | `route('admin.operations.view', ['run' => $run])` |
|
||||||
|
| **Change** | None |
|
||||||
|
|
||||||
|
### OperationRunLinks::index()
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | `route('admin.operations.index')` |
|
||||||
|
| **Change** | None |
|
||||||
|
|
||||||
|
### OperationRunLinks::related($run, $tenant)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | Array of up to 11 contextual link arrays |
|
||||||
|
| **Change** | None — consumed by `TenantlessOperationRunViewer` header actions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Route Assertions
|
||||||
|
|
||||||
|
### Positive (must work)
|
||||||
|
|
||||||
|
| Test | Route | Expected |
|
||||||
|
|------|-------|----------|
|
||||||
|
| T-078-001 | `GET /admin/operations/{run}` | 200 (member) |
|
||||||
|
| T-078-001 | `GET /admin/operations/{run}` | 200 (run with `tenant_id = null`) |
|
||||||
|
| T-078-009 | `GET /admin/t/{tenant}/operations` | 302 redirect to `/admin/operations` |
|
||||||
|
|
||||||
|
### Negative (must 404)
|
||||||
|
|
||||||
|
| Test | Route | Expected |
|
||||||
|
|------|-------|----------|
|
||||||
|
| T-078-001 | `GET /admin/operations/{run}` | 404 (non-member) |
|
||||||
|
| T-078-002 | `GET /admin/t/{tenant}/operations/r/{record}` | 404 (any user) |
|
||||||
|
| T-078-004 | Route name `filament.admin.resources.operations.view` | Not registered |
|
||||||
|
| T-078-004 | Route name `filament.admin.resources.operations.index` | Not registered |
|
||||||
94
specs/078-operations-tenantless-canonical/data-model.md
Normal file
94
specs/078-operations-tenantless-canonical/data-model.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Data Model: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does **not** introduce new models or migrations. It reorganizes how existing models are rendered and routed.
|
||||||
|
|
||||||
|
## Entities (Existing — No Changes)
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | `bigint` (PK) | Auto-increment |
|
||||||
|
| `workspace_id` | `bigint` (FK) | Required. Authorization boundary. |
|
||||||
|
| `tenant_id` | `bigint` (FK, nullable) | Null for workspace-level runs (e.g., onboarding). |
|
||||||
|
| `type` | `string` | Operation type slug (e.g., `policy.sync`, `restore.execute`). |
|
||||||
|
| `status` | `enum` | `OperationRunStatus`: Queued, Running, Completed, Cancelled. |
|
||||||
|
| `outcome` | `enum` (nullable) | `OperationRunOutcome`: Succeeded, PartiallySucceeded, Failed. |
|
||||||
|
| `context` | `jsonb` | Contains `target_scope`, `verification_report`, etc. |
|
||||||
|
| `summary_counts` | `jsonb` | `{total, processed, succeeded, failed, skipped}` |
|
||||||
|
| `failure_summary` | `jsonb` (nullable) | Sanitized failure details. |
|
||||||
|
| `initiated_by` | `bigint` (FK, nullable) | User who started the run. |
|
||||||
|
| `started_at` | `timestamp` (nullable) | |
|
||||||
|
| `completed_at` | `timestamp` (nullable) | |
|
||||||
|
| `created_at` | `timestamp` | |
|
||||||
|
| `updated_at` | `timestamp` | |
|
||||||
|
|
||||||
|
**Relationships**: `belongsTo Workspace`, `belongsTo Tenant` (nullable), `belongsTo User` (initiated_by)
|
||||||
|
|
||||||
|
### WorkspaceMembership (Authorization Boundary)
|
||||||
|
|
||||||
|
The `OperationRunPolicy::view()` checks:
|
||||||
|
1. User must be a member of `$run->workspace_id`
|
||||||
|
2. Returns `Response::denyAsNotFound()` if not a member
|
||||||
|
|
||||||
|
No changes to this model or policy logic.
|
||||||
|
|
||||||
|
## Routing Changes (No Model Impact)
|
||||||
|
|
||||||
|
### Routes Removed
|
||||||
|
|
||||||
|
| Route Name | Pattern | Handler |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `filament.admin.resources.operations.index` | `GET /admin/t/{tenant}/operations` | `ListOperationRuns` (resource-generated) |
|
||||||
|
| `filament.admin.resources.operations.view` | `GET /admin/t/{tenant}/operations/r/{record}` | `ViewOperationRun` |
|
||||||
|
|
||||||
|
### Route Added
|
||||||
|
|
||||||
|
| Route Name | Pattern | Handler |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `admin.operations.legacy-index` | `GET /admin/t/{tenant}/operations` | Redirect 302 to `/admin/operations` |
|
||||||
|
|
||||||
|
### Routes Retained (Unchanged)
|
||||||
|
|
||||||
|
| Route Name | Pattern | Handler |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `admin.operations.index` | `GET /admin/operations` | `Operations.php` |
|
||||||
|
| `admin.operations.view` | `GET /admin/operations/{run}` | `TenantlessOperationRunViewer` |
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
|
||||||
|
| File | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer |
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php |
|
||||||
|
| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code |
|
||||||
|
| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code (blade for OperationsDetail) |
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` |
|
||||||
|
| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Reuse infolist via schema, add related links |
|
||||||
|
| `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with `{{ $this->infolist }}` |
|
||||||
|
| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Hide stats when no tenant context |
|
||||||
|
|
||||||
|
### Test Files Requiring Updates
|
||||||
|
|
||||||
|
| File | Change Required |
|
||||||
|
|------|----------------|
|
||||||
|
| `tests/Feature/Verification/VerificationAuthorizationTest.php` | Replace `OperationRunResource::getUrl('view')` with `route('admin.operations.view')` |
|
||||||
|
| `tests/Feature/OpsUx/FailureSanitizationTest.php` | Replace `OperationRunResource::getUrl('view')` with canonical route; replace `ViewOperationRun` mount |
|
||||||
|
| `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` | Update guard regex to account for headless resource |
|
||||||
|
| `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
|
||||||
|
| `tests/Feature/Verification/VerificationReportRedactionTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
|
||||||
|
| `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
|
||||||
|
| `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` | Remove `ListOperationRuns` test; add route-not-registered assertion |
|
||||||
|
| `tests/Feature/Monitoring/OperationsTenantScopeTest.php` | Remove `ListOperationRuns` test |
|
||||||
288
specs/078-operations-tenantless-canonical/plan.md
Normal file
288
specs/078-operations-tenantless-canonical/plan.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# Implementation Plan: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Branch**: `078-operations-tenantless-canonical` | **Date**: 2025-07-13 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/078-operations-tenantless-canonical/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make Operations detail **fully canonical** at `/admin/operations/{run}` by converting `TenantlessOperationRunViewer` to reuse `OperationRunResource::infolist()` via Filament v5's unified schema system, decommissioning auto-generated tenant-scoped resource pages (they naturally 404 after route removal), cleaning up dead code, and updating all affected tests.
|
||||||
|
|
||||||
|
Key approach: Filament v5 deprecated `InteractsWithInfolists` — every `Page` already has `InteractsWithSchemas`. The existing `OperationRunResource::infolist()` is `public static` with no `$this` references and already handles `Filament::getTenant()` returning null. This means `TenantlessOperationRunViewer` can define `infolist(Schema $schema)` to delegate directly, achieving full visual parity with zero code duplication.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Filament Infolists (schema-based)
|
||||||
|
**Storage**: PostgreSQL (no new migrations — read-only model changes)
|
||||||
|
**Testing**: Pest v4 (Feature tests)
|
||||||
|
**Target Platform**: Web (Laravel Sail / Docker)
|
||||||
|
**Project Type**: Web application (monolith)
|
||||||
|
**Performance Goals**: DB-only rendering (no external calls on page load)
|
||||||
|
**Constraints**: Tenantless pages must render without `Filament::getTenant()`; no new dependencies
|
||||||
|
**Scale/Scope**: ~15 files modified/deleted, ~8 test files updated, 0 new migrations
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: All pass. No violations.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Inventory-first | N/A | No inventory changes |
|
||||||
|
| Read/write separation | Pass | Feature is read-only (rendering changes only) |
|
||||||
|
| Graph contract path | N/A | No Graph calls |
|
||||||
|
| Deterministic capabilities | N/A | No capability changes |
|
||||||
|
| RBAC-UX planes | Pass | Workspace-level auth only; non-member = 404 (RBAC-UX-002) |
|
||||||
|
| RBAC-UX destructive | N/A | No destructive actions |
|
||||||
|
| RBAC-UX global search | Pass | Resource has `$shouldRegisterNavigation = false`, no `$recordTitleAttribute` |
|
||||||
|
| Tenant isolation | Pass | Reads workspace-scoped; no cross-tenant access |
|
||||||
|
| Run observability | N/A | No new operations; monitoring pages remain DB-only |
|
||||||
|
| Automation | N/A | No queued/scheduled work |
|
||||||
|
| Data minimization | Pass | No new data stored |
|
||||||
|
| Badge semantics | Pass | Existing `BadgeRenderer` reused via infolist — no new badge mappings |
|
||||||
|
|
||||||
|
**Post-design re-check**: Same results — no constitution violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/078-operations-tenantless-canonical/
|
||||||
|
+-- spec.md # Feature specification
|
||||||
|
+-- plan.md # This file
|
||||||
|
+-- research.md # Phase 0: Filament v5 schema research
|
||||||
|
+-- data-model.md # Phase 1: Entity & routing changes
|
||||||
|
+-- quickstart.md # Phase 1: Verification steps
|
||||||
|
+-- contracts/
|
||||||
|
| +-- routes.md # Route contract (before/after)
|
||||||
|
+-- checklists/
|
||||||
|
| +-- requirements.md # Spec quality checklist
|
||||||
|
+-- tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (files touched)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
+-- Filament/
|
||||||
|
| +-- Resources/
|
||||||
|
| | +-- OperationRunResource.php # getPages() returns []
|
||||||
|
| | +-- OperationRunResource/Pages/
|
||||||
|
| | +-- ViewOperationRun.php # DELETE
|
||||||
|
| | +-- ListOperationRuns.php # DELETE
|
||||||
|
| +-- Pages/
|
||||||
|
| | +-- Operations/
|
||||||
|
| | +-- TenantlessOperationRunViewer.php # Infolist reuse + related links
|
||||||
|
| +-- Widgets/
|
||||||
|
| +-- Operations/
|
||||||
|
| +-- OperationsKpiHeader.php # Hide stats when no tenant
|
||||||
|
+-- Livewire/
|
||||||
|
+-- Monitoring/
|
||||||
|
+-- OperationsDetail.php # DELETE (dead code)
|
||||||
|
|
||||||
|
resources/views/
|
||||||
|
+-- filament/pages/operations/
|
||||||
|
| +-- tenantless-operation-run-viewer.blade.php # Replace HTML with infolist render
|
||||||
|
+-- livewire/monitoring/
|
||||||
|
+-- operations-detail.blade.php # DELETE (dead code)
|
||||||
|
|
||||||
|
tests/Feature/
|
||||||
|
+-- Operations/
|
||||||
|
| +-- TenantlessOperationRunViewerTest.php # Update infolist assertions
|
||||||
|
+-- Monitoring/
|
||||||
|
| +-- OperationsCanonicalUrlsTest.php # Remove ListOperationRuns, add route-gone
|
||||||
|
| +-- OperationsTenantScopeTest.php # Remove ListOperationRuns reference
|
||||||
|
+-- Verification/
|
||||||
|
| +-- VerificationAuthorizationTest.php # Canonical route instead of getUrl
|
||||||
|
| +-- VerificationReportViewerDbOnlyTest.php # TenantlessViewer replaces ViewOperationRun
|
||||||
|
| +-- VerificationReportRedactionTest.php # TenantlessViewer replaces ViewOperationRun
|
||||||
|
| +-- VerificationReportMissingOrMalformedTest.php # TenantlessViewer replaces ViewOperationRun
|
||||||
|
+-- OpsUx/
|
||||||
|
| +-- FailureSanitizationTest.php # Canonical route + TenantlessViewer
|
||||||
|
| +-- CanonicalViewRunLinksTest.php # Update guard regex
|
||||||
|
+-- 078/ # NEW spec-specific tests
|
||||||
|
+-- CanonicalDetailRenderTest.php
|
||||||
|
+-- LegacyRoutesReturnNotFoundTest.php
|
||||||
|
+-- KpiHeaderTenantlessTest.php
|
||||||
|
+-- VerificationReportTenantlessTest.php
|
||||||
|
+-- TenantListRedirectTest.php
|
||||||
|
+-- RelatedLinksOnDetailTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. No new directories except `tests/Feature/078/` for spec tests.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations to justify.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| None | N/A | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase A — Headless Resource + Dead Code Cleanup
|
||||||
|
|
||||||
|
**Goal**: Remove auto-generated routes; delete dead code.
|
||||||
|
**Risk**: Low — removing unused pages and dead code.
|
||||||
|
**Tests first**: T-078-004 (routes not registered), T-078-002 (legacy URLs 404).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `OperationRunResource.php` | `getPages()` returns `[]` |
|
||||||
|
| A.2 | `ViewOperationRun.php` | Delete file |
|
||||||
|
| A.3 | `ListOperationRuns.php` | Delete file |
|
||||||
|
| A.4 | `OperationsDetail.php` | Delete file (dead code) |
|
||||||
|
| A.5 | `operations-detail.blade.php` | Delete file (dead code) |
|
||||||
|
| A.6 | `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` | New: T-078-002 + T-078-004 |
|
||||||
|
| A.7 | 7 existing test files | Replace `ViewOperationRun` / `ListOperationRuns` / `getUrl('view')` references |
|
||||||
|
|
||||||
|
**Exit criteria**: All existing tests pass; legacy URLs return 404; no routes registered for resource.
|
||||||
|
|
||||||
|
### Phase B — Infolist Reuse on TenantlessOperationRunViewer
|
||||||
|
|
||||||
|
**Goal**: Replace hand-coded Blade with Filament schema-based infolist for full visual parity.
|
||||||
|
**Risk**: Medium — schema auto-discovery on standalone Page needs verification.
|
||||||
|
**Tests first**: T-078-001 (canonical detail renders), T-078-008 (verification report tenantless).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `TenantlessOperationRunViewer.php` | Add `infolist(Schema $schema)` — delegates to `OperationRunResource::infolist($schema)` |
|
||||||
|
| B.2 | `TenantlessOperationRunViewer.php` | Add `defaultInfolist(Schema $schema)` — `->record($this->run)->columns(2)` |
|
||||||
|
| B.3 | `TenantlessOperationRunViewer.php` | Add `content(Schema $schema)` — returns `EmbeddedSchema::make('infolist')` |
|
||||||
|
| B.4 | `TenantlessOperationRunViewer.php` | Add `public bool $opsUxIsTabHidden = false` property (polling callback) |
|
||||||
|
| B.5 | `tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with infolist render tag |
|
||||||
|
| B.6 | `tests/Feature/078/CanonicalDetailRenderTest.php` | New: T-078-001 (+ T-078-007 guard) |
|
||||||
|
| B.7 | `tests/Feature/078/VerificationReportTenantlessTest.php` | New: T-078-008 |
|
||||||
|
|
||||||
|
**Exit criteria**: Canonical detail shows identical layout to old tenant-scoped view; verification report section renders.
|
||||||
|
|
||||||
|
### Phase C — Contextual Navigation (Related Links)
|
||||||
|
|
||||||
|
**Goal**: Replace "Admin details" button with `OperationRunLinks::related()` action group.
|
||||||
|
**Risk**: Low — mechanism already exists, just wiring.
|
||||||
|
**Tests first**: T-078-010 (related links appear), T-078-005 (no "Admin details" link).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `TenantlessOperationRunViewer.php` | Add `getHeaderActions()` using `OperationRunLinks::related()` |
|
||||||
|
| C.2 | `TenantlessOperationRunViewer.php` | Remove "Admin details" button code (~line 61) |
|
||||||
|
| C.3 | `tests/Feature/078/RelatedLinksOnDetailTest.php` | New: T-078-010 + T-078-005 + T-078-012 |
|
||||||
|
|
||||||
|
**Exit criteria**: Related links render for different run types; "Admin details" link absent.
|
||||||
|
|
||||||
|
### Phase D — KPI Header Tenantless Handling
|
||||||
|
|
||||||
|
**Goal**: Hide KPI stats when no tenant context (not workspace-scoped — deferred).
|
||||||
|
**Risk**: Low — conditional early return.
|
||||||
|
**Tests first**: T-078-006 (KPI hidden in tenantless mode), T-078-011 (tenantless list query safety).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `OperationsKpiHeader.php` | `getStats()`: if `Filament::getTenant()` is null then return `[]` |
|
||||||
|
| D.2 | `tests/Feature/078/KpiHeaderTenantlessTest.php` | New: T-078-006 |
|
||||||
|
| D.3 | `tests/Feature/078/OperationsListTenantlessSafetyTest.php` | New: T-078-011 |
|
||||||
|
|
||||||
|
**Exit criteria**: Operations page without tenant context renders without errors; KPI section hidden.
|
||||||
|
|
||||||
|
### Phase E — List Redirect (FR-078-012)
|
||||||
|
|
||||||
|
**Goal**: Convenience redirect for decommissioned list URL.
|
||||||
|
**Risk**: Low — single route addition.
|
||||||
|
**Tests first**: T-078-009 (302 redirect).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `routes/web.php` | Add redirect: `/admin/t/{tenant}/operations` 302 to `/admin/operations` |
|
||||||
|
| E.2 | `tests/Feature/078/TenantListRedirectTest.php` | New: T-078-009 |
|
||||||
|
|
||||||
|
**Exit criteria**: Tenant-scoped list URL redirects; no other URLs affected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Schema-based infolist (not InteractsWithInfolists)
|
||||||
|
|
||||||
|
Filament v5 unified forms/infolists/tables into the schema system. `InteractsWithInfolists` is deprecated. Every `Page` already has `InteractsWithSchemas` via `BasePage`. Define `infolist(Schema $schema)` on the page and it auto-discovers via reflection.
|
||||||
|
|
||||||
|
**See**: [research.md](research.md) R-001
|
||||||
|
|
||||||
|
### D-002 — Empty getPages() (not resource exclusion)
|
||||||
|
|
||||||
|
Returning `[]` from `getPages()` cleanly prevents all route registration while keeping the class available for `::table()` and `::infolist()` reuse. Simpler than excluding from panel discovery.
|
||||||
|
|
||||||
|
**See**: [research.md](research.md) R-003
|
||||||
|
|
||||||
|
### D-003 — Natural 404 (not redirect handlers)
|
||||||
|
|
||||||
|
After route decommission, legacy detail URLs naturally 404. No redirect handlers needed — simplest approach, zero information leakage, zero maintenance.
|
||||||
|
|
||||||
|
**See**: spec.md clarifications (FR-078-005/006 removed)
|
||||||
|
|
||||||
|
### D-004 — KPI hidden (not workspace-scoped queries)
|
||||||
|
|
||||||
|
Phase 1 hides KPI when no tenant. Full workspace-scoped KPI requires refactoring 6 queries + `ActiveRuns::existForWorkspace()` — deferred to separate spec.
|
||||||
|
|
||||||
|
**See**: [research.md](research.md) R-004, R-005
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Schema auto-discovery fails on standalone Page | Medium | Low | Research confirms it works (R-001); fallback: static builder extraction |
|
||||||
|
| Test updates miss a reference to deleted pages | Low | Medium | `grep -r` sweep for ViewOperationRun and ListOperationRuns before PR |
|
||||||
|
| Infolist polling breaks without opsUxIsTabHidden | Low | Medium | Add property explicitly; existing poll logic has null-safe fallback |
|
||||||
|
| KPI widget error when tenant is null | Low | Low | Already returns [] on null; this change makes it explicit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### New Tests (spec-specific in tests/Feature/078/)
|
||||||
|
|
||||||
|
| Test ID | File | Coverage |
|
||||||
|
|---------|------|----------|
|
||||||
|
| T-078-001 | CanonicalDetailRenderTest.php | Detail renders with/without tenant_id |
|
||||||
|
| T-078-002 | LegacyRoutesReturnNotFoundTest.php | Legacy detail URLs return 404 |
|
||||||
|
| T-078-004 | LegacyRoutesReturnNotFoundTest.php | Route names not registered |
|
||||||
|
| T-078-005 | RelatedLinksOnDetailTest.php | No "Admin details" link |
|
||||||
|
| T-078-006 | KpiHeaderTenantlessTest.php | KPI hidden without tenant |
|
||||||
|
| T-078-008 | VerificationReportTenantlessTest.php | Verification report renders tenantless |
|
||||||
|
| T-078-009 | TenantListRedirectTest.php | List redirect 302 |
|
||||||
|
| T-078-010 | RelatedLinksOnDetailTest.php | Related links in header actions |
|
||||||
|
| T-078-011 | OperationsListTenantlessSafetyTest.php | List renders safely with tenant and tenantless context |
|
||||||
|
| T-078-012 | RelatedLinksOnDetailTest.php | Canonical CTA label is "View run" and legacy CTA removed |
|
||||||
|
|
||||||
|
### Updated Tests (existing)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| VerificationAuthorizationTest.php | `getUrl('view')` replaced with `route('admin.operations.view')` |
|
||||||
|
| FailureSanitizationTest.php | `getUrl('view')` replaced with canonical route; ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| CanonicalViewRunLinksTest.php | Update guard regex for headless resource |
|
||||||
|
| VerificationReportViewerDbOnlyTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| VerificationReportRedactionTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| VerificationReportMissingOrMalformedTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| OperationsCanonicalUrlsTest.php | Remove ListOperationRuns test, add route-gone assertion |
|
||||||
|
| OperationsTenantScopeTest.php | Remove ListOperationRuns reference |
|
||||||
|
|
||||||
|
### Focused Test Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/078/ \
|
||||||
|
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
|
||||||
|
tests/Feature/Verification/VerificationAuthorizationTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportRedactionTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php \
|
||||||
|
tests/Feature/OpsUx/FailureSanitizationTest.php \
|
||||||
|
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
|
||||||
|
```
|
||||||
80
specs/078-operations-tenantless-canonical/quickstart.md
Normal file
80
specs/078-operations-tenantless-canonical/quickstart.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# Quickstart: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Branch**: `078-operations-tenantless-canonical`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Laravel Sail running (`vendor/bin/sail up -d`)
|
||||||
|
- Database migrated (`vendor/bin/sail artisan migrate`)
|
||||||
|
- At least one workspace with a user member
|
||||||
|
- At least one `OperationRun` record (with and without `tenant_id`)
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
### 1. Canonical detail renders
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Visit as authenticated workspace member
|
||||||
|
# URL: /admin/operations/{run_id}
|
||||||
|
# Expected: Full infolist renders (summary, target scope, verification report, counts, context JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Auto-generated tenant routes are gone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan route:list --name=filament.admin.resources.operations
|
||||||
|
# Expected: No routes listed (empty output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Canonical list still works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Visit: /admin/operations
|
||||||
|
# Expected: Workspace-scoped table with status tabs, filters
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the focused test pack for this spec:
|
||||||
|
vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/078/ \
|
||||||
|
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
|
||||||
|
tests/Feature/Verification/VerificationAuthorizationTest.php \
|
||||||
|
tests/Feature/OpsUx/FailureSanitizationTest.php \
|
||||||
|
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportRedactionTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php
|
||||||
|
|
||||||
|
# Expected: All pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Pint formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files to Inspect
|
||||||
|
|
||||||
|
| File | What to check |
|
||||||
|
|------|---------------|
|
||||||
|
| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` |
|
||||||
|
| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Uses schema-based infolist, has related links header |
|
||||||
|
| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Returns empty stats when no tenant context |
|
||||||
|
| `app/Filament/Pages/Monitoring/Operations.php` | Unchanged — still reuses `OperationRunResource::table()` |
|
||||||
|
|
||||||
|
## What Was Deleted
|
||||||
|
|
||||||
|
| File | Why |
|
||||||
|
|------|-----|
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer |
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php |
|
||||||
|
| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code |
|
||||||
|
| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code |
|
||||||
93
specs/078-operations-tenantless-canonical/research.md
Normal file
93
specs/078-operations-tenantless-canonical/research.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Research: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-001 — Filament v5 Infolist Reuse on Standalone Pages
|
||||||
|
|
||||||
|
**Decision**: Use the native `InteractsWithSchemas` mechanism (already on every `Page`). No need for `InteractsWithInfolists` or `HasInfolists`.
|
||||||
|
|
||||||
|
**Rationale**: In Filament v5, the schema system is unified — forms, infolists, and tables all go through `InteractsWithSchemas`. The `InteractsWithInfolists` trait is fully deprecated (every method proxies to schema equivalents). `HasInfolists` is an empty marker interface. Every `Filament\Pages\Page` already extends `BasePage` which uses `InteractsWithSchemas` and implements `HasSchemas`.
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
1. Define `public function infolist(Schema $schema): Schema` on the Page
|
||||||
|
2. `InteractsWithSchemas` auto-discovers it via reflection
|
||||||
|
3. Render via `{{ $this->infolist }}` (magic property) or `EmbeddedSchema::make('infolist')`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Adding `InteractsWithInfolists` trait → Rejected: deprecated shim, adds no functionality
|
||||||
|
- Extracting a static builder from `OperationRunResource` → Not needed: `infolist()` is already `static`
|
||||||
|
|
||||||
|
**Source files verified**:
|
||||||
|
- `vendor/filament/infolists/src/Concerns/InteractsWithInfolists.php` — all methods `@deprecated`
|
||||||
|
- `vendor/filament/infolists/src/Contracts/HasInfolists.php` — empty interface
|
||||||
|
- `vendor/filament/support/src/Pages/BasePage.php` — `implements HasSchemas`, uses `InteractsWithSchemas`
|
||||||
|
- `vendor/filament/support/src/Concerns/InteractsWithSchemas.php` — auto-discovers methods by name
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-002 — OperationRunResource::infolist() Compatibility
|
||||||
|
|
||||||
|
**Decision**: Call `OperationRunResource::infolist($schema)` directly from the standalone Page.
|
||||||
|
|
||||||
|
**Rationale**: The method is `public static`, uses no `$this` references, and already handles tenantless context gracefully (`Filament::getTenant()` returns null → falls back to `OperationRunLinks::tenantlessView()`).
|
||||||
|
|
||||||
|
**Requirements for the standalone Page**:
|
||||||
|
1. `public function infolist(Schema $schema): Schema` → delegates to `OperationRunResource::infolist($schema)`
|
||||||
|
2. `public function defaultInfolist(Schema $schema): Schema` → sets `->record($this->run)->columns(2)`
|
||||||
|
3. `public bool $opsUxIsTabHidden = false` property → needed for polling callback in infolist
|
||||||
|
4. Override `content()` or render via Blade `{{ $this->infolist }}`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Extracting infolist into a shared trait → Overengineered for one consumer
|
||||||
|
- Keeping hand-coded Blade → Defeats the single-source goal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-003 — Headless Resource Pattern (Empty getPages)
|
||||||
|
|
||||||
|
**Decision**: Return `[]` from `OperationRunResource::getPages()` to eliminate all auto-generated routes.
|
||||||
|
|
||||||
|
**Rationale**: `Resource::routes()` iterates `getPages()` in a `foreach` — empty array means zero route registrations. The resource class is retained as a static utility providing `::table()` and `::infolist()` schema builders.
|
||||||
|
|
||||||
|
**Impact**: The following route names will no longer exist:
|
||||||
|
- `filament.admin.resources.operations.index`
|
||||||
|
- `filament.admin.resources.operations.view`
|
||||||
|
|
||||||
|
**Files that reference these routes** (need updating):
|
||||||
|
- `tests/Feature/Verification/VerificationAuthorizationTest.php` (L38, L74) — `OperationRunResource::getUrl('view', ...)`
|
||||||
|
- `tests/Feature/OpsUx/FailureSanitizationTest.php` (L58) — `OperationRunResource::getUrl('view', ...)`
|
||||||
|
- `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` (L25) — guard test scanning for stale references (update regex)
|
||||||
|
- `tests/Feature/Verification/VerificationDbOnlyTest.php` — `ViewOperationRun` Livewire test
|
||||||
|
- `tests/Feature/OpsUx/FailureSanitizationTest.php` — `ViewOperationRun` Livewire test
|
||||||
|
- `tests/Feature/Verification/VerificationReportRenderingTest.php` — `ViewOperationRun` Livewire test
|
||||||
|
- `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` (L121) — `ListOperationRuns` Livewire test
|
||||||
|
- `tests/Feature/Monitoring/OperationsTenantScopeTest.php` (L119) — `ListOperationRuns` Livewire test
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keeping a dummy page that redirects → Adds complexity, spec says natural 404
|
||||||
|
- Excluding resource from discovery → More fragile than empty `getPages()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-004 — KPI Header Tenantless Behavior
|
||||||
|
|
||||||
|
**Decision**: Hide `OperationsKpiHeader` when no tenant context is available (Phase 1).
|
||||||
|
|
||||||
|
**Rationale**: The widget runs 6 queries all scoped by `tenant_id`. Without tenant context, all queries return 0. Showing zeros is misleading. Workspace-scoped queries are a larger refactor deferred to a separate spec.
|
||||||
|
|
||||||
|
**Implementation**: Check `Filament::getTenant()` in the widget's `getStats()` — return empty array if null. Or conditionally register the widget on the page based on tenant presence.
|
||||||
|
|
||||||
|
**Source**: `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — 131 lines, 4 stat cards, `$isLazy = false`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-005 — Legacy List Redirect (FR-078-012)
|
||||||
|
|
||||||
|
**Decision**: Add a 302 redirect from `/admin/t/{tenant}/operations` to `/admin/operations`.
|
||||||
|
|
||||||
|
**Rationale**: Unlike detail URLs which naturally 404 after page decommission, the list URL pattern may still be reached through Filament's navigation system during the transition period. A simple redirect avoids confusion.
|
||||||
|
|
||||||
|
**Note**: This is the only redirect in the spec. All detail-level legacy URLs naturally 404.
|
||||||
343
specs/078-operations-tenantless-canonical/spec.md
Normal file
343
specs/078-operations-tenantless-canonical/spec.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# Feature Specification: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature Branch**: `078-operations-tenantless-canonical`
|
||||||
|
**Created**: 2026-02-06
|
||||||
|
**Status**: Draft
|
||||||
|
**Stack**: Filament v5 + Livewire v4 (native only)
|
||||||
|
**Input**: Eliminate dual "run detail" surfaces (tenant-scoped Filament view vs tenantless canonical viewer) and make Operations truly canonical, supportable, and secure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Executive Summary
|
||||||
|
|
||||||
|
Operations is already **canonical at the index and tenantless detail** (`/admin/operations`, `/admin/operations/{run}`), but **auto-generated tenant-scoped Filament resource routes** still exist as side-effects of panel discovery:
|
||||||
|
|
||||||
|
- Tenant-scoped detail: `/admin/t/{tenant}/operations/r/{record}`
|
||||||
|
- Tenant-scoped list: `/admin/t/{tenant}/operations`
|
||||||
|
|
||||||
|
This spec makes the tenantless detail page **the only run detail view**, removes the auto-generated tenant-scoped pages (legacy detail URLs naturally 404), and ensures **deny-as-not-found** security—without introducing non-native UI frameworks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-06
|
||||||
|
|
||||||
|
- Q: Should a tenant-scoped legacy URL (`/admin/t/{tenant}/operations/r/{record}`) for a run with `tenant_id = null` be allowed (redirect) or blocked (404)? → A: ~~Allow redirect~~ Superseded — FR-078-005/006 removed; legacy detail URLs naturally 404 after route decommission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
1. **Single canonical run detail view** — `/admin/operations/{run}`
|
||||||
|
2. **Remove auto-generated tenant-scoped pages** — decommission both `/admin/t/{tenant}/operations/r/{record}` and `/admin/t/{tenant}/operations`
|
||||||
|
3. **Clean decommission** — legacy tenant-scoped detail URLs naturally 404 after route removal (no redirect handlers needed)
|
||||||
|
4. **No duplication of UI logic** — reuse `OperationRunResource::infolist()` in the tenantless viewer
|
||||||
|
5. **Enterprise security semantics** — non-member → 404, no existence leakage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Non-Goals
|
||||||
|
|
||||||
|
- Building a new operations dashboard
|
||||||
|
- Introducing an alerts engine
|
||||||
|
- Changing operation execution semantics
|
||||||
|
- Implementing fine-grained `operations.view` capabilities (access remains workspace-membership)
|
||||||
|
- Workspace-scoped KPI header (tracked separately; header hidden in tenantless mode for Phase 1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Current State (Baseline)
|
||||||
|
|
||||||
|
**Registered routes (today):**
|
||||||
|
|
||||||
|
| Route | Pattern | Name | Handler | Scope |
|
||||||
|
|-------|---------|------|---------|-------|
|
||||||
|
| Canonical list | `GET /admin/operations` | `admin.operations.index` | `Operations.php` (custom page) | Workspace (session) |
|
||||||
|
| Canonical detail | `GET /admin/operations/{run}` | `admin.operations.view` | `TenantlessOperationRunViewer.php` | Workspace membership |
|
||||||
|
| Auto-generated list | `GET /admin/t/{tenant}/operations` | `filament.admin.resources.operations.index` | `ListOperationRuns.php` | Tenant-scoped |
|
||||||
|
| Auto-generated view | `GET /admin/t/{tenant}/operations/r/{record}` | `filament.admin.resources.operations.view` | `ViewOperationRun.php` | Tenant-scoped |
|
||||||
|
|
||||||
|
**Key constraints:**
|
||||||
|
- Tenant-scoped routes exist because `OperationRunResource` is auto-discovered in a tenant panel (`AdminPanelProvider::discoverResources`).
|
||||||
|
- All production "View run" links already point to canonical tenantless detail via `OperationRunLinks::view()` → `OperationRunLinks::tenantlessView()`.
|
||||||
|
- The resource already declares `$isScopedToTenant = false` and `$shouldRegisterNavigation = false`.
|
||||||
|
|
||||||
|
**Link helper state:**
|
||||||
|
- `OperationRunLinks::view($run, $tenant)` delegates to `tenantlessView($run)` — the `$tenant` parameter is a no-op.
|
||||||
|
- `OperationRunLinks::related($run, $tenant)` returns up to 11 contextual links (Policies, Inventory, Drift, Backup Sets, Provider Connections, etc.).
|
||||||
|
- `OperationRunUrl::view()` and `OperationRunUrl::index()` are thin wrappers around `OperationRunLinks`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Enterprise Principles (Normative)
|
||||||
|
|
||||||
|
### P-078-001 — Canonical deep links
|
||||||
|
There MUST be exactly one canonical URL to open a run: `/admin/operations/{run}`.
|
||||||
|
|
||||||
|
### P-078-002 — No tenant context dependency
|
||||||
|
Canonical run detail MUST render without tenant context and MUST NOT require `/admin/t/{tenant}`.
|
||||||
|
|
||||||
|
### P-078-003 — Deny-as-not-found
|
||||||
|
Any user not entitled to view a run MUST receive **404**, not 403. (Constitution: RBAC-UX-002)
|
||||||
|
|
||||||
|
### P-078-004 — No leakage via legacy URLs
|
||||||
|
Decommissioned tenant-scoped routes MUST NOT exist after migration. Removed routes naturally return 404 — no redirect handlers, no existence leakage.
|
||||||
|
|
||||||
|
### P-078-005 — Filament-native
|
||||||
|
Use Filament pages/resources/infolists/actions and Livewire v4 only. No custom SPA routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Scenarios & Testing
|
||||||
|
|
||||||
|
### User Story 1 — View operation run via canonical URL (Priority: P1)
|
||||||
|
|
||||||
|
A workspace member clicks a "View run" link (from notification, widget, or operations list) and sees the full run detail at `/admin/operations/{run}` — regardless of whether the run has a tenant or not.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature — the single canonical detail surface that replaces the dual-surface.
|
||||||
|
|
||||||
|
**Independent Test**: Create runs with and without `tenant_id`, navigate to `/admin/operations/{run}`, assert all sections render (summary, target scope, verification report, context JSON).
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run with `tenant_id` set and user is workspace member, **When** user visits `/admin/operations/{run}`, **Then** full detail renders including target scope, verification report (if present), summary counts, and context JSON.
|
||||||
|
2. **Given** a run with `tenant_id = null` (e.g., onboarding run), **When** user visits `/admin/operations/{run}`, **Then** detail renders without crash; target scope shows "No target scope details recorded."
|
||||||
|
3. **Given** a run with a verification report in context, **When** user visits canonical detail, **Then** verification report section renders correctly with badge rendering and acknowledgements — even though `Filament::getTenant()` returns null.
|
||||||
|
4. **Given** user is NOT a workspace member, **When** user visits `/admin/operations/{run}`, **Then** 404 (deny-as-not-found).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
|
||||||
|
|
||||||
|
A user has a bookmarked or old notification link pointing to `/admin/t/{tenant}/operations/r/{record}`. After route decommission, the system returns 404 — no redirect, no existence leakage.
|
||||||
|
|
||||||
|
**Why this priority**: Ensures decommissioned routes don't silently serve stale pages or leak information.
|
||||||
|
|
||||||
|
**Independent Test**: Hit legacy detail URLs; assert 404 for all users regardless of membership.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** any user (member or non-member), **When** user visits `/admin/t/{tenant}/operations/r/{record}`, **Then** 404.
|
||||||
|
2. **Given** any user, **When** user visits `/admin/operations/r/{record}`, **Then** 404 (the `/r/` slug variant also does not exist).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — Contextual navigation from run detail (Priority: P2)
|
||||||
|
|
||||||
|
On the canonical detail page, a workspace member sees contextual "Open" actions (e.g., "Open tenant", "Policies", "Backup Set", "Provider Connection") based on the run's context — using the existing `OperationRunLinks::related()` mechanism.
|
||||||
|
|
||||||
|
**Why this priority**: Replaces the "Admin details" back-link with richer, already-implemented navigation.
|
||||||
|
|
||||||
|
**Independent Test**: Create runs of different types, verify that the related links appear in the header actions group.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run of type `policy.sync` with `policy_id` in context, **When** viewing canonical detail, **Then** header shows "Open" group with links to Operations index, Policy, and Policies list.
|
||||||
|
2. **Given** a run with `tenant_id` and user is tenant member, **When** viewing canonical detail, **Then** related links include tenant-scoped resources (via `OperationRunLinks::related()`).
|
||||||
|
3. **Given** a run with `tenant_id = null`, **When** viewing canonical detail, **Then** no tenant-specific related links appear; only generic links (Operations index).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 — Operations list remains workspace-scoped (Priority: P3)
|
||||||
|
|
||||||
|
The `/admin/operations` page continues to show all runs for the current workspace, with an optional tenant default filter when tenant context is active.
|
||||||
|
|
||||||
|
**Why this priority**: Existing behavior; this story ensures no regression during migration.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping and default filter behavior.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** workspace context set but no tenant context, **When** visiting `/admin/operations`, **Then** all workspace runs shown.
|
||||||
|
2. **Given** tenant context active, **When** visiting `/admin/operations`, **Then** tenant filter defaults to active tenant but can be cleared to show all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Run with `workspace_id = 0` or null → 404 (existing behavior in `OperationRunPolicy::view`)
|
||||||
|
- Run exists but requesting user has no workspace membership → 404 (deny-as-not-found)
|
||||||
|
- Verification report section with `Filament::getTenant()` returning null → falls back to `OperationRunLinks::tenantlessView()` for previous-run URLs (already handled in infolist)
|
||||||
|
- Legacy tenant-scoped URLs (`/admin/t/{tenant}/operations/r/{record}`) → 404 (routes no longer registered after decommission)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):**
|
||||||
|
- Authorization plane: Workspace-level (not tenant-level). Operations detail requires workspace membership only.
|
||||||
|
- 404 vs 403: Non-member → 404 (RBAC-UX-002). No capability-gating for view-only operations access in this spec.
|
||||||
|
- Server-side enforcement: `OperationRunPolicy::view()` + `WorkspaceMembership` check in `TenantlessOperationRunViewer::mount()`.
|
||||||
|
- Legacy tenant-scoped routes are decommissioned (naturally 404); no redirect handlers needed (P-078-004).
|
||||||
|
- Global search: `OperationRunResource` has `$shouldRegisterNavigation = false` and no `$recordTitleAttribute` — not globally searchable.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable — no auth handshakes involved.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** No badge changes. Existing badge mappings (`OperationRunStatus`, `OperationRunOutcome`) are reused via shared `BadgeRenderer` in the reused infolist.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
#### 5.1 Canonical Run Detail — Feature-Complete Tenantless Page
|
||||||
|
|
||||||
|
- **FR-078-001**: `GET /admin/operations/{run}` MUST display: run summary (status/outcome/timestamps/initiator), target scope, verification report (DB-only), summary counts, failure summary, and context JSON (redacted).
|
||||||
|
- **FR-078-002**: Tenantless detail page MUST reuse `OperationRunResource::infolist()` via Filament v5's native schema system. `TenantlessOperationRunViewer` MUST define `public function infolist(Schema $schema): Schema` delegating to `OperationRunResource::infolist($schema)`, provide `defaultInfolist(Schema $schema)` to bind `->record($this->run)->columns(2)`, and render the schema via `{{ $this->infolist }}` (or `EmbeddedSchema::make('infolist')` in `content()`).
|
||||||
|
- **FR-078-003**: Tenantless detail MUST reuse `OperationRunLinks::related($run, $tenant)` for contextual header actions (replacing the removed "Admin details" button). If `$run->tenant` is null, only generic links (Operations index) appear.
|
||||||
|
|
||||||
|
#### 5.2 Decommission Auto-Generated Tenant-Scoped Pages
|
||||||
|
|
||||||
|
- **FR-078-004**: Remove the `'view'` page entry from `OperationRunResource::getPages()` and delete `ViewOperationRun.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations/r/{record}` route.
|
||||||
|
- **FR-078-011**: Remove the `'index'` page entry from `OperationRunResource::getPages()` and delete `ListOperationRuns.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations` route. The resource retains only its `table()` and `infolist()` schema builders for reuse by the custom pages.
|
||||||
|
|
||||||
|
#### 5.3 Legacy URL Handling
|
||||||
|
|
||||||
|
Legacy detail URLs (`/admin/t/{tenant}/operations/r/{record}` and `/admin/operations/r/{record}`) naturally return 404 after route decommission — no redirect handlers are needed. ~~FR-078-005 and FR-078-006 removed.~~
|
||||||
|
|
||||||
|
- **FR-078-012**: Add a convenience redirect for `GET /admin/t/{tenant}/operations`:
|
||||||
|
1. Redirect **302** to `/admin/operations`.
|
||||||
|
|
||||||
|
Note: No auth check needed since `/admin/operations` already enforces workspace membership.
|
||||||
|
|
||||||
|
#### 5.4 KPI Header Handling (Phase 1 Simplification)
|
||||||
|
|
||||||
|
- **FR-078-008**: `OperationsKpiHeader` widget MUST continue to render when tenant context is available. When no tenant context exists (tenantless pages), the KPI header SHOULD be hidden (not rendered) rather than showing zeros. Full workspace-scoped KPI support is deferred to a follow-up spec.
|
||||||
|
|
||||||
|
- **FR-078-009**: Polling/active-run queries on the canonical list page (`Operations.php`) MUST handle `tenant_id = null` runs and tenantless rendering safely. This MUST be verified by an explicit regression test covering list rendering with and without tenant context.
|
||||||
|
|
||||||
|
#### 5.5 Dead Code Cleanup
|
||||||
|
|
||||||
|
- **FR-078-010**: Delete `app/Livewire/Monitoring/OperationsDetail.php` and its Blade view `resources/views/livewire/monitoring/operations-detail.blade.php`. This component is unreferenced dead code that enforces an obsolete tenant-only abort check.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **OperationRun**: The central model. Has `workspace_id` (required for authorization), `tenant_id` (nullable — onboarding/provider-check runs may lack it), `context` (JSONB with target_scope, verification_report, etc.), `summary_counts`, `failure_summary`.
|
||||||
|
- **WorkspaceMembership**: The authorization boundary. View access requires membership in the run's workspace.
|
||||||
|
- **OperationRunLinks**: Centralized link helper. `view()` → `tenantlessView()` (already canonical). `related()` provides contextual navigation links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Exactly one canonical run detail URL exists: `/admin/operations/{run}`. The route `filament.admin.resources.operations.view` is no longer registered.
|
||||||
|
- **SC-002**: All "View run" links across notifications, widgets, tables, and jobs resolve to `/admin/operations/{run}` (no tenant-scoped detail links remain).
|
||||||
|
- **SC-003**: Legacy tenant-scoped detail URLs return 404 after route decommission (no redirect handlers, no information leakage).
|
||||||
|
- **SC-004**: Runs with `tenant_id = null` render on canonical detail without errors.
|
||||||
|
- **SC-005**: Verification report section renders correctly on canonical detail when `Filament::getTenant()` returns null.
|
||||||
|
- **SC-006**: All existing Pest tests pass after migration (no regressions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security & RBAC
|
||||||
|
|
||||||
|
### SR-078-001 — View permissions
|
||||||
|
- Viewing operations list and run detail requires workspace membership (baseline).
|
||||||
|
- Non-member → 404 (deny-as-not-found, per RBAC-UX-002).
|
||||||
|
- No fine-grained capabilities for operations view in this spec.
|
||||||
|
|
||||||
|
### SR-078-002 — No sensitive leakage in context JSON
|
||||||
|
- Context JSON shown in UI uses allowlist-based redaction (existing behavior).
|
||||||
|
- The verification report section already sanitizes sensitive data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX Requirements
|
||||||
|
|
||||||
|
### UX-078-001 — Consistent CTA label
|
||||||
|
All run CTAs MUST use the canonical label per ux-contracts.md:
|
||||||
|
- **"View run"** (exact casing)
|
||||||
|
|
||||||
|
### UX-078-002 — Context navigation via related links
|
||||||
|
Canonical detail header MUST show an "Open" action group populated by `OperationRunLinks::related()`. This provides all relevant contextual links (Operations index, Provider Connection, Policies, Backup Sets, Restore Runs, Drift, Inventory, etc.) based on run type and context.
|
||||||
|
|
||||||
|
### UX-078-003 — Empty/missing target scope
|
||||||
|
If target scope is missing: show the existing non-blocking text "No target scope details were recorded for this run." Do not crash; degrade gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests (Mandatory)
|
||||||
|
|
||||||
|
### T-078-001 — Canonical run detail renders without tenant context
|
||||||
|
- Create run with `tenant_id = null` and with `tenant_id` set
|
||||||
|
- Assert `/admin/operations/{run}` renders summary sections without crash
|
||||||
|
|
||||||
|
### T-078-002 — Legacy tenant-scoped detail URLs return 404
|
||||||
|
- Any user visiting `/admin/t/{tenant}/operations/r/{record}` → 404 (route not registered)
|
||||||
|
- Any user visiting `/admin/operations/r/{record}` → 404 (route not registered)
|
||||||
|
|
||||||
|
### T-078-004 — No auto-generated tenant-scoped routes exist
|
||||||
|
- Assert route names `filament.admin.resources.operations.view` and `filament.admin.resources.operations.index` are not registered (or return 404)
|
||||||
|
|
||||||
|
### T-078-005 — No "Admin details" link on canonical detail
|
||||||
|
- Assert canonical detail page does not render `/admin/t/.../operations/r/...` links
|
||||||
|
|
||||||
|
### T-078-006 — KPI header hidden in tenantless mode
|
||||||
|
- On `/admin/operations` without tenant context, KPI header does not render (or renders gracefully)
|
||||||
|
|
||||||
|
### T-078-007 — DB-only rendering
|
||||||
|
- Rendering canonical detail does not dispatch jobs or perform HTTP calls (existing guard tests remain valid)
|
||||||
|
|
||||||
|
### T-078-008 — Verification report renders on tenantless detail
|
||||||
|
- Create run with verification report in context and `tenant_id` set
|
||||||
|
- Visit `/admin/operations/{run}` without `Filament::getTenant()` set
|
||||||
|
- Assert verification report section renders (badge, acknowledgements, change indicator with tenantless previous-run URL)
|
||||||
|
|
||||||
|
### T-078-009 — Tenant-scoped list redirect
|
||||||
|
- `/admin/t/{tenant}/operations` redirects (302) to `/admin/operations`
|
||||||
|
|
||||||
|
### T-078-010 — Related links appear on canonical detail
|
||||||
|
- Create run of type `restore.execute` with `restore_run_id` in context
|
||||||
|
- Assert header actions include "Restore Run" link
|
||||||
|
|
||||||
|
### T-078-011 — Operations list tenantless safety
|
||||||
|
- Visit `/admin/operations` with and without tenant context
|
||||||
|
- Assert list renders successfully and includes `tenant_id = null` runs without polling/query errors
|
||||||
|
|
||||||
|
### T-078-012 — Canonical CTA label consistency
|
||||||
|
- Assert run entry points use the exact label **"View run"**
|
||||||
|
- Assert canonical detail no longer shows the legacy "Admin details" CTA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- ✅ Exactly one canonical run detail URL: `/admin/operations/{run}`
|
||||||
|
- ✅ Auto-generated tenant-scoped routes (list + view) are removed; legacy detail URLs return 404
|
||||||
|
- ✅ No "Admin details" / tenant-scoped back-links from canonical pages
|
||||||
|
- ✅ Operations list works in workspace mode; tenant context only adds default filter
|
||||||
|
- ✅ Runs with `tenant_id = null` display safely
|
||||||
|
- ✅ Verification report renders correctly without tenant context
|
||||||
|
- ✅ Related contextual links (from `OperationRunLinks::related()`) replace the removed "Admin details" button
|
||||||
|
- ✅ Dead code (`OperationsDetail.php` + Blade view) removed
|
||||||
|
- ✅ Tests cover 404 semantics, infolist rendering, and canonical link rules
|
||||||
|
- ✅ Implementation is Filament v5 + Livewire v4 native only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes (Non-Normative)
|
||||||
|
|
||||||
|
### Infolist Reuse Strategy
|
||||||
|
|
||||||
|
Use Filament v5's native schema flow on the standalone page:
|
||||||
|
- Define `infolist(Schema $schema)` and delegate to `OperationRunResource::infolist($schema)`
|
||||||
|
- Define `defaultInfolist(Schema $schema)` with `->record($this->run)->columns(2)`
|
||||||
|
- Render with `{{ $this->infolist }}` (and use `EmbeddedSchema::make('infolist')` from `content()` when needed)
|
||||||
|
|
||||||
|
### Resource After Decommission
|
||||||
|
|
||||||
|
After removing both page entries from `getPages()`, `OperationRunResource` becomes a "headless" resource — it provides `table()` and `infolist()` schema builders reused by:
|
||||||
|
- `Operations.php` (custom list page, via `OperationRunResource::table($table)`)
|
||||||
|
- `TenantlessOperationRunViewer` (canonical detail, via infolist delegation)
|
||||||
|
|
||||||
|
The resource class itself is retained; only its auto-generated routes are eliminated.
|
||||||
|
|
||||||
|
### KPI Header Deferral
|
||||||
|
|
||||||
|
`OperationsKpiHeader` currently queries 6 times by `tenant_id` and uses `ActiveRuns::existForTenant()`. Full workspace-scoping requires adding `existForWorkspace()` to `ActiveRuns` and refactoring all 6 queries. This is deferred to a separate spec to keep this migration focused. Phase 1 simply hides the KPI header when tenant context is absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Decisions
|
||||||
|
|
||||||
|
- **"Copy JSON" capability-gating**: Recommended for enterprise environments but not in scope for this spec.
|
||||||
262
specs/078-operations-tenantless-canonical/tasks.md
Normal file
262
specs/078-operations-tenantless-canonical/tasks.md
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
# Tasks: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/078-operations-tenantless-canonical/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED (Pest). This feature changes runtime routing and rendering behavior.
|
||||||
|
**Operations**: No new operations introduced. Existing `OperationRun` model is read-only in this feature.
|
||||||
|
**RBAC**: Authorization unchanged — workspace membership enforced via `OperationRunPolicy::view()`. Non-member gets 404 (deny-as-not-found). No new capabilities, no destructive actions.
|
||||||
|
**Badges**: No badge changes. Existing `BadgeRenderer` reused via shared infolist schema.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story. US1 (P1) is the MVP. US2/US3 (P2) can proceed in parallel after foundational phase. US4 (P3) is independent.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4)
|
||||||
|
- Exact file paths included in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- Standard Laravel monolith: `app/`, `resources/`, `routes/`, `tests/`, `config/`
|
||||||
|
- New spec tests: `tests/Feature/078/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Create test directory and verify branch readiness
|
||||||
|
|
||||||
|
- [X] T001 Create spec test directory `tests/Feature/078/`
|
||||||
|
- [X] T002 Verify branch is clean and on `078-operations-tenantless-canonical`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Make `OperationRunResource` headless, delete decommissioned pages, delete dead code, and fix all existing tests that reference deleted classes/routes. This phase MUST complete before any user story work.
|
||||||
|
|
||||||
|
**Why blocking**: US1 needs ViewOperationRun deleted so infolist lives on standalone page. US2 needs routes removed. US3 needs the viewer to be the sole detail surface. All existing tests must pass after this phase.
|
||||||
|
|
||||||
|
- [X] T003 Change `getPages()` to return `[]` in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T004 [P] Delete `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php`
|
||||||
|
- [X] T005 [P] Delete `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php`
|
||||||
|
- [X] T006 [P] Delete `app/Livewire/Monitoring/OperationsDetail.php` (dead code)
|
||||||
|
- [X] T007 [P] Delete `resources/views/livewire/monitoring/operations-detail.blade.php` (dead code)
|
||||||
|
- [X] T008 Update `tests/Feature/Verification/VerificationAuthorizationTest.php` — replace `OperationRunResource::getUrl('view', ...)` with `route('admin.operations.view', ...)` and replace `ViewOperationRun` Livewire mount with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T009 [P] Update `tests/Feature/OpsUx/FailureSanitizationTest.php` — replace `OperationRunResource::getUrl('view', ...)` with canonical route; replace `ViewOperationRun` mount with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T010 [P] Update `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` — update guard regex to account for headless resource (no `getUrl` calls exist)
|
||||||
|
- [X] T011 [P] Update `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` — replace `ViewOperationRun` mount with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T012 [P] Update `tests/Feature/Verification/VerificationReportRedactionTest.php` + `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` — replace `ViewOperationRun` mounts with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T013 [P] Update `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` — remove `ListOperationRuns` test block; add route-not-registered assertion for `filament.admin.resources.operations.index`
|
||||||
|
- [X] T014 [P] Update `tests/Feature/Monitoring/OperationsTenantScopeTest.php` — remove `ListOperationRuns` reference
|
||||||
|
- [X] T015 Run `grep -r "ViewOperationRun\|ListOperationRuns" app/ tests/ resources/` to verify no stale references remain
|
||||||
|
- [X] T016 Run existing test suite for affected files: `vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/OpsUx/ tests/Feature/Monitoring/ tests/Feature/Operations/`
|
||||||
|
|
||||||
|
**Checkpoint**: All existing tests pass. Resource is headless. Dead code removed. No stale references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — View operation run via canonical URL (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: The canonical detail page at `/admin/operations/{run}` renders with full Filament infolist (summary, target scope, verification report, counts, context JSON) — replacing the current hand-coded Blade with `OperationRunResource::infolist()` reuse via the unified schema system.
|
||||||
|
|
||||||
|
**Independent Test**: Create runs with and without `tenant_id`, visit `/admin/operations/{run}`, assert all infolist sections render.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T017 [P] [US1] Write test T-078-001 (canonical detail renders with tenant_id) in `tests/Feature/078/CanonicalDetailRenderTest.php` — create OperationRun with tenant_id, visit canonical URL as workspace member, assert 200 + infolist sections visible (status badge, outcome, timestamps, target scope, summary counts)
|
||||||
|
- [X] T018 [P] [US1] Write test T-078-001 (canonical detail renders without tenant_id) in `tests/Feature/078/CanonicalDetailRenderTest.php` — create OperationRun with tenant_id=null, visit canonical URL, assert 200 + graceful rendering ("No target scope details")
|
||||||
|
- [X] T019 [P] [US1] Write test T-078-001 (non-member gets 404) in `tests/Feature/078/CanonicalDetailRenderTest.php` — visit as non-member, assert 404
|
||||||
|
- [X] T020 [P] [US1] Write test T-078-008 (verification report renders tenantless) in `tests/Feature/078/VerificationReportTenantlessTest.php` — create run with verification_report in context, visit canonical URL, assert verification section renders with badges and acknowledgements
|
||||||
|
- [X] T021 [P] [US1] Write test T-078-007 (DB-only rendering) in `tests/Feature/078/CanonicalDetailRenderTest.php` — assert canonical detail rendering does not dispatch jobs or HTTP calls
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T022 [US1] Add `public function infolist(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — delegates to `OperationRunResource::infolist($schema)`
|
||||||
|
- [X] T023 [US1] Add `public function defaultInfolist(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — sets `->record($this->run)->columns(2)`
|
||||||
|
- [X] T024 [US1] Add `public bool $opsUxIsTabHidden = false` property to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (required for polling callback in infolist)
|
||||||
|
- [X] T025 [US1] Add `public function content(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` returning `EmbeddedSchema::make('infolist')`
|
||||||
|
- [X] T026 [US1] Replace hand-coded HTML in `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` with infolist render (`{{ $this->infolist }}`)
|
||||||
|
- [X] T027 [US1] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/CanonicalDetailRenderTest.php tests/Feature/078/VerificationReportTenantlessTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Canonical detail at `/admin/operations/{run}` renders full Filament infolist. Runs with and without tenant_id render correctly. MVP is functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Verify that decommissioned tenant-scoped routes naturally return 404. No redirect handlers, no existence leakage.
|
||||||
|
|
||||||
|
**Independent Test**: Hit legacy detail URLs; assert 404 for all users.
|
||||||
|
|
||||||
|
**Note**: The actual route removal was done in Phase 2 (Foundational). This phase adds the spec-required tests that validate the behavior.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T028 [P] [US2] Write test T-078-002 (legacy detail URL returns 404) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — visit `/admin/t/{tenant}/operations/r/{record}` as any user, assert 404
|
||||||
|
- [X] T029 [P] [US2] Write test T-078-002 (slug variant returns 404) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — visit `/admin/operations/r/{record}`, assert 404
|
||||||
|
- [X] T030 [P] [US2] Write test T-078-004 (auto-generated route names not registered) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — assert `Route::has('filament.admin.resources.operations.view')` is false and `Route::has('filament.admin.resources.operations.index')` is false
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
> No additional implementation needed — routes were removed in Phase 2.
|
||||||
|
|
||||||
|
- [X] T031 [US2] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/LegacyRoutesReturnNotFoundTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: All legacy URLs return 404. Route names are not registered. No existence leakage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Contextual navigation from run detail (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Replace the "Admin details" button on canonical detail with `OperationRunLinks::related()` action group in header actions — providing richer contextual navigation (Operations index, Policy, Backup Set, Restore Run, etc.).
|
||||||
|
|
||||||
|
**Independent Test**: Create runs of different types, verify related links appear in header actions and "Admin details" link is absent.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T032 [P] [US3] Write test T-078-010 (related links appear) in `tests/Feature/078/RelatedLinksOnDetailTest.php` — create run of type `restore.execute` with `restore_run_id` in context, visit canonical detail, assert header actions include "Restore Run" link
|
||||||
|
- [X] T033 [P] [US3] Write test T-078-010 (generic links for tenantless run) in `tests/Feature/078/RelatedLinksOnDetailTest.php` — create run with tenant_id=null, assert only generic links (Operations index) appear
|
||||||
|
- [X] T034 [P] [US3] Write tests T-078-005 + T-078-012 in `tests/Feature/078/RelatedLinksOnDetailTest.php` — assert canonical detail does not render `/admin/t/.../operations/r/...` links and uses exact CTA label "View run" (legacy "Admin details" absent)
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T035 [US3] Add `getHeaderActions()` method to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — return actions from `OperationRunLinks::related($this->run, $this->run->tenant)`
|
||||||
|
- [X] T036 [US3] Remove "Admin details" button code (~line 61) from `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||||
|
- [X] T037 [US3] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/RelatedLinksOnDetailTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Header shows contextual "Open" action group. "Admin details" link is gone. Related links vary by run type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 — Operations list remains workspace-scoped (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Ensure no regression on `/admin/operations` list. KPI header hidden in tenantless mode. 302 redirect for decommissioned list URL.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping, KPI behavior, and redirect.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [X] T038 [P] [US4] Write test T-078-006 (KPI header hidden without tenant) in `tests/Feature/078/KpiHeaderTenantlessTest.php` — visit `/admin/operations` without tenant context, assert KPI stats are not rendered (empty array from `getStats()`)
|
||||||
|
- [X] T039 [P] [US4] Write test T-078-009 (tenant-scoped list redirect) in `tests/Feature/078/TenantListRedirectTest.php` — visit `/admin/t/{tenant}/operations`, assert 302 redirect to `/admin/operations`
|
||||||
|
- [X] T048 [P] [US4] Write test T-078-011 (tenantless list query safety) in `tests/Feature/078/OperationsListTenantlessSafetyTest.php` — visit `/admin/operations` with and without tenant context, assert runs including `tenant_id = null` render without errors
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T040 [US4] Add tenant-null guard in `getStats()` of `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — if `Filament::getTenant()` is null, return `[]`
|
||||||
|
- [X] T041 [US4] Add 302 redirect route in `routes/web.php` — `/admin/t/{tenant}/operations` redirects to `/admin/operations` (FR-078-012)
|
||||||
|
- [X] T042 [US4] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/KpiHeaderTenantlessTest.php tests/Feature/078/TenantListRedirectTest.php tests/Feature/078/OperationsListTenantlessSafetyTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Operations list works in workspace mode. KPI hidden without tenant. List redirect works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final validation, formatting, and stale reference sweep
|
||||||
|
|
||||||
|
- [X] T043 Run `grep -r "ViewOperationRun\|ListOperationRuns\|OperationsDetail" app/ tests/ resources/` — verify zero stale references across entire codebase
|
||||||
|
- [X] T044 Run `vendor/bin/sail bin pint --dirty` — fix any formatting issues
|
||||||
|
- [X] T049 Remove obsolete temporary layout `resources/views/filament/layouts/topbar-only.blade.php`
|
||||||
|
- [X] T045 Run focused test pack: `vendor/bin/sail artisan test --compact tests/Feature/078/ tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Verification/VerificationAuthorizationTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Feature/Verification/VerificationReportRedactionTest.php tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php tests/Feature/OpsUx/FailureSanitizationTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
|
||||||
|
- [X] T046 Run quickstart.md validation: `vendor/bin/sail artisan route:list --name=filament.admin.resources.operations` — assert empty output
|
||||||
|
- [X] T047 Ask user if they want to run the full test suite: `vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories
|
||||||
|
- **US1 (Phase 3)**: Depends on Phase 2 completion (resource must be headless first)
|
||||||
|
- **US2 (Phase 4)**: Depends on Phase 2 completion (routes must be removed)
|
||||||
|
- **US3 (Phase 5)**: Depends on Phase 3 completion (viewer must have infolist before adding header actions)
|
||||||
|
- **US4 (Phase 6)**: Depends on Phase 2 completion only (independent of US1/US2/US3)
|
||||||
|
- **Polish (Phase 7)**: Depends on all desired user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: After Phase 2 — no other story dependencies. **This is the MVP.**
|
||||||
|
- **US2 (P2)**: After Phase 2 — independent of US1 (tests only, implementation in Phase 2)
|
||||||
|
- **US3 (P2)**: After US1 (Phase 3) — needs infolist on viewer before adding header actions
|
||||||
|
- **US4 (P3)**: After Phase 2 — independent of US1/US2/US3
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests written FIRST, verified to FAIL before implementation
|
||||||
|
- Implementation follows test guidance
|
||||||
|
- Story checkpoint validates independently
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
After Phase 2 completes:
|
||||||
|
- **US1 and US2 and US4** can proceed in parallel (different files, no dependencies)
|
||||||
|
- US3 must wait for US1 (same file: TenantlessOperationRunViewer.php)
|
||||||
|
|
||||||
|
Within Phase 2:
|
||||||
|
- T004, T005, T006, T007 (file deletions) can all run in parallel
|
||||||
|
- T008-T014 (test file updates) can all run in parallel
|
||||||
|
|
||||||
|
Within Phase 3 (US1):
|
||||||
|
- T017-T021 (test writing) can all run in parallel
|
||||||
|
- T022-T026 (implementation) are sequential (same file)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: After Phase 2
|
||||||
|
|
||||||
|
```
|
||||||
|
# US1 (developer/agent A):
|
||||||
|
T017-T021: Write US1 tests (parallel)
|
||||||
|
T022-T026: Implement infolist reuse (sequential, same file)
|
||||||
|
T027: Run US1 tests
|
||||||
|
|
||||||
|
# US2 (developer/agent B — can run simultaneously):
|
||||||
|
T028-T030: Write US2 tests (parallel)
|
||||||
|
T031: Run US2 tests
|
||||||
|
|
||||||
|
# US4 (developer/agent C — can run simultaneously):
|
||||||
|
T038-T039: Write US4 tests (parallel)
|
||||||
|
T040-T041: Implement KPI guard + redirect
|
||||||
|
T042: Run US4 tests
|
||||||
|
|
||||||
|
# US3 (must wait for US1):
|
||||||
|
T032-T034: Write US3 tests (parallel)
|
||||||
|
T035-T036: Implement related links
|
||||||
|
T037: Run US3 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup
|
||||||
|
2. Complete Phase 2: Foundational (headless resource + dead code + test fixes)
|
||||||
|
3. Complete Phase 3: User Story 1 (infolist reuse on canonical detail)
|
||||||
|
4. **STOP and VALIDATE**: Canonical detail renders full infolist for all run types
|
||||||
|
5. This is deployable as MVP — core value delivered
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational -> Foundation ready (all existing tests pass)
|
||||||
|
2. Add US1 -> Canonical detail has full infolist (MVP!)
|
||||||
|
3. Add US2 -> Legacy URLs confirmed 404 (validation tests)
|
||||||
|
4. Add US3 -> Related links replace "Admin details" button
|
||||||
|
5. Add US4 -> KPI hidden + list redirect
|
||||||
|
6. Polish -> Full sweep, formatting, final validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All tests use Pest (not PHPUnit class syntax)
|
||||||
|
- Use existing `OperationRun` factory for test data setup
|
||||||
|
- `TenantlessOperationRunViewer` is the sole detail surface after migration
|
||||||
|
- `OperationRunResource` retained as headless utility (provides `::table()` and `::infolist()`)
|
||||||
|
- No new migrations, no new models, no new dependencies
|
||||||
|
- Total: 47 tasks across 7 phases
|
||||||
19
specs/079-inventory-links-non-uuid-ids/plan.md
Normal file
19
specs/079-inventory-links-non-uuid-ids/plan.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 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.
|
||||||
22
specs/079-inventory-links-non-uuid-ids/spec.md
Normal file
22
specs/079-inventory-links-non-uuid-ids/spec.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# 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.
|
||||||
18
specs/079-inventory-links-non-uuid-ids/tasks.md
Normal file
18
specs/079-inventory-links-non-uuid-ids/tasks.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 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`
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
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 spec’s 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: []
|
||||||
52
specs/080-workspace-managed-tenant-admin/contracts/routes.md
Normal file
52
specs/080-workspace-managed-tenant-admin/contracts/routes.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# 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.
|
||||||
71
specs/080-workspace-managed-tenant-admin/data-model.md
Normal file
71
specs/080-workspace-managed-tenant-admin/data-model.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# 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.
|
||||||
188
specs/080-workspace-managed-tenant-admin/plan.md
Normal file
188
specs/080-workspace-managed-tenant-admin/plan.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# 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`
|
||||||
36
specs/080-workspace-managed-tenant-admin/quickstart.md
Normal file
36
specs/080-workspace-managed-tenant-admin/quickstart.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
45
specs/080-workspace-managed-tenant-admin/research.md
Normal file
45
specs/080-workspace-managed-tenant-admin/research.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# 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: Filament’s 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 it’s 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).
|
||||||
238
specs/080-workspace-managed-tenant-admin/spec.md
Normal file
238
specs/080-workspace-managed-tenant-admin/spec.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# 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 I’m 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.
|
||||||
200
specs/080-workspace-managed-tenant-admin/tasks.md
Normal file
200
specs/080-workspace-managed-tenant-admin/tasks.md
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
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 US1–US5 completion for stable regression assertions (T036, T037).
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- After Phase 2 completes:
|
||||||
|
- US1 tests (T008–T009, T031) can be written in parallel with US1 implementation (T010–T012, T032).
|
||||||
|
- US2 (T013–T015) 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 (T001–T012)
|
||||||
|
- 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)
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
13
specs/081-provider-connection-cutover/contracts/README.md
Normal file
13
specs/081-provider-connection-cutover/contracts/README.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# 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.
|
||||||
139
specs/081-provider-connection-cutover/data-model.md
Normal file
139
specs/081-provider-connection-cutover/data-model.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# 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 repo’s 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 tenant’s 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.
|
||||||
|
|
||||||
139
specs/081-provider-connection-cutover/plan.md
Normal file
139
specs/081-provider-connection-cutover/plan.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# 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.
|
||||||
48
specs/081-provider-connection-cutover/quickstart.md
Normal file
48
specs/081-provider-connection-cutover/quickstart.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# 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`
|
||||||
102
specs/081-provider-connection-cutover/research.md
Normal file
102
specs/081-provider-connection-cutover/research.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
199
specs/081-provider-connection-cutover/spec.md
Normal file
199
specs/081-provider-connection-cutover/spec.md
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
# 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 operation’s 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.
|
||||||
146
specs/081-provider-connection-cutover/tasks.md
Normal file
146
specs/081-provider-connection-cutover/tasks.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# 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).
|
||||||
129
tests/Feature/078/CanonicalDetailRenderTest.php
Normal file
129
tests/Feature/078/CanonicalDetailRenderTest.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class CanonicalDetailRenderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_renders_canonical_detail_for_a_workspace_member_when_tenant_context_exists(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_name' => 'Contoso',
|
||||||
|
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 10,
|
||||||
|
'processed' => 10,
|
||||||
|
'succeeded' => 10,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Operation run')
|
||||||
|
->assertSee('Policy sync')
|
||||||
|
->assertSee('Counts')
|
||||||
|
->assertSee('Context')
|
||||||
|
->assertSee('Contoso');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_canonical_detail_gracefully_when_tenant_id_is_null(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No target scope details were recorded for this run.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_404_on_canonical_detail_for_non_members(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$otherUser] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_canonical_detail_db_only_with_no_job_dispatch(): void
|
||||||
|
{
|
||||||
|
Bus::fake();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => json_decode(
|
||||||
|
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($user, $run): void {
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification report');
|
||||||
|
});
|
||||||
|
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Feature/078/KpiHeaderTenantlessTest.php
Normal file
34
tests/Feature/078/KpiHeaderTenantlessTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class KpiHeaderTenantlessTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_hides_operations_kpi_stats_when_tenant_context_is_absent(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
OperationRun::factory()->count(3)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Total Runs (30 days)')
|
||||||
|
->assertDontSee('Failed/Partial (7 days)')
|
||||||
|
->assertDontSee('Avg Duration (7 days)');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/Feature/078/LegacyRoutesReturnNotFoundTest.php
Normal file
42
tests/Feature/078/LegacyRoutesReturnNotFoundTest.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class LegacyRoutesReturnNotFoundTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_returns_404_for_legacy_tenant_scoped_operation_detail_urls(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/t/'.$tenant->external_id.'/operations/r/'.$run->getKey())
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_404_for_the_admin_operations_r_record_legacy_slug_variant(): void
|
||||||
|
{
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/operations/r/123')
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_does_not_register_legacy_operation_resource_route_names(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Route::has('filament.admin.resources.operations.view'));
|
||||||
|
$this->assertFalse(Route::has('filament.admin.resources.operations.index'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user