diff --git a/.github/skills/platform-feature-finish/SKILL.md b/.github/skills/platform-feature-finish/SKILL.md new file mode 100644 index 00000000..a204a3b4 --- /dev/null +++ b/.github/skills/platform-feature-finish/SKILL.md @@ -0,0 +1,625 @@ + + +--- +name: platform-feature-finish +description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase. +--- + +# Skill: platform-feature-finish + +## Purpose + +Automate the TenantPilot platform feature completion workflow. + +Trigger this skill when the user says something like: + +- "alles committen pushen und PR gegen platform-dev" +- "feature fertig, bitte PR erstellen" +- "platform feature abschließen" +- "commit push PR mit Gitea MCP" +- "mach PR gegen platform-dev" +- "finish platform feature" +- "platform-dev nach dev vorbereiten" +- "platform-dev PR aktualisieren" +- "out-of-date mit dev beheben" +- "integration PR refresh" +- "platform-dev auf dev rebasen" + +This skill handles: + +1. Validate current Git branch +2. Commit all feature changes +3. Push current feature branch +4. Create a Gitea pull request into `platform-dev` +5. Refresh the `platform-dev` → `dev` integration PR when explicitly requested +6. Report the PR link and next integration step + +--- + +## Branch Model + +TenantPilot uses area branches: + +```text +dev = shared integration branch +platform-dev = platform/application area integration branch +website-dev = website/marketing area integration branch +``` + +For platform features: + +```text +platform-dev + ↓ +feature branch + ↓ +PR back to platform-dev + ↓ +platform-dev → dev integration PR +``` + +Rules: + +- Platform feature branches MUST target `platform-dev`. +- Do NOT target `dev` directly unless the user explicitly asks. +- Do NOT use `website-dev` for platform features. +- `platform-dev` is the default PR base for TenantPilot platform/application work. +- `dev` is the shared integration branch. + +### Solo Workflow Rule + +The user works alone on `platform-dev`. + +For refreshing the integration branch before opening or updating the PR `platform-dev` → `dev`, prefer rebase over merge. + +Do not repeatedly merge `origin/dev` into `platform-dev` for refresh. + +Avoid creating repeated merge commits like: + +```text +Merge remote-tracking branch 'origin/dev' into platform-dev +``` + +Use `--force-with-lease`, never plain `--force`. + +If rebase conflicts occur, stop and report the conflict files. + +--- + +## Preconditions + +Before committing: + +1. Confirm repository root. +2. Confirm current branch is not protected. + +Protected branches: + +```text +dev +platform-dev +website-dev +main +master +``` + +If the current branch is protected, STOP and report: + +```text +Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken. +``` + +3. Confirm remote exists. +4. Confirm there are local changes, untracked files, or unpushed commits. +5. Confirm there are no unresolved conflicts. + +Do not ask for confirmation unless: + +- The current branch is protected. +- Git status indicates unresolved conflicts. +- There is no remote configured. +- `.env` or other local secret/config files would be committed. +- Commit fails. +- Push fails. +- Gitea MCP PR creation fails. + +--- + +## Required Tools + +Use terminal for Git operations. + +Use Gitea MCP for pull request creation. + +Preferred Gitea MCP operation: + +```text +create_pull_request +``` + +Required PR parameters: + +```json +{ + "owner": "ahmido", + "repo": "TenantAtlas", + "head": "", + "base": "platform-dev", + "title": "", + "body": "" +} +``` + +--- + +## Workflow + +### Step 1 — Inspect Git state + +Run: + +```bash +git rev-parse --show-toplevel +git rev-parse --abbrev-ref HEAD +git status --porcelain +git status -sb +git config --get remote.origin.url +git log --oneline --max-count=5 +``` + +Determine: + +- repository root +- current branch +- changed files +- untracked files +- remote URL +- whether there are unpushed commits +- whether unresolved conflicts exist + +If the current branch is protected, stop. + +If unresolved conflicts exist, stop. + +If no remote exists, stop. + +--- + +### Step 2 — Check for local environment files + +Before `git add -A`, check whether local environment/config files are modified or untracked: + +```bash +git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true +``` + +If `.env` or another environment file is included, STOP and report: + +```text +Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen. +``` + +Do not commit secrets or local runtime configuration. + +--- + +### Step 3 — Build commit message + +Use the current branch name. + +If branch starts with a spec number, for example: + +```text +256-external-support-desk-handoff +``` + +Generate: + +```text +feat(specs/256): external support desk handoff +``` + +If branch does not contain a spec number, generate: + +```text +feat(platform): complete +``` + +Rules: + +- Use lowercase subject. +- Use feature-style subject. +- Do not include `WIP`. +- Do not include `final`. +- Do not include overly generic `updates`. + +Examples: + +```text +feat(specs/256): external support desk handoff +feat(specs/252): platform localization v1 +feat(platform): improve tenant review workspace +``` + +--- + +### Step 4 — Commit all changes + +Run: + +```bash +git add -A +git commit -m "" +``` + +If there are no local changes to commit, continue only if the branch has unpushed commits. + +Check unpushed commits with: + +```bash +git status -sb +git log --oneline origin/..HEAD +``` + +If there are no local changes and no unpushed commits, report: + +```text +Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit. +``` + +Then continue to PR creation only if the branch already exists remotely or can be pushed. + +--- + +### Step 5 — Push branch + +Run: + +```bash +git push --set-upstream origin +``` + +If the upstream already exists, this is acceptable. + +Never force-push unless the user explicitly requests it. + +--- + +### Step 6 — Create PR into platform-dev via Gitea MCP + +Use Gitea MCP to create a pull request: + +```json +{ + "owner": "ahmido", + "repo": "TenantAtlas", + "head": "", + "base": "platform-dev", + "title": "", + "body": "Implements platform feature branch ``.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev` → `dev`." +} +``` + +If a PR already exists for the same branch and base, do not create a duplicate. + +Report the existing PR if available. + +--- + +## Optional Step — Check platform-dev to dev PR + +After creating the feature PR, check whether an open integration PR exists: + +```text +platform-dev → dev +``` + +If a Gitea MCP list/search pull request function is available, use it. + +If one exists, report: + +```text +Der Folge-PR `platform-dev` → `dev` existiert bereits: +``` + +If none exists, report: + +```text +Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev` → `dev` erstellt oder aktualisiert werden. +``` + +Do not automatically create the `platform-dev` → `dev` PR unless the user explicitly asks for it. + +Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet. + +--- + +## Integration Refresh Mode + +Use this mode when the user explicitly says one of the following: + +- "platform-dev nach dev vorbereiten" +- "platform-dev PR aktualisieren" +- "out-of-date mit dev beheben" +- "integration PR refresh" +- "platform-dev auf dev rebasen" +- "auch platform-dev nach dev" +- "und danach platform-dev nach dev" +- "full integration" +- "kompletten platform-dev zu dev PR machen" +- "folge-pr erstellen" + +This mode prepares or updates the integration PR: + +```text +platform-dev → dev +``` + +Because the user works alone on `platform-dev`, prefer rebase over merge. + +### Integration Refresh Preconditions + +Before running this mode: + +1. Ensure the working tree is clean. +2. Ensure there are no unresolved conflicts. +3. Fetch remote branches. +4. Ensure `origin/platform-dev` exists. +5. Ensure `origin/dev` exists. + +If the working tree is dirty, STOP and report: + +```text +Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird. +``` + +If unresolved conflicts exist, STOP and report the conflict files. + +### Integration Refresh Workflow + +Run: + +```bash +git fetch origin +git checkout platform-dev +git reset --hard origin/platform-dev +git rebase origin/dev +git push --force-with-lease origin platform-dev +``` + +After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`: + +```bash +git fetch origin +git merge-base --is-ancestor origin/dev origin/platform-dev \ + && echo "OK: platform-dev contains dev" \ + || echo "OUTDATED: platform-dev does not contain dev" +``` + +If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date. + +Rules: + +- Do not merge `origin/dev` into `platform-dev` for this refresh. +- Do not create repeated merge commits from `origin/dev` into `platform-dev`. +- Use `git push --force-with-lease origin platform-dev` after a successful rebase. +- Never use plain `git push --force`. +- If `git rebase origin/dev` reports conflicts, stop immediately. +- Do not continue to PR creation while a rebase is unresolved. +- Do not auto-merge the PR. +- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds. + +If rebase conflicts occur, report: + +```text +Rebase-Konflikte erkannt. Ich habe gestoppt. + +Konfliktdateien: + + +Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen. +``` + +### Create or Report Integration PR + +After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR: + +```json +{ + "owner": "ahmido", + "repo": "TenantAtlas", + "head": "platform-dev", + "base": "dev", + "title": "chore(platform): merge platform-dev into dev", + "body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically." +} +``` + +If an open PR already exists for `platform-dev` → `dev`, do not create a duplicate. Report the existing PR. + +### Integration Refresh Reporting Format + +Final response for this mode must include: + +```text +Fertig. + +- Branch aktualisiert: platform-dev +- Refresh-Methode: rebase auf origin/dev +- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev +- Push: --force-with-lease origin/platform-dev +- Integration PR: +- Base: dev +- Hinweis: PR wurde nicht automatisch gemerged. +``` + +Do not claim tests passed unless they were actually executed. + +--- + +## Reporting Format + +Final response must be concise and include: + +```text +Fertig. + +- Branch: +- Commit: +- Push: origin/ +- PR: +- Base: platform-dev +- Nächster Schritt: Nach Merge `platform-dev` → `dev` PR aktualisieren/erstellen +``` + +If tests were not run, say: + +```text +Tests wurden in diesem Skill nicht automatisch ausgeführt. +``` + +Do not claim tests passed unless the tool actually ran them. + +--- + +## Safety Rules + +- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`. +- Never force-push unless explicitly requested. +- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`. +- Never auto-merge PRs unless explicitly requested. +- Never target `dev` directly for platform feature PRs unless explicitly requested. +- Never delete branches unless explicitly requested. +- Never claim tests were run unless the tool actually ran them. +- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes. +- If migrations were created, mention that the target environment needs migration execution after deployment. +- If unresolved conflicts exist, stop. + +--- + +## Useful Commands + +Inspect: + +```bash +git rev-parse --show-toplevel +git rev-parse --abbrev-ref HEAD +git status --porcelain +git status -sb +git config --get remote.origin.url +``` + +Detect protected branch: + +```bash +branch="$(git rev-parse --abbrev-ref HEAD)" +case "$branch" in + dev|platform-dev|website-dev|main|master) + echo "PROTECTED_BRANCH:$branch" + exit 2 + ;; +esac +``` + +Detect unresolved conflicts: + +```bash +git diff --name-only --diff-filter=U +``` + +Detect `.env` changes: + +```bash +git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true +``` + +Commit: + +```bash +git add -A +git commit -m "" +``` + +Push: + +```bash +git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)" +``` + +Latest commit: + +```bash +git rev-parse --short HEAD +git log -1 --pretty=%s +``` + +Integration refresh: + +```bash +git fetch origin +git checkout platform-dev +git reset --hard origin/platform-dev +git rebase origin/dev +git push --force-with-lease origin platform-dev +``` + +Verify integration refresh: + +```bash +git fetch origin +git merge-base --is-ancestor origin/dev origin/platform-dev \ + && echo "OK: platform-dev contains dev" \ + || echo "OUTDATED: platform-dev does not contain dev" +``` + +Check rebase conflicts: + +```bash +git diff --name-only --diff-filter=U +``` + +--- + +## Example User Request + +User: + +```text +alles committen pushen und pr gegen platform-dev mit gitea mcp +``` + +Assistant should: + +1. Check current branch. +2. Stop if branch is protected. +3. Stop if `.env` or secrets would be committed. +4. Commit all changes. +5. Push current branch. +6. Create PR into `platform-dev` with Gitea MCP. +7. Report result. + +Do not ask unnecessary follow-up questions. + +--- + +## Example Integration Refresh Request + +User: + +```text +platform-dev PR aktualisieren +``` + +Assistant should: + +1. Ensure the working tree is clean. +2. Fetch origin. +3. Checkout `platform-dev`. +4. Reset local `platform-dev` to `origin/platform-dev`. +5. Rebase `platform-dev` onto `origin/dev`. +6. Push with `--force-with-lease`. +7. Verify `origin/dev` is an ancestor of `origin/platform-dev`. +8. Create or report the PR `platform-dev` → `dev`. +9. Report result. + +Do not merge the PR automatically. \ No newline at end of file diff --git a/apps/platform/app/Filament/Pages/CrossTenantComparePage.php b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php new file mode 100644 index 00000000..5de3ac21 --- /dev/null +++ b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php @@ -0,0 +1,674 @@ + + */ + public array $selectedPolicyTypes = []; + + /** + * @var array|null + */ + public ?array $navigationContextPayload = null; + + /** + * @var array|null + */ + public ?array $preview = null; + + /** + * @var array|null + */ + public ?array $preflight = null; + + public ?string $selectionMessage = null; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.'); + } + + public function mount(): void + { + $this->authorizePageAccess(); + + $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; + + $this->hydrateSelectionFromRequest(); + $this->refreshPreview(); + + $this->form->fill($this->formState()); + } + + public function form(Schema $schema): Schema + { + return $schema + ->schema([ + Grid::make([ + 'default' => 1, + 'xl' => 3, + ]) + ->schema([ + Select::make('sourceTenantId') + ->label('Source tenant') + ->options(fn (): array => $this->tenantOptions()) + ->searchable() + ->preload() + ->native(false) + ->placeholder('Select a source tenant') + ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source']) + ->extraInputAttributes(['data-testid' => 'cross-tenant-source']), + Select::make('targetTenantId') + ->label('Target tenant') + ->options(fn (): array => $this->tenantOptions()) + ->searchable() + ->preload() + ->native(false) + ->placeholder('Select a target tenant') + ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target']) + ->extraInputAttributes(['data-testid' => 'cross-tenant-target']), + Select::make('selectedPolicyTypes') + ->label('Governed subjects') + ->options(fn (): array => $this->policyTypeOptions()) + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->placeholder('All governed subjects') + ->helperText(fn (): ?string => $this->policyTypeOptions() === [] + ? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.' + : null) + ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types']) + ->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']), + ]), + ]); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $actions = []; + + $navigationContext = $this->navigationContext(); + + if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { + $actions[] = Action::make('return_to_origin') + ->label($navigationContext->backLinkLabel) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url($navigationContext->backLinkUrl); + } + + $sourceTenant = $this->selectedSourceTenant(); + + if ($sourceTenant instanceof Tenant) { + $actions[] = Action::make('open_source_tenant') + ->label('Open source tenant') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin')); + } + + $targetTenant = $this->selectedTargetTenant(); + + if ($targetTenant instanceof Tenant) { + $actions[] = Action::make('open_target_tenant') + ->label('Open target tenant') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin')); + } + + $preflightAction = Action::make('generatePromotionPreflight') + ->label('Generate promotion preflight') + ->icon('heroicon-o-sparkles') + ->color('primary') + ->disabled(fn (): bool => $this->preflightDisabledReason() !== null) + ->tooltip(fn (): ?string => $this->preflightDisabledReason()) + ->action(fn (): mixed => $this->generatePromotionPreflight()); + + $preflightAction = WorkspaceUiEnforcement::forAction( + $preflightAction, + fn (): ?Workspace => $this->workspace(), + ) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveDisabled() + ->tooltip('You need workspace baseline manage access to generate a promotion preflight.') + ->apply() + ->tooltip(function (): ?string { + $user = auth()->user(); + $workspace = $this->workspace(); + + if ($user instanceof User && $workspace instanceof Workspace) { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if ($resolver->isMember($user, $workspace) + && ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + return 'You need workspace baseline manage access to generate a promotion preflight.'; + } + } + + return $this->preflightDisabledReason(); + }); + + $actions[] = $preflightAction; + + return $actions; + } + + public function applySelection(): void + { + $this->selectionMessage = null; + $this->preflight = null; + + $this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId); + $this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId); + $this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes); + + if ($this->sourceTenantId !== null + && $this->targetTenantId !== null + && $this->sourceTenantId === $this->targetTenantId) { + $this->selectionMessage = 'Choose two different tenants.'; + $this->addError('targetTenantId', $this->selectionMessage); + + return; + } + + $this->redirect($this->selectionUrl(), navigate: true); + } + + public function generatePromotionPreflight(): void + { + $this->authorizePageAccess(); + $this->authorizePreflightExecution(); + + if ($this->preview === null) { + $this->refreshPreview(); + } + + if ($this->preview === null) { + return; + } + + $selection = $this->compareSelection(); + + if (! $selection instanceof CrossTenantCompareSelection) { + return; + } + + $this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview); + + $workspace = $this->workspace(); + $user = auth()->user(); + + if ($workspace instanceof Workspace && $user instanceof User) { + app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated( + workspace: $workspace, + sourceTenant: $selection->sourceTenant, + targetTenant: $selection->targetTenant, + preflight: $this->preflight, + actor: $user, + ); + } + } + + public function clearSelectionUrl(): string + { + return static::getUrl($this->routeParameters([ + self::SOURCE_TENANT_QUERY_KEY => null, + self::TARGET_TENANT_QUERY_KEY => null, + self::POLICY_TYPE_QUERY_KEY => null, + ]), panel: 'admin'); + } + + public function selectionUrl(): string + { + return static::getUrl($this->routeParameters(), panel: 'admin'); + } + + public static function launchUrl( + ?Tenant $sourceTenant = null, + ?Tenant $targetTenant = null, + ?CanonicalNavigationContext $navigationContext = null, + ): string { + $parameters = []; + + if ($sourceTenant instanceof Tenant) { + $parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey(); + } + + if ($targetTenant instanceof Tenant) { + $parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey(); + } + + if ($navigationContext instanceof CanonicalNavigationContext) { + $parameters = array_replace($parameters, $navigationContext->toQuery()); + } + + return static::getUrl($parameters, panel: 'admin'); + } + + public function hasActiveSelection(): bool + { + return $this->sourceTenantId !== null + || $this->targetTenantId !== null + || $this->selectedPolicyTypes !== []; + } + + public function stateColor(string $state): string + { + return match ($state) { + 'match', 'ready' => 'success', + 'different', 'manual_mapping_required' => 'warning', + 'missing' => 'info', + 'ambiguous' => 'gray', + 'blocked' => 'danger', + default => 'gray', + }; + } + + public function stateLabel(string $value): string + { + return Str::headline(str_replace('_', ' ', $value)); + } + + public function reasonLabel(string $reasonCode): string + { + return Str::headline(str_replace('_', ' ', $reasonCode)); + } + + public function sourceTenantUrl(): ?string + { + $tenant = $this->selectedSourceTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + } + + public function targetTenantUrl(): ?string + { + $tenant = $this->selectedTargetTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + } + + /** + * @return array + */ + private function formState(): array + { + return [ + 'sourceTenantId' => $this->sourceTenantId, + 'targetTenantId' => $this->targetTenantId, + 'selectedPolicyTypes' => $this->selectedPolicyTypes, + ]; + } + + private function hydrateSelectionFromRequest(): void + { + $this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY)); + $this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY)); + $this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, [])); + } + + private function refreshPreview(): void + { + $this->selectionMessage = null; + $this->preview = null; + $this->preflight = null; + + $selection = $this->compareSelection(); + + if (! $selection instanceof CrossTenantCompareSelection) { + return; + } + + $this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection); + } + + private function authorizePageAccess(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + abort(404); + } + + if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + abort(403); + } + } + + private function authorizePreflightExecution(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + abort(404); + } + + if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + abort(403); + } + } + + private function compareSelection(): ?CrossTenantCompareSelection + { + $sourceTenant = $this->selectedSourceTenant(); + $targetTenant = $this->selectedTargetTenant(); + + if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) { + return null; + } + + if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) { + $this->selectionMessage = 'Choose two different tenants.'; + + return null; + } + + return new CrossTenantCompareSelection( + sourceTenant: $sourceTenant, + targetTenant: $targetTenant, + policyTypes: $this->selectedPolicyTypes, + ); + } + + private function selectedSourceTenant(): ?Tenant + { + if ($this->sourceTenantId === null) { + return null; + } + + return $this->resolveAuthorizedTenant($this->sourceTenantId); + } + + private function selectedTargetTenant(): ?Tenant + { + if ($this->targetTenantId === null) { + return null; + } + + return $this->resolveAuthorizedTenant($this->targetTenantId); + } + + private function resolveAuthorizedTenant(string $tenantId): Tenant + { + $workspace = $this->workspace(); + $user = auth()->user(); + + if (! $workspace instanceof Workspace || ! $user instanceof User) { + abort(404); + } + + $tenant = Tenant::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->whereKey((int) $tenantId) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { + abort(404); + } + + return $tenant; + } + + /** + * @return array + */ + private function tenantOptions(): array + { + $workspace = $this->workspace(); + $user = auth()->user(); + + if (! $workspace instanceof Workspace || ! $user instanceof User) { + return []; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + $tenants = $user->tenants() + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->select('tenants.*') + ->orderBy('tenants.name') + ->get(); + + $resolver->primeMemberships($user, $tenants->modelKeys()); + + return $tenants + ->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) + ->mapWithKeys(fn (Tenant $tenant): array => [ + (string) $tenant->getKey() => (string) $tenant->name, + ]) + ->all(); + } + + /** + * @return array + */ + private function policyTypeOptions(): array + { + $tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions())); + + if ($tenantIds === []) { + return []; + } + + return InventoryItem::query() + ->whereIn('tenant_id', $tenantIds) + ->whereNotNull('policy_type') + ->where('policy_type', '!=', '') + ->distinct() + ->orderBy('policy_type') + ->pluck('policy_type') + ->mapWithKeys(fn (string $policyType): array => [ + $policyType => Str::headline($policyType), + ]) + ->all(); + } + + private function preflightDisabledReason(): ?string + { + if ($this->selectionMessage !== null) { + return $this->selectionMessage; + } + + if (! is_array($this->preview)) { + return 'Select an authorized source and target tenant to generate a promotion preflight.'; + } + + if ((int) data_get($this->preview, 'summary.total', 0) === 0) { + return 'No governed subjects are available for this compare selection yet.'; + } + + return null; + } + + /** + * @param mixed $value + */ + private function normalizeTenantIdentifier(mixed $value): ?string + { + if (! is_string($value) && ! is_int($value)) { + return null; + } + + $normalized = trim((string) $value); + + return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null; + } + + /** + * @param mixed $value + * @return list + */ + private function normalizePolicyTypes(mixed $value): array + { + $allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true); + + $values = match (true) { + is_string($value) && $value !== '' => [$value], + is_array($value) => $value, + default => [], + }; + + return array_values(array_filter(array_unique(array_map( + static fn (mixed $item): string => is_string($item) ? trim($item) : '', + $values, + )), static fn (string $item): bool => $item !== '' && isset($allowed[$item]))); + } + + /** + * @param array $overrides + * @return array + */ + private function routeParameters(array $overrides = []): array + { + $parameters = [ + self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId, + self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId, + self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes, + ]; + + if (is_array($this->navigationContextPayload)) { + $parameters['nav'] = $this->navigationContextPayload; + } + + foreach ($overrides as $key => $value) { + $parameters[$key] = $value; + } + + return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); + } + + private function navigationContext(): ?CanonicalNavigationContext + { + if (! is_array($this->navigationContextPayload)) { + return CanonicalNavigationContext::fromRequest(request()); + } + + return CanonicalNavigationContext::fromPayload($this->navigationContextPayload); + } + + private function workspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + return $workspace instanceof Workspace ? $workspace : null; + } +} \ No newline at end of file diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index 8971e621..53963a29 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Pages\CrossTenantComparePage; use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\RelationManagers; @@ -15,9 +16,11 @@ use App\Models\TenantOnboardingSession; use App\Models\TenantTriageReview; use App\Models\User; +use App\Models\Workspace; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Graph\GraphClientInterface; @@ -44,6 +47,7 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\Rbac\UiEnforcement; @@ -68,6 +72,7 @@ use Filament\Actions; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -824,6 +829,27 @@ public static function table(Table $table): Table ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), + Actions\Action::make('compareTenants') + ->label('Compare tenants') + ->icon('heroicon-o-scale') + ->color('gray') + ->url(function (Tenant $record, mixed $livewire): string { + $triageState = $livewire instanceof Pages\ListTenants + ? static::currentPortfolioTriageState($livewire) + : []; + + if (! static::hasActivePortfolioTriageState( + static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), + static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), + static::sanitizeReviewStates($triageState['review_state'] ?? []), + static::sanitizeTriageSort($triageState['triage_sort'] ?? null), + )) { + $triageState = static::portfolioReturnFiltersFromRequest(request()->query()); + } + + return static::crossTenantCompareOpenUrl($record, $triageState); + }) + ->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)), UiEnforcement::forAction( Actions\Action::make('edit') ->label('Edit') @@ -966,6 +992,34 @@ public static function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ + Actions\BulkAction::make('compareSelected') + ->label('Compare selected') + ->icon('heroicon-o-scale') + ->color('gray') + ->visible(fn (): bool => auth()->user() instanceof User) + ->authorize(fn (): bool => auth()->user() instanceof User) + ->extraAttributes(fn (mixed $livewire): array => [ + 'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null', + 'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire), + 'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire), + 'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }', + ]) + ->action(function (Collection $records, mixed $livewire): void { + $disabledReason = static::crossTenantCompareBulkDisabledReason($records); + + if ($disabledReason !== null) { + Notification::make() + ->title($disabledReason) + ->danger() + ->send(); + + return; + } + + if (method_exists($livewire, 'redirect')) { + $livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true); + } + }), Actions\BulkAction::make('syncSelected') ->label('Sync selected') ->icon('heroicon-o-arrow-path') @@ -1158,6 +1212,52 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState ); } + /** + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + */ + public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string + { + return static::crossTenantCompareOpenUrlForSelection( + targetTenant: $record, + triageState: $triageState, + ); + } + + /** + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + */ + public static function crossTenantCompareOpenUrlForSelection( + Tenant $targetTenant, + array $triageState = [], + ?Tenant $sourceTenant = null, + ): string { + $normalizedState = static::portfolioReturnFilters( + static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), + static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), + static::sanitizeReviewStates($triageState['review_state'] ?? []), + static::sanitizeTriageSort($triageState['triage_sort'] ?? null), + ); + + return CrossTenantComparePage::launchUrl( + sourceTenant: $sourceTenant, + targetTenant: $targetTenant, + navigationContext: CanonicalNavigationContext::forTenantRegistry( + backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState), + tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(), + ), + ); + } + /** * @param array{ * backup_posture?: list, @@ -1248,6 +1348,168 @@ private static function portfolioReturnFiltersFromRequest(array $query): array ); } + private static function crossTenantCompareActionVisible(Tenant $record): bool + { + if (! $record->isActive()) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $workspaceResolver */ + $workspaceResolver = app(WorkspaceCapabilityResolver::class); + + if (! $workspaceResolver->isMember($user, $workspace) + || ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + return false; + } + + /** @var CapabilityResolver $tenantResolver */ + $tenantResolver = app(CapabilityResolver::class); + + return $user->canAccessTenant($record) + && $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW); + } + + private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string + { + $user = auth()->user(); + + if (! $user instanceof User) { + return UiTooltips::insufficientPermission(); + } + + $tenants = $records + ->filter(fn ($record): bool => $record instanceof Tenant) + ->values(); + + if ($records->count() !== 2 || $tenants->count() !== 2) { + return 'Select exactly two tenants to compare.'; + } + + if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) { + return 'Only active tenants can be compared.'; + } + + $workspaceIds = $tenants + ->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id) + ->unique() + ->values(); + + if ($workspaceIds->count() !== 1) { + return UiTooltips::insufficientPermission(); + } + + $workspaceId = $workspaceIds->first(); + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return UiTooltips::insufficientPermission(); + } + + /** @var WorkspaceCapabilityResolver $workspaceResolver */ + $workspaceResolver = app(WorkspaceCapabilityResolver::class); + + if (! $workspaceResolver->isMember($user, $workspace) + || ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + return UiTooltips::insufficientPermission(); + } + + /** @var CapabilityResolver $tenantResolver */ + $tenantResolver = app(CapabilityResolver::class); + + $isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant) + || ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW)); + + return $isDenied ? UiTooltips::insufficientPermission() : null; + } + + private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string + { + $containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire); + + return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}"; + } + + private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string + { + $containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire); + + return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)"; + } + + private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string + { + $inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire)); + + return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))"; + } + + /** + * @return list + */ + private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array + { + if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) { + return []; + } + + $tableRecords = $livewire->getTableRecords(); + + if (method_exists($tableRecords, 'getCollection')) { + $tableRecords = $tableRecords->getCollection(); + } + + return collect($tableRecords) + ->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive()) + ->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant)) + ->values() + ->all(); + } + + private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string + { + $triageState = $livewire instanceof Pages\ListTenants + ? static::currentPortfolioTriageState($livewire) + : []; + + if (! static::hasActivePortfolioTriageState( + static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), + static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), + static::sanitizeReviewStates($triageState['review_state'] ?? []), + static::sanitizeTriageSort($triageState['triage_sort'] ?? null), + )) { + $triageState = static::portfolioReturnFiltersFromRequest(request()->query()); + } + + $tenants = $records + ->filter(fn ($record): bool => $record instanceof Tenant) + ->values(); + + return static::crossTenantCompareOpenUrlForSelection( + targetTenant: $tenants->get(1), + triageState: $triageState, + sourceTenant: $tenants->get(0), + ); + } + private static function hasActivePortfolioTriageState( array $backupPostures, array $recoveryEvidence, diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 602bfe4f..3f29f388 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -5,6 +5,7 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\CrossTenantComparePage; use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Governance\GovernanceInbox; @@ -181,6 +182,7 @@ public function panel(Panel $panel): Panel InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, + CrossTenantComparePage::class, GovernanceInbox::class, FindingsHygieneReport::class, FindingsIntakeQueue::class, diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index 51b14aef..208fe080 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -139,6 +139,43 @@ public function logSupportDiagnosticsOpened( ); } + /** + * @param array $preflight + */ + public function logCrossTenantPromotionPreflightGenerated( + Workspace $workspace, + Tenant $sourceTenant, + Tenant $targetTenant, + array $preflight, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + $summary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : []; + + return $this->log( + workspace: $workspace, + action: AuditActionId::CrossTenantPromotionPreflightGenerated, + context: [ + 'source_tenant_id' => (int) $sourceTenant->getKey(), + 'source_tenant_name' => (string) $sourceTenant->name, + 'target_tenant_id' => (int) $targetTenant->getKey(), + 'target_tenant_name' => (string) $targetTenant->name, + 'ready_count' => (int) ($summary['ready'] ?? 0), + 'blocked_count' => (int) ($summary['blocked'] ?? 0), + 'manual_mapping_required_count' => (int) ($summary['manual_mapping_required'] ?? 0), + 'total_count' => (int) ($summary['total'] ?? 0), + 'blocked_reason_counts' => is_array($preflight['blockedReasonCounts'] ?? null) + ? $preflight['blockedReasonCounts'] + : [], + ], + actor: $actor, + status: 'success', + resourceType: 'cross_tenant_promotion_preflight', + resourceId: sprintf('%s:%s', $sourceTenant->getKey(), $targetTenant->getKey()), + targetLabel: $sourceTenant->name.' -> '.$targetTenant->name, + summary: 'Cross-tenant promotion preflight generated for '.$sourceTenant->name.' -> '.$targetTenant->name, + ); + } + public function logSupportRequestCreated( SupportRequest $supportRequest, User|PlatformUser|null $actor = null, diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 5dd9bd45..efdbe3fb 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -69,6 +69,7 @@ enum AuditActionId: string case BaselineCompareStarted = 'baseline_compare.started'; case BaselineCompareCompleted = 'baseline_compare.completed'; case BaselineCompareFailed = 'baseline_compare.failed'; + case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated'; case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; @@ -218,6 +219,7 @@ private static function labels(): array self::BaselineCompareStarted->value => 'Baseline compare started', self::BaselineCompareCompleted->value => 'Baseline compare completed', self::BaselineCompareFailed->value => 'Baseline compare failed', + self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', self::BaselineAssignmentCreated->value => 'Baseline assignment created', self::BaselineAssignmentUpdated->value => 'Baseline assignment updated', self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted', @@ -312,6 +314,7 @@ private static function summaries(): array self::BaselineProfileUpdated->value => 'Baseline profile updated', self::BaselineProfileArchived->value => 'Baseline profile archived', self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled', + self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', self::AlertDestinationCreated->value => 'Alert destination created', self::AlertDestinationUpdated->value => 'Alert destination updated', self::AlertDestinationDeleted->value => 'Alert destination deleted', diff --git a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php index 66b33c15..333a1f76 100644 --- a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php +++ b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php @@ -5,8 +5,10 @@ namespace App\Support\Navigation; use App\Filament\Pages\BaselineCompareMatrix; +use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Models\BaselineProfile; use App\Models\Tenant; +use Filament\Facades\Filament; use Illuminate\Http\Request; final readonly class CanonicalNavigationContext @@ -82,6 +84,17 @@ public static function forGovernanceInbox( ); } + public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self + { + return new self( + sourceSurface: 'tenant_registry', + canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')), + tenantId: $tenantId, + backLinkLabel: 'Back to tenant registry', + backLinkUrl: $backLinkUrl, + ); + } + /** * @return array */ diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php new file mode 100644 index 00000000..6232faa7 --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php @@ -0,0 +1,416 @@ + + * }, + * summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int}, + * subjects: list> + * } + */ + public function build(CrossTenantCompareSelection $selection): array + { + $sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes); + $targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes); + + $sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']); + $targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $targetIndex['evidence_subjects']); + + $subjects = []; + $summary = [ + 'match' => 0, + 'different' => 0, + 'missing' => 0, + 'ambiguous' => 0, + 'blocked' => 0, + 'total' => 0, + ]; + + foreach ($sourceIndex['preview_subjects'] as $sourceSubject) { + $previewSubject = $this->buildPreviewSubject( + sourceSubject: $sourceSubject, + sourceTenant: $selection->sourceTenant, + targetTenant: $selection->targetTenant, + targetIndex: $targetIndex['subjects'], + sourceEvidence: $sourceEvidence, + targetEvidence: $targetEvidence, + ); + + $subjects[] = $previewSubject; + $summary[$previewSubject['state']]++; + $summary['total']++; + } + + usort($subjects, function (array $left, array $right): int { + $policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? '')); + + if ($policyTypeComparison !== 0) { + return $policyTypeComparison; + } + + $displayNameComparison = strcmp( + Str::lower((string) ($left['displayName'] ?? '')), + Str::lower((string) ($right['displayName'] ?? '')), + ); + + if ($displayNameComparison !== 0) { + return $displayNameComparison; + } + + return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? '')); + }); + + return [ + 'selection' => [ + 'workspaceId' => $selection->workspaceId(), + 'sourceTenantId' => $selection->sourceTenantId(), + 'sourceTenantName' => (string) $selection->sourceTenant->name, + 'targetTenantId' => $selection->targetTenantId(), + 'targetTenantName' => (string) $selection->targetTenant->name, + 'policyTypes' => $selection->policyTypes, + ], + 'summary' => $summary, + 'subjects' => $subjects, + ]; + } + + /** + * @param Tenant $tenant + * @param list $policyTypes + * @return array{ + * preview_subjects: list>, + * evidence_subjects: list, + * subjects: array> + * } + */ + private function indexTenantSubjects(Tenant $tenant, array $policyTypes): array + { + $inventoryItems = InventoryItem::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->when( + $policyTypes !== [], + fn ($query) => $query->whereIn('policy_type', $policyTypes), + ) + ->orderBy('policy_type') + ->orderBy('display_name') + ->orderBy('id') + ->get(); + + $subjects = []; + $previewSubjects = []; + $evidenceSubjects = []; + + foreach ($inventoryItems as $inventoryItem) { + if (! $inventoryItem instanceof InventoryItem) { + continue; + } + + $policyType = trim((string) $inventoryItem->policy_type); + $subjectKey = BaselineSubjectKey::forPolicy( + $policyType, + is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null, + is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null, + ); + + $subjectRecord = $this->inventorySubjectRecord($tenant, $inventoryItem, $policyType, $subjectKey); + + if ($subjectKey === null) { + $previewSubjects[] = [ + ...$subjectRecord, + 'resolution' => 'identifier_missing', + 'duplicateCount' => 1, + ]; + + continue; + } + + $indexKey = $this->subjectIndexKey($policyType, $subjectKey); + + if (! array_key_exists($indexKey, $subjects)) { + $subjects[$indexKey] = [ + 'policyType' => $policyType, + 'subjectKey' => $subjectKey, + 'displayName' => $subjectRecord['displayName'], + 'items' => [], + ]; + } + + $subjects[$indexKey]['items'][] = $subjectRecord; + } + + foreach ($subjects as $indexKey => $subjectGroup) { + $items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : []; + $firstItem = $items[0] ?? null; + + if (! is_array($firstItem)) { + continue; + } + + $previewSubjects[] = [ + ...$firstItem, + 'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved', + 'duplicateCount' => count($items), + ]; + + if (count($items) === 1) { + $evidenceSubjects[] = [ + 'policy_type' => (string) $firstItem['policyType'], + 'subject_external_id' => (string) $firstItem['subjectExternalId'], + ]; + } + + $subjects[$indexKey]['representative'] = $firstItem; + $subjects[$indexKey]['duplicateCount'] = count($items); + } + + return [ + 'preview_subjects' => $previewSubjects, + 'evidence_subjects' => $evidenceSubjects, + 'subjects' => $subjects, + ]; + } + + /** + * @param array> $targetIndex + * @param array $sourceEvidence + * @param array $targetEvidence + * @return array + */ + private function buildPreviewSubject( + array $sourceSubject, + Tenant $sourceTenant, + Tenant $targetTenant, + array $targetIndex, + array $sourceEvidence, + array $targetEvidence, + ): array { + $policyType = (string) ($sourceSubject['policyType'] ?? ''); + $displayName = (string) ($sourceSubject['displayName'] ?? ''); + $subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null; + $reasonCodes = []; + $state = 'blocked'; + $trustLevel = 'unusable'; + + $sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject); + $targetEvidenceRecord = null; + $targetSubject = null; + + if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') { + $reasonCodes[] = 'source_identifier_missing'; + } elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') { + $state = 'ambiguous'; + $trustLevel = 'diagnostic_only'; + $reasonCodes[] = 'source_subject_ambiguous'; + } elseif ($subjectKey !== null) { + $targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null; + + if (! is_array($targetSubject)) { + $state = 'missing'; + $trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence + && $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent + ? 'trustworthy' + : 'limited_confidence'; + $reasonCodes[] = 'target_subject_missing'; + } elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) { + $state = 'ambiguous'; + $trustLevel = 'diagnostic_only'; + $reasonCodes[] = 'target_subject_ambiguous'; + } else { + $representative = $targetSubject['representative'] ?? null; + + if (is_array($representative)) { + $targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative); + } + + if (! $sourceEvidenceRecord instanceof ResolvedEvidence) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + if (! $targetEvidenceRecord instanceof ResolvedEvidence) { + $reasonCodes[] = 'target_evidence_refresh_required'; + } + + if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) { + $state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different'; + $trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent + && $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent + ? 'trustworthy' + : 'limited_confidence'; + + if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) { + $reasonCodes[] = 'target_evidence_refresh_required'; + } + } else { + $state = 'blocked'; + $trustLevel = 'unusable'; + } + } + } + + if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + if ($state === 'blocked' && $reasonCodes === []) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + $reasonCodes = array_values(array_unique($reasonCodes)); + + return [ + 'policyType' => $policyType, + 'displayName' => $displayName, + 'subjectKey' => $subjectKey, + 'state' => $state, + 'trustLevel' => $trustLevel, + 'reasonCodes' => $reasonCodes, + 'source' => $this->subjectSidePayload($sourceTenant, $sourceSubject, $sourceEvidenceRecord), + 'target' => $this->subjectSidePayload( + $targetTenant, + is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null, + $targetEvidenceRecord, + ), + ]; + } + + /** + * @param list $subjects + * @return array + */ + private function resolvedEvidenceMap(Tenant $tenant, array $subjects): array + { + return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects); + } + + /** + * @param array|null $subject + * @return array + */ + private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array + { + return [ + 'tenantId' => (int) $tenant->getKey(), + 'tenantName' => (string) $tenant->name, + 'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null, + 'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null, + 'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null, + 'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null, + 'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null, + 'evidence' => $this->evidencePayload($evidence), + ]; + } + + /** + * @return array{ + * policyType: string, + * displayName: string, + * subjectKey: ?string, + * inventoryItemId: int, + * subjectExternalId: string, + * lastSeenAt: ?string + * } + */ + private function inventorySubjectRecord(Tenant $tenant, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array + { + $displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : ''; + $displayName = $displayName !== '' + ? $displayName + : ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType)); + + return [ + 'tenantId' => (int) $tenant->getKey(), + 'policyType' => $policyType, + 'displayName' => $displayName, + 'subjectKey' => $subjectKey, + 'inventoryItemId' => (int) $inventoryItem->getKey(), + 'subjectExternalId' => (string) $inventoryItem->external_id, + 'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(), + ]; + } + + /** + * @param array $evidenceMap + */ + private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence + { + $policyType = trim((string) ($subject['policyType'] ?? '')); + $subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? '')); + + if ($policyType === '' || $subjectExternalId === '') { + return null; + } + + $key = $policyType.'|'.$subjectExternalId; + $evidence = $evidenceMap[$key] ?? null; + + return $evidence instanceof ResolvedEvidence ? $evidence : null; + } + + /** + * @return array{ + * hash: string, + * fidelity: string, + * source: string, + * observedAt: ?string, + * policyVersionId: ?int, + * operationRunId: ?int, + * capturePurpose: ?string + * }|null + */ + private function evidencePayload(?ResolvedEvidence $evidence): ?array + { + if (! $evidence instanceof ResolvedEvidence) { + return null; + } + + return [ + 'hash' => $evidence->hash, + 'fidelity' => $evidence->fidelity, + 'source' => $evidence->source, + 'observedAt' => $evidence->observedAt?->toIso8601String(), + 'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null) + ? (int) $evidence->meta['policy_version_id'] + : null, + 'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null) + ? (int) $evidence->meta['operation_run_id'] + : null, + 'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null) + ? (string) $evidence->meta['capture_purpose'] + : null, + ]; + } + + private function subjectIndexKey(string $policyType, string $subjectKey): string + { + return $policyType.'|'.$subjectKey; + } +} diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php new file mode 100644 index 00000000..46a59b5a --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php @@ -0,0 +1,83 @@ + + */ + public array $policyTypes; + + /** + * @param list $policyTypes + */ + public function __construct( + Tenant $sourceTenant, + Tenant $targetTenant, + array $policyTypes = [], + ) { + $this->sourceTenant = $sourceTenant; + $this->targetTenant = $targetTenant; + + if ((int) $this->sourceTenant->getKey() === (int) $this->targetTenant->getKey()) { + throw new InvalidArgumentException('Source and target tenants must differ.'); + } + + if ((int) $this->sourceTenant->workspace_id !== (int) $this->targetTenant->workspace_id) { + throw new InvalidArgumentException('Source and target tenants must belong to the same workspace.'); + } + + $this->policyTypes = $this->normalizePolicyTypes($policyTypes); + } + + public function workspaceId(): int + { + return (int) $this->sourceTenant->workspace_id; + } + + public function sourceTenantId(): int + { + return (int) $this->sourceTenant->getKey(); + } + + public function targetTenantId(): int + { + return (int) $this->targetTenant->getKey(); + } + + public function hasPolicyTypeFilter(): bool + { + return $this->policyTypes !== []; + } + + /** + * @param list $policyTypes + * @return list + */ + private function normalizePolicyTypes(array $policyTypes): array + { + $normalized = array_values(array_unique(array_filter(array_map(static function (mixed $policyType): ?string { + if (! is_string($policyType)) { + return null; + } + + $normalizedPolicyType = trim($policyType); + + return $normalizedPolicyType !== '' ? $normalizedPolicyType : null; + }, $policyTypes)))); + + sort($normalized, SORT_STRING); + + return $normalized; + } +} diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php new file mode 100644 index 00000000..c1c5c466 --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php @@ -0,0 +1,143 @@ +, + * subjects?: list> + * } $preview + * @return array{ + * selection: array, + * summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int}, + * blockedReasonCounts: array, + * buckets: array{ + * ready: list>, + * blocked: list>, + * manual_mapping_required: list> + * } + * } + */ + public function build(array $preview): array + { + $subjects = is_array($preview['subjects'] ?? null) ? $preview['subjects'] : []; + $buckets = [ + 'ready' => [], + 'blocked' => [], + 'manual_mapping_required' => [], + ]; + $blockedReasonCounts = []; + + foreach ($subjects as $subject) { + if (! is_array($subject)) { + continue; + } + + $decision = $this->classifySubject($subject); + $subject['preflight'] = $decision; + $buckets[$decision['bucket']][] = $subject; + + if ($decision['bucket'] !== 'ready') { + foreach ($decision['reasonCodes'] as $reasonCode) { + $blockedReasonCounts[$reasonCode] = ($blockedReasonCounts[$reasonCode] ?? 0) + 1; + } + } + } + + return [ + 'selection' => is_array($preview['selection'] ?? null) ? $preview['selection'] : [], + 'summary' => [ + 'ready' => count($buckets['ready']), + 'blocked' => count($buckets['blocked']), + 'manual_mapping_required' => count($buckets['manual_mapping_required']), + 'total' => count($buckets['ready']) + count($buckets['blocked']) + count($buckets['manual_mapping_required']), + ], + 'blockedReasonCounts' => $blockedReasonCounts, + 'buckets' => $buckets, + ]; + } + + /** + * @param array $subject + * @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list, reasonLabels: list} + */ + private function classifySubject(array $subject): array + { + $state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked'; + $reasonCodes = is_array($subject['reasonCodes'] ?? null) + ? array_values(array_filter($subject['reasonCodes'], 'is_string')) + : []; + + if (in_array('source_identifier_missing', $reasonCodes, true)) { + return $this->decision('blocked', ['source_identifier_missing']); + } + + if (in_array('source_subject_ambiguous', $reasonCodes, true)) { + return $this->decision('blocked', ['source_subject_ambiguous']); + } + + if (in_array('target_subject_ambiguous', $reasonCodes, true) || $state === 'ambiguous') { + return $this->decision('manual_mapping_required', ['target_subject_ambiguous']); + } + + $sourceEvidence = is_array(data_get($subject, 'source.evidence')) ? data_get($subject, 'source.evidence') : null; + $targetEvidence = is_array(data_get($subject, 'target.evidence')) ? data_get($subject, 'target.evidence') : null; + + if (! $this->evidenceSupportsPromotion($sourceEvidence)) { + return $this->decision('blocked', ['source_evidence_refresh_required']); + } + + if ($state !== 'missing' && ! $this->evidenceSupportsPromotion($targetEvidence)) { + return $this->decision('blocked', ['target_evidence_refresh_required']); + } + + return match ($state) { + 'match' => $this->decision('ready', ['target_already_aligned']), + 'different' => $this->decision('ready', ['target_subject_requires_update']), + 'missing' => $this->decision('ready', ['target_subject_missing']), + default => $this->decision('blocked', ['source_evidence_refresh_required']), + }; + } + + /** + * @param array|null $evidence + */ + private function evidenceSupportsPromotion(?array $evidence): bool + { + return is_array($evidence) + && is_string($evidence['fidelity'] ?? null) + && (string) $evidence['fidelity'] === 'content'; + } + + /** + * @param list $reasonCodes + * @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list, reasonLabels: list} + */ + private function decision(string $bucket, array $reasonCodes): array + { + return [ + 'bucket' => $bucket, + 'reasonCodes' => $reasonCodes, + 'reasonLabels' => array_map(fn (string $reasonCode): string => $this->reasonLabel($reasonCode), $reasonCodes), + ]; + } + + private function reasonLabel(string $reasonCode): string + { + return match ($reasonCode) { + 'source_identifier_missing' => 'Source tenant subject is missing a stable compare identifier.', + 'source_subject_ambiguous' => 'Source tenant subject resolves to multiple candidates and cannot drive promotion safely.', + 'target_subject_ambiguous' => 'Target tenant has multiple matching subjects and needs manual mapping.', + 'source_evidence_refresh_required' => 'Refresh source evidence before relying on this subject.', + 'target_evidence_refresh_required' => 'Refresh target evidence before relying on this subject.', + 'target_already_aligned' => 'Target tenant already matches the source for this subject.', + 'target_subject_requires_update' => 'Target tenant differs from the source but has enough evidence for later promotion planning.', + 'target_subject_missing' => 'Target tenant does not currently contain this subject and can be planned as a missing target item.', + default => 'This subject needs additional review before promotion planning can continue.', + }; + } +} diff --git a/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php b/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php new file mode 100644 index 00000000..bfe60b0e --- /dev/null +++ b/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php @@ -0,0 +1,208 @@ + + @php + $preview = is_array($preview ?? null) ? $preview : null; + $preflight = is_array($preflight ?? null) ? $preflight : null; + $previewSummary = is_array($preview['summary'] ?? null) ? $preview['summary'] : []; + $preflightSummary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : []; + $blockedReasonCounts = is_array($preflight['blockedReasonCounts'] ?? null) ? $preflight['blockedReasonCounts'] : []; + $sourceTenantName = data_get($preview, 'selection.sourceTenantName'); + $targetTenantName = data_get($preview, 'selection.targetTenantName'); + $selectedPolicyTypes = data_get($preview, 'selection.policyTypes', []); + @endphp + + + + Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only. + + +
+
+
+ {{ $this->form }} + +
+
+
Shareable compare scope
+

+ Source tenant, target tenant, and governed-subject filters live on the URL so the same compare preview can be reopened or shared. +

+
+ +
+ + Run compare preview + + + @if ($this->hasActiveSelection()) + + Clear selection + + @endif +
+
+
+
+ + @if (filled($selectionMessage)) +
+ {{ $selectionMessage }} +
+ @endif + + @if ($preview === null) +
+ Choose a source tenant and a target tenant to build a compare preview. The source and target must be different tenants inside the active workspace. +
+ @endif +
+
+ + @if ($preview !== null) + + + Decision-first summary of governed subjects. Raw payloads stay on the existing tenant and baseline surfaces. + + +
+
+
+
+ Source tenant: {{ $sourceTenantName }} + Target tenant: {{ $targetTenantName }} + @foreach ($selectedPolicyTypes as $policyType) + {{ $this->stateLabel($policyType) }} + @endforeach +
+ +
+ @if (filled($this->sourceTenantUrl())) + + Open source tenant + + @endif + + @if (filled($this->targetTenantUrl())) + + Open target tenant + + @endif +
+ +

+ The preview groups governed subjects into reproducible compare states so you can decide whether the target is aligned, missing, blocked, or needs manual review. +

+
+ +
+ @foreach (['match', 'different', 'missing', 'ambiguous', 'blocked', 'total'] as $state) +
+
{{ $this->stateLabel($state) }}
+
{{ (int) ($previewSummary[$state] ?? 0) }}
+
+ @endforeach +
+
+ +
+
+
Governed subject
+
Reasoning
+
Compare state
+
+ +
+ @foreach (data_get($preview, 'subjects', []) as $subject) +
+
+
{{ data_get($subject, 'displayName') }}
+
+ {{ $this->stateLabel((string) data_get($subject, 'policyType', 'unknown')) }} + @if (filled(data_get($subject, 'subjectKey'))) + {{ data_get($subject, 'subjectKey') }} + @endif +
+
+ +
+ @forelse (data_get($subject, 'reasonCodes', []) as $reasonCode) + {{ $this->reasonLabel((string) $reasonCode) }} + @empty + No blocking reason. + @endforelse +
+ +
+ + {{ $this->stateLabel((string) data_get($subject, 'state', 'unknown')) }} + +
+
+ @endforeach +
+
+
+
+ @endif + + @if ($preflight !== null) + + + Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice. + + +
+
+ @foreach (['ready', 'blocked', 'manual_mapping_required', 'total'] as $state) +
+
{{ $this->stateLabel($state) }}
+
{{ (int) ($preflightSummary[$state] ?? 0) }}
+
+ @endforeach +
+ + @if ($blockedReasonCounts !== []) +
+
Top blocked reasons
+
+ @foreach ($blockedReasonCounts as $reasonCode => $count) + {{ $this->reasonLabel((string) $reasonCode) }}: {{ (int) $count }} + @endforeach +
+
+ @endif + +
+ @foreach (['ready', 'manual_mapping_required', 'blocked'] as $bucket) +
+
+
{{ $this->stateLabel($bucket) }}
+ + {{ count(data_get($preflight, 'buckets.'.$bucket, [])) }} + +
+ +
+ @forelse (data_get($preflight, 'buckets.'.$bucket, []) as $subject) +
+
{{ data_get($subject, 'displayName') }}
+ @if (data_get($subject, 'preflight.reasonLabels', []) !== []) +
+ @foreach (data_get($subject, 'preflight.reasonLabels', []) as $reasonLabel) + {{ $reasonLabel }} + @endforeach +
+ @endif +
+ @empty +
No governed subjects in this bucket.
+ @endforelse +
+
+ @endforeach +
+
+
+ @endif + + +
\ No newline at end of file diff --git a/apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php b/apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php new file mode 100644 index 00000000..1945c41f --- /dev/null +++ b/apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php @@ -0,0 +1,119 @@ +create([ + 'name' => 'Source Tenant', + ]); + + [$user, $sourceTenant] = createUserWithTenant( + tenant: $sourceTenant, + role: $tenantRole, + workspaceRole: $workspaceRole, + ); + + $workspace = Workspace::query()->findOrFail((int) $sourceTenant->workspace_id); + + $targetTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Target Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $targetTenant->getKey() => ['role' => $tenantRole], + ]); + + app(CapabilityResolver::class)->clearCache(); + app(WorkspaceCapabilityResolver::class)->clearCache(); + + return [ + 'user' => $user, + 'workspace' => $workspace, + 'sourceTenant' => $sourceTenant, + 'targetTenant' => $targetTenant, + ]; + } + + /** + * @return array{0: string, 1: array} + */ + protected function setAdminWorkspaceContext(User $user, Workspace $workspace): array + { + $this->actingAs($user); + + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + return [WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]; + } + + /** + * @param array $snapshot + * @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem} + */ + protected function createPortfolioCompareSubject( + Tenant $tenant, + string $displayName, + array $snapshot, + string $policyType = 'deviceConfiguration', + ?string $externalId = null, + ): array { + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(), + 'display_name' => $displayName, + 'platform' => 'windows', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => $policyType, + 'platform' => 'windows', + 'captured_at' => now(), + 'snapshot' => $snapshot, + 'assignments' => [], + 'scope_tags' => [], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => (string) $policy->external_id, + 'display_name' => $displayName, + 'last_seen_at' => now(), + ]); + + return [ + 'policy' => $policy, + 'version' => $version, + 'inventory' => $inventory, + ]; + } +} \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php new file mode 100644 index 00000000..b9847d95 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php @@ -0,0 +1,97 @@ +create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(CrossTenantComparePage::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for workspace members missing baseline view capability on the compare route', function (): void { + $workspace = Workspace::factory()->create(); + $viewer = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $viewer->getKey(), + 'role' => 'readonly', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + $this->actingAs($viewer) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(CrossTenantComparePage::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('returns 404 when the requested target tenant is outside the actor scope', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + $hiddenTarget = Tenant::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'name' => 'Hidden Target', + ]); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $hiddenTarget->getKey(), + ], panel: 'admin')) + ->assertNotFound(); +}); + +it('keeps promotion preflight visible but disabled for readonly members and forbids forced execution', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'readonly', tenantRole: 'readonly'); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Readonly Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Readonly Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->assertActionVisible('generatePromotionPreflight') + ->assertActionDisabled('generatePromotionPreflight') + ->assertActionExists('generatePromotionPreflight', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to generate a promotion preflight.') + ->call('generatePromotionPreflight') + ->assertForbidden(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php new file mode 100644 index 00000000..f51742b5 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php @@ -0,0 +1,188 @@ +makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE); + $this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet); + + $triageState = $this->portfolioReturnFilters( + [TenantBackupHealthAssessment::POSTURE_STALE], + [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED], + [], + TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ); + + $expectedUrl = TenantResource::crossTenantCompareOpenUrl($targetTenant, $triageState); + + $this->portfolioTriageRegistryList($user, $anchorTenant, $triageState) + ->assertTableActionVisible('compareTenants', $targetTenant) + ->assertTableActionHasUrl('compareTenants', $expectedUrl, $targetTenant); + + $query = crossTenantCompareLaunchQuery($expectedUrl); + $backUrl = urldecode((string) data_get($query, 'nav.back_url')); + + expect($query)->toMatchArray([ + 'target_tenant_id' => (string) $targetTenant->getKey(), + ]) + ->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry') + ->and(data_get($query, 'nav.tenant_id'))->toBe((string) $targetTenant->getKey()) + ->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry') + ->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE) + ->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED) + ->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST); + + Livewire::withQueryParams($query) + ->actingAs($user) + ->test(CrossTenantComparePage::class) + ->assertSet('sourceTenantId', null) + ->assertSet('targetTenantId', (string) $targetTenant->getKey()) + ->assertActionVisible('return_to_origin') + ->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry' + && $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState)); +}); + +it('launches cross-tenant compare from an exact-two bulk selection with both tenants prefilled', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $anchorBackupSet = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_STALE); + $this->seedPortfolioRecoveryConcern($anchorTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $anchorBackupSet); + + $backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE); + $this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet); + + $triageState = $this->portfolioReturnFilters( + [TenantBackupHealthAssessment::POSTURE_STALE], + [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED], + [], + TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ); + + $expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection( + targetTenant: $targetTenant, + triageState: $triageState, + sourceTenant: $anchorTenant, + ); + + $this->portfolioTriageRegistryList($user, $anchorTenant, $triageState) + ->selectTableRecords([$anchorTenant, $targetTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant]) + ->assertRedirect($expectedUrl); + + $query = crossTenantCompareLaunchQuery($expectedUrl); + $backUrl = urldecode((string) data_get($query, 'nav.back_url')); + + expect($query)->toMatchArray([ + 'source_tenant_id' => (string) $anchorTenant->getKey(), + 'target_tenant_id' => (string) $targetTenant->getKey(), + ]) + ->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry') + ->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry') + ->and(data_get($query, 'nav.tenant_id'))->toBeNull() + ->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE) + ->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED) + ->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST); + + Livewire::withQueryParams($query) + ->actingAs($user) + ->test(CrossTenantComparePage::class) + ->assertSet('sourceTenantId', (string) $anchorTenant->getKey()) + ->assertSet('targetTenantId', (string) $targetTenant->getKey()) + ->assertActionVisible('return_to_origin'); +}); + +it('rejects the bulk compare action until exactly two active tenants are selected', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + $thirdTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Third Tenant'); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->selectTableRecords([$anchorTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant]) + ->assertNotified('Select exactly two tenants to compare.'); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->selectTableRecords([$anchorTenant, $targetTenant, $thirdTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant, $thirdTenant]) + ->assertNotified('Select exactly two tenants to compare.'); +}); + +it('rejects the bulk compare action when a selected tenant is not active', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $onboardingTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Onboarding Tenant'); + + $onboardingTenant->forceFill([ + 'status' => Tenant::STATUS_ONBOARDING, + ])->save(); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->selectTableRecords([$anchorTenant, $onboardingTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant, $onboardingTenant]) + ->assertNotified('Only active tenants can be compared.'); +}); + +it('hides the compare launch action when workspace baseline view capability is missing', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->assertTableActionHidden('compareTenants', $targetTenant); +}); + +it('hides the compare launch action when the actor lacks tenant view on the launched tenant', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $resolver = \Mockery::mock(CapabilityResolver::class); + $resolver->shouldReceive('primeMemberships')->andReturnNull(); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnUsing(function (mixed $actor, mixed $tenant, string $capability) use ($targetTenant): bool { + if ($tenant instanceof Tenant + && (int) $tenant->getKey() === (int) $targetTenant->getKey() + && $capability === Capabilities::TENANT_VIEW) { + return false; + } + + return true; + }); + app()->instance(CapabilityResolver::class, $resolver); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->assertTableActionHidden('compareTenants', $targetTenant); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php new file mode 100644 index 00000000..ddd5b709 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php @@ -0,0 +1,82 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'WiFi Corp', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'WiFi Corp', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]], + ); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: $query, panel: 'admin')) + ->assertOk() + ->assertSee('Cross-tenant compare') + ->assertSee('Compare preview') + ->assertSee('WiFi Corp') + ->assertSee('Windows Compliance') + ->assertSee('Source tenant: '.$fixture['sourceTenant']->name) + ->assertSee('Target tenant: '.$fixture['targetTenant']->name) + ->assertSee(TenantResource::getUrl('view', ['record' => $fixture['sourceTenant']], panel: 'admin'), false) + ->assertSee(TenantResource::getUrl('view', ['record' => $fixture['targetTenant']], panel: 'admin'), false); + + Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->assertActionVisible('generatePromotionPreflight') + ->assertActionEnabled('generatePromotionPreflight') + ->call('generatePromotionPreflight') + ->assertHasNoErrors() + ->assertSee('Promotion preflight') + ->assertSee('WiFi Corp') + ->assertSee('Windows Compliance'); +}); + +it('rejects the same tenant as source and target without rendering compare results', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + ], panel: 'admin')) + ->assertOk() + ->assertSee('Choose two different tenants.') + ->assertDontSee('data-testid="cross-tenant-compare-preview"', false) + ->assertDontSee('Promotion preflight'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php new file mode 100644 index 00000000..87b85cb3 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php @@ -0,0 +1,62 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Audit Policy', + snapshot: ['settings' => [['key' => 'audit', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Audit Policy', + snapshot: ['settings' => [['key' => 'audit', 'value' => 2]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $operationRunCount = OperationRun::query()->count(); + $policyVersionCount = PolicyVersion::query()->count(); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->assertHasNoErrors(); + + $audit = AuditLog::query() + ->where('workspace_id', (int) $fixture['workspace']->getKey()) + ->where('action', AuditActionId::CrossTenantPromotionPreflightGenerated->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->status)->toBe('success') + ->and($audit?->resource_type)->toBe('cross_tenant_promotion_preflight') + ->and(data_get($audit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey()) + ->and(data_get($audit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey()) + ->and(data_get($audit?->metadata, 'ready_count'))->toBe(1) + ->and(data_get($audit?->metadata, 'blocked_count'))->toBe(0) + ->and(data_get($audit?->metadata, 'manual_mapping_required_count'))->toBe(0); + + expect(OperationRun::query()->count())->toBe($operationRunCount) + ->and(PolicyVersion::query()->count())->toBe($policyVersionCount); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php new file mode 100644 index 00000000..e764eee0 --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php @@ -0,0 +1,192 @@ + [['key' => 'wifi', 'value' => 1]]], + ); + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'WiFi Corp', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + + createComparedPolicy( + tenant: $fixture['sourceTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]], + ); + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]], + ); + + createComparedPolicy( + tenant: $fixture['sourceTenant'], + displayName: 'VPN Profile', + snapshot: ['settings' => [['key' => 'vpn', 'value' => 1]]], + ); + + $selection = new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + ); + + $builder = app(CrossTenantComparePreviewBuilder::class); + $preview = $builder->build($selection); + + expect($preview['summary'])->toBe([ + 'match' => 1, + 'different' => 1, + 'missing' => 1, + 'ambiguous' => 0, + 'blocked' => 0, + 'total' => 3, + ]); + + $subjects = collect($preview['subjects'])->keyBy('displayName'); + + expect($subjects->get('WiFi Corp'))->not->toBeNull() + ->and($subjects->get('WiFi Corp')['state'])->toBe('match') + ->and(data_get($subjects->get('WiFi Corp'), 'source.evidence.fidelity'))->toBe('content') + ->and(data_get($subjects->get('WiFi Corp'), 'target.evidence.fidelity'))->toBe('content') + ->and($subjects->get('Windows Compliance')['state'])->toBe('different') + ->and($subjects->get('VPN Profile')['state'])->toBe('missing'); + + expect($builder->build($selection))->toBe($preview); +}); + +it('marks unresolved source identity and duplicate target matches distinctly', function (): void { + $fixture = crossTenantCompareFixture(); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => ' ', + 'external_id' => 'source-without-identifier', + ]); + + createComparedPolicy( + tenant: $fixture['sourceTenant'], + displayName: 'Duplicated Policy', + snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]], + ); + + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'Duplicated Policy', + externalId: 'dup-target-1', + snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]], + ); + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'Duplicated Policy', + externalId: 'dup-target-2', + snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]], + ); + + $preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + )); + + expect($preview['summary'])->toBe([ + 'match' => 0, + 'different' => 0, + 'missing' => 0, + 'ambiguous' => 1, + 'blocked' => 1, + 'total' => 2, + ]); + + $identifierGap = collect($preview['subjects']) + ->first(fn (array $subject): bool => in_array('source_identifier_missing', $subject['reasonCodes'], true)); + $ambiguousTarget = collect($preview['subjects']) + ->first(fn (array $subject): bool => in_array('target_subject_ambiguous', $subject['reasonCodes'], true)); + + expect($identifierGap)->toBeArray() + ->and($identifierGap['state'])->toBe('blocked') + ->and($identifierGap['trustLevel'])->toBe('unusable') + ->and($ambiguousTarget)->toBeArray() + ->and($ambiguousTarget['state'])->toBe('ambiguous') + ->and($ambiguousTarget['trustLevel'])->toBe('diagnostic_only'); +}); + +/** + * @return array{sourceTenant: Tenant, targetTenant: Tenant} + */ +function crossTenantCompareFixture(): array +{ + $sourceTenant = Tenant::factory()->create(); + $targetTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + ]); + + return [ + 'sourceTenant' => $sourceTenant, + 'targetTenant' => $targetTenant, + ]; +} + +/** + * @param array $snapshot + * @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem} + */ +function createComparedPolicy( + Tenant $tenant, + string $displayName, + array $snapshot, + string $policyType = 'deviceConfiguration', + ?string $externalId = null, +): array { + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(), + 'display_name' => $displayName, + 'platform' => 'windows', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => $policyType, + 'platform' => 'windows', + 'captured_at' => now(), + 'snapshot' => $snapshot, + 'assignments' => [], + 'scope_tags' => [], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => (string) $policy->external_id, + 'display_name' => $displayName, + 'last_seen_at' => now(), + ]); + + return [ + 'policy' => $policy, + 'version' => $version, + 'inventory' => $inventory, + ]; +} diff --git a/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php new file mode 100644 index 00000000..bb19d1f2 --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php @@ -0,0 +1,227 @@ + [['key' => 'aligned', 'value' => 1]]], + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Aligned Policy', + snapshot: ['settings' => [['key' => 'aligned', 'value' => 1]]], + ); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Different Policy', + snapshot: ['settings' => [['key' => 'different', 'value' => 1]]], + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Different Policy', + snapshot: ['settings' => [['key' => 'different', 'value' => 2]]], + ); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Missing Policy', + snapshot: ['settings' => [['key' => 'missing', 'value' => 1]]], + ); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Manual Mapping Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Manual Mapping Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + externalId: 'manual-target-1', + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Manual Mapping Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + externalId: 'manual-target-2', + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => ' ', + 'external_id' => 'missing-source-identifier', + ]); + + Policy::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-source', + 'display_name' => 'Refresh Required Policy', + 'platform' => 'windows', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-source', + 'display_name' => 'Refresh Required Policy', + 'meta_jsonb' => ['etag' => 'source-meta-only'], + ]); + + Policy::factory()->create([ + 'tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-target', + 'display_name' => 'Refresh Required Policy', + 'platform' => 'windows', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-target', + 'display_name' => 'Refresh Required Policy', + 'meta_jsonb' => ['etag' => 'target-meta-only'], + ]); + + $selection = new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + ); + + $preview = app(CrossTenantComparePreviewBuilder::class)->build($selection); + $preflight = app(CrossTenantPromotionPreflight::class)->build($preview); + + expect($preflight['summary'])->toBe([ + 'ready' => 3, + 'blocked' => 2, + 'manual_mapping_required' => 1, + 'total' => 6, + ]); + + $bucketByName = collect($preflight['buckets']['ready']) + ->merge($preflight['buckets']['blocked']) + ->merge($preflight['buckets']['manual_mapping_required']) + ->mapWithKeys(static fn (array $subject): array => [ + (string) ($subject['displayName'] ?? '') => $subject['preflight'], + ]); + + expect(data_get($bucketByName, 'Aligned Policy.bucket'))->toBe('ready') + ->and(data_get($bucketByName, 'Different Policy.bucket'))->toBe('ready') + ->and(data_get($bucketByName, 'Missing Policy.bucket'))->toBe('ready') + ->and(data_get($bucketByName, 'Manual Mapping Policy.bucket'))->toBe('manual_mapping_required') + ->and(data_get($bucketByName, 'Refresh Required Policy.bucket'))->toBe('blocked'); + + $identifierGap = collect($preflight['buckets']['blocked']) + ->first(fn (array $subject): bool => in_array('source_identifier_missing', data_get($subject, 'preflight.reasonCodes', []), true)); + + expect($identifierGap)->toBeArray() + ->and(data_get($identifierGap, 'preflight.reasonLabels.0'))->toBe('Source tenant subject is missing a stable compare identifier.') + ->and($preflight['blockedReasonCounts'])->toMatchArray([ + 'source_identifier_missing' => 1, + 'source_evidence_refresh_required' => 1, + 'target_subject_ambiguous' => 1, + ]); +}); + +it('remains read only when building a promotion preflight', function (): void { + $fixture = crossTenantPromotionFixture(); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Readonly Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + + $preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + )); + + $operationRunCount = OperationRun::query()->count(); + $policyVersionCount = PolicyVersion::query()->count(); + + app(CrossTenantPromotionPreflight::class)->build($preview); + + expect(OperationRun::query()->count())->toBe($operationRunCount) + ->and(PolicyVersion::query()->count())->toBe($policyVersionCount); +}); + +/** + * @return array{sourceTenant: Tenant, targetTenant: Tenant} + */ +function crossTenantPromotionFixture(): array +{ + $sourceTenant = Tenant::factory()->create(); + $targetTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + ]); + + return [ + 'sourceTenant' => $sourceTenant, + 'targetTenant' => $targetTenant, + ]; +} + +/** + * @param array $snapshot + * @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem} + */ +function createPromotionSubject( + Tenant $tenant, + string $displayName, + array $snapshot, + string $policyType = 'deviceConfiguration', + ?string $externalId = null, +): array { + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(), + 'display_name' => $displayName, + 'platform' => 'windows', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => $policyType, + 'platform' => 'windows', + 'captured_at' => now(), + 'snapshot' => $snapshot, + 'assignments' => [], + 'scope_tags' => [], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => (string) $policy->external_id, + 'display_name' => $displayName, + 'last_seen_at' => now(), + ]); + + return [ + 'policy' => $policy, + 'version' => $version, + 'inventory' => $inventory, + ]; +} diff --git a/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md b/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md index df9f95c8..c78e00d6 100644 --- a/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md +++ b/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md @@ -1,7 +1,7 @@ # Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight -**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop -**Created**: 2026-04-27 +**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop +**Created**: 2026-04-27 **Feature**: [spec.md](../spec.md) ## Content Quality @@ -54,4 +54,6 @@ ## Notes - This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists. - The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation. -- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets. \ No newline at end of file +- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets. +- Implementation sync on 2026-04-30 confirmed the code still honors those guardrails: the landed slice remains read-only, adds no compare resource to global search, and introduces no new asset registration. +- TEST-GOV-001 close-out for the landed slice stays `keep`: focused `Unit` + `Feature` proof only, with actual execution, mapping automation, and multi-provider compare explicitly deferred as follow-up work rather than hidden scope growth. diff --git a/specs/043-cross-tenant-compare-and-promotion/plan.md b/specs/043-cross-tenant-compare-and-promotion/plan.md index 2a1c7982..bae96691 100644 --- a/specs/043-cross-tenant-compare-and-promotion/plan.md +++ b/specs/043-cross-tenant-compare-and-promotion/plan.md @@ -1,6 +1,6 @@ # Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight -**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md) +**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from [spec.md](spec.md) ## Summary @@ -9,22 +9,45 @@ ## Summary Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected. +## Implementation Sync + +- Landed runtime artifacts: + - `App\Filament\Pages\CrossTenantComparePage` + - `App\Support\PortfolioCompare\CrossTenantCompareSelection` + - `App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder` + - `App\Support\PortfolioCompare\CrossTenantPromotionPreflight` + - tenant-registry row launch, exact-two bulk launch, and return wiring in `TenantResource` and `CanonicalNavigationContext` + - bounded preflight audit logging in `WorkspaceAuditLogger` and `AuditActionId` +- Landed validation artifacts: + - focused `Unit/Support/PortfolioCompare` tests for compare preview and promotion preflight + - focused `Feature/PortfolioCompare` tests for page rendering, auth semantics, audit semantics, and registry launch continuity +- Confirmed implementation constraints: + - read-only only; no target mutation, queue, or `OperationRun` + - no new asset registration + - no new globally searchable resource + - admin panel provider registration remains unchanged outside explicit page registration in Filament's admin panel provider +- Deferred follow-up remains unchanged: + - actual promotion execution + - persisted promotion drafts or compare snapshots + - mapping automation + - multi-provider compare + ## Technical Context -**Language/Version**: PHP 8.4, Laravel 12 -**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers -**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table -**Testing**: Pest v4 `Unit` and `Feature` coverage only -**Validation Lanes**: fast-feedback, confidence -**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) -**Project Type**: Web application (Laravel monolith with Filament pages) -**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1 -**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers +**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table +**Testing**: Pest v4 `Unit` and `Feature` coverage only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) +**Project Type**: Web application (Laravel monolith with Filament pages) +**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1 +**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default **Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders ## UI / Surface Guardrail Plan -- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context +- **Guardrail scope**: one new canonical compare page plus bounded row and exact-two bulk launch actions from existing tenant-registry/portfolio context - **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives - **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy - **State layers in scope**: page, query state @@ -32,7 +55,7 @@ ## UI / Surface Guardrail Plan - **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces - **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages - **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary -- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly +- **Launch default**: the row launch prefills the launched tenant as `target tenant`; the exact-two bulk launch prefills both selected tenants while preserving the same registry return context - **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope - **Repository-signal treatment**: review-mandatory - **Special surface test profiles**: standard-native-filament diff --git a/specs/043-cross-tenant-compare-and-promotion/spec.md b/specs/043-cross-tenant-compare-and-promotion/spec.md index 3adb2752..a5c45c37 100644 --- a/specs/043-cross-tenant-compare-and-promotion/spec.md +++ b/specs/043-cross-tenant-compare-and-promotion/spec.md @@ -1,11 +1,21 @@ # Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight -**Feature Branch**: `043-cross-tenant-compare-and-promotion` -**Created**: 2026-01-07 -**Updated**: 2026-04-27 -**Status**: Ready for implementation +**Feature Branch**: `043-cross-tenant-compare-and-promotion` +**Created**: 2026-01-07 +**Updated**: 2026-04-30 +**Status**: Implemented (read-only slice) **Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition. +## Implementation Sync *(2026-04-30)* + +- The canonical admin compare surface is implemented as `CrossTenantComparePage` under `/admin/cross-tenant-compare` with shareable query state, direct tenant drill-down links, and one dominant read-only action: `Generate promotion preflight`. +- The reusable compare contract is implemented in `App\Support\PortfolioCompare\CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight`. +- Portfolio launch continuity is implemented from the tenant registry via a bounded row-level `Compare tenants` action, an exact-two bulk compare launch, and `CanonicalNavigationContext` return-state wiring. +- Preflight audit is implemented through the existing workspace audit pipeline using `AuditActionId::CrossTenantPromotionPreflightGenerated` and `WorkspaceAuditLogger`. +- The focused `Unit` + `Feature` PortfolioCompare suite is green for compare preview, preflight, authorization, audit, and launch/return continuity. +- Explicitly deferred and still out of scope: actual promotion execution, target mutation, queueing, `OperationRun`, persisted compare snapshots, persisted promotion drafts, mapping automation, customer-facing compare, and multi-provider compare. +- Guardrails remain unchanged in implementation: Filament v5 on Livewire v4, provider registration stays in `bootstrap/providers.php`, no globally searchable compare resource was introduced, and no new asset registration was added. + ## Spec Candidate Check *(mandatory - SPEC-GATE-001)* - **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision. @@ -98,7 +108,7 @@ ## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are chang | Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | |---|---|---|---|---|---|---|---| | Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker | -| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row | +| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` / `Compare selected` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row | ## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* @@ -112,7 +122,7 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang | Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | |---|---|---|---|---|---|---|---|---|---|---| | Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none | -| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none | +| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenants should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants / Compare selected | none | ## Proportionality Review *(mandatory when structural complexity is introduced)* @@ -242,7 +252,7 @@ ### User Story 3 - Launch compare from portfolio context without losing return s **Acceptance Scenarios**: -1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`. +1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or an exact-two bulk selection, **Then** the compare page preserves a return token and prefills the launched tenant context without dropping the current registry filters. 2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored. ### Edge Cases diff --git a/specs/043-cross-tenant-compare-and-promotion/tasks.md b/specs/043-cross-tenant-compare-and-promotion/tasks.md index f3176f9d..77e10304 100644 --- a/specs/043-cross-tenant-compare-and-promotion/tasks.md +++ b/specs/043-cross-tenant-compare-and-promotion/tasks.md @@ -6,31 +6,31 @@ # Tasks: Cross-Tenant Compare Preview and Promotion Preflight -**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/` +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/` **Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required) -**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice. -**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only. -**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`. -**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood. +**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice. +**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only. +**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`. +**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood. **Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist. ## Test Governance Checklist -- [ ] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. -- [ ] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only. -- [ ] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history. -- [ ] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope. -- [ ] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces. -- [ ] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth. +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only. +- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history. +- [x] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope. +- [x] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces. +- [x] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth. ## Phase 1: Setup (Shared Context) **Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins. -- [ ] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references. -- [ ] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`. -- [ ] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`. +- [x] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references. +- [x] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`. +- [x] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`. --- @@ -40,11 +40,11 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Critical**: No user-story work should begin until this phase is complete. -- [ ] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework. -- [ ] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics. -- [ ] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth. -- [ ] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation. -- [ ] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only. +- [x] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework. +- [x] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics. +- [x] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth. +- [x] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation. +- [x] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only. **Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently. @@ -58,15 +58,15 @@ ## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP ### Tests for User Story 1 -- [ ] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. -- [ ] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. -- [ ] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`. +- [x] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. +- [x] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. +- [x] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`. ### Implementation for User Story 1 -- [ ] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder. -- [ ] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics. -- [ ] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary. +- [x] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder. +- [x] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics. +- [x] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary. **Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants. @@ -80,15 +80,15 @@ ## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P ### Tests for User Story 2 -- [ ] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. -- [ ] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. -- [ ] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`. +- [x] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. +- [x] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. +- [x] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`. ### Implementation for User Story 2 -- [ ] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects. -- [ ] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons. -- [ ] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only. +- [x] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects. +- [x] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons. +- [x] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only. **Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page. @@ -102,13 +102,13 @@ ## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing ### Tests for User Story 3 -- [ ] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. -- [ ] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. +- [x] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [x] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. ### Implementation for User Story 3 -- [ ] T023 [US3] Add a bounded launch action from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` that opens the canonical compare page with the current tenant prefilled as the `target tenant`. -- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format. +- [x] T023 [US3] Add bounded registry launch actions from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` so row launch can prefill the current tenant as the `target tenant` and exact-two bulk launch can prefill both selected tenants. +- [x] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format. **Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters. @@ -118,11 +118,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns **Purpose**: Finish narrow validation and reviewer close-out without widening scope. -- [ ] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. -- [ ] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. -- [ ] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. -- [ ] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource. -- [ ] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes. +- [x] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. +- [x] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [x] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource. +- [x] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes. ---