Compare commits
2 Commits
083-requir
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2adeab71 | |||
| 55166cf9b8 |
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -22,6 +22,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
|
||||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
|
||||||
|
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -41,9 +43,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
|
||||||
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||||
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
|
|
||||||
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.7.0 → 1.8.0
|
- Version change: 1.8.0 → 1.8.1
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule)
|
- Workspace Isolation is Non-negotiable (new core principle)
|
||||||
- Added sections: None
|
- Tenant Isolation is Non-negotiable (clarified tenant-plane scope + canonical tenantless views)
|
||||||
|
- RBAC-UX-002 / RBAC-UX-003 (clarified workspace + tenant membership semantics)
|
||||||
|
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added micro-rules + clarified CI enforcement phrasing)
|
||||||
|
- Added sections:
|
||||||
|
- Workspace Isolation is Non-negotiable
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
@ -38,9 +42,16 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Workspace Isolation is Non-negotiable
|
||||||
|
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||||
|
deny-as-not-found (404).
|
||||||
|
- Workspace is the primary session context. Tenant-scoped routes/resources MUST require an established workspace context.
|
||||||
|
- Workspace context switching is separate from Filament Tenancy (Managed Tenant switching).
|
||||||
|
|
||||||
### Tenant Isolation is Non-negotiable
|
### Tenant Isolation is Non-negotiable
|
||||||
- Every read/write MUST be tenant-scoped.
|
- Every tenant-plane read/write MUST be tenant-scoped.
|
||||||
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
||||||
|
- Tenantless canonical views (e.g., Monitoring/Operations) MUST enforce tenant entitlement before revealing records.
|
||||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||||
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
||||||
deny-as-not-found (404).
|
deny-as-not-found (404).
|
||||||
@ -67,15 +78,15 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
|
|||||||
- Any missing server-side authorization is a P0 security bug.
|
- Any missing server-side authorization is a P0 security bug.
|
||||||
|
|
||||||
RBAC-UX-002 — Deny-as-not-found for non-members
|
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||||
- Tenant membership (and plane membership) is an isolation boundary.
|
- Tenant and workspace membership (and plane membership) are isolation boundaries.
|
||||||
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
- If the current actor is not a member of the current workspace OR the current tenant (or otherwise not entitled to the
|
||||||
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
workspace/tenant scope), the system MUST respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||||
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||||
action endpoints (Livewire calls included).
|
action endpoints (Livewire calls included).
|
||||||
|
|
||||||
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||||
- Within an established tenant scope, missing permissions are authorization failures.
|
- Within an established workspace + tenant scope, missing permissions are authorization failures.
|
||||||
- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
- If the actor is a workspace + tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
||||||
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
||||||
|
|
||||||
RBAC-UX-004 — Visible vs disabled UX rule
|
RBAC-UX-004 — Visible vs disabled UX rule
|
||||||
@ -147,11 +158,13 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
||||||
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
||||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
||||||
|
- View/Detail SHOULD be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
|
||||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
- Create/Edit MUST provide consistent Save/Cancel UX.
|
||||||
|
|
||||||
Grouping & safety
|
Grouping & safety
|
||||||
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
||||||
- Bulk actions MUST be grouped via BulkActionGroup.
|
- Bulk actions MUST be grouped via BulkActionGroup.
|
||||||
|
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
|
||||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
||||||
- Relevant mutations MUST write an audit log entry.
|
- Relevant mutations MUST write an audit log entry.
|
||||||
|
|
||||||
@ -163,7 +176,7 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
Spec / DoD gates
|
Spec / DoD gates
|
||||||
- Every spec MUST include a “UI Action Matrix”.
|
- Every spec MUST include a “UI Action Matrix”.
|
||||||
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
||||||
- CI MUST enforce the contract (test/command) and block merges on violations.
|
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
|
||||||
|
|
||||||
### Data Minimization & Safe Logging
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
@ -200,4 +213,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.8.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
|
**Version**: 1.8.1 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-09
|
||||||
|
|||||||
@ -36,6 +36,7 @@ ## Constitution Check
|
|||||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||||
|
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
|
||||||
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
|
|||||||
@ -86,7 +86,7 @@ ## Requirements *(mandatory)*
|
|||||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||||
- ensure any cross-plane access is deny-as-not-found (404),
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
- explicitly define 404 vs 403 semantics:
|
- explicitly define 404 vs 403 semantics:
|
||||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||||
- member but missing capability → 403
|
- member but missing capability → 403
|
||||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ # Tasks: [FEATURE NAME]
|
|||||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||||
- explicit 404 vs 403 semantics:
|
- explicit 404 vs 403 semantics:
|
||||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||||
- member but missing capability → 403,
|
- member but missing capability → 403,
|
||||||
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
||||||
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -16,6 +15,7 @@
|
|||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class TenantlessOperationRunViewer extends Page
|
class TenantlessOperationRunViewer extends Page
|
||||||
@ -87,20 +87,7 @@ public function mount(OperationRun $run): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspaceId = (int) ($run->workspace_id ?? 0);
|
Gate::forUser($user)->authorize('view', $run);
|
||||||
|
|
||||||
if ($workspaceId <= 0) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isMember = WorkspaceMembership::query()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (! $isMember) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
@ -41,34 +40,28 @@ class TenantRequiredPermissions extends Page
|
|||||||
*/
|
*/
|
||||||
public array $viewModel = [];
|
public array $viewModel = [];
|
||||||
|
|
||||||
|
public ?Tenant $scopedTenant = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return WorkspaceMembership::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function currentTenant(): ?Tenant
|
public function currentTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
return static::resolveScopedTenant();
|
return $this->scopedTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$tenant = static::resolveScopedTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->scopedTenant = $tenant;
|
||||||
|
|
||||||
$queryFeatures = request()->query('features', $this->features);
|
$queryFeatures = request()->query('features', $this->features);
|
||||||
|
|
||||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||||
@ -147,7 +140,7 @@ public function resetFilters(): void
|
|||||||
|
|
||||||
private function refreshViewModel(): void
|
private function refreshViewModel(): void
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->viewModel = [];
|
$this->viewModel = [];
|
||||||
@ -174,27 +167,22 @@ private function refreshViewModel(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reRunVerificationUrl(): ?string
|
public function reRunVerificationUrl(): string
|
||||||
{
|
{
|
||||||
$tenant = static::resolveScopedTenant();
|
return route('admin.onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function manageProviderConnectionUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$connectionId = ProviderConnection::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->orderByDesc('is_default')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->value('id');
|
|
||||||
|
|
||||||
if (! is_int($connectionId)) {
|
|
||||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function resolveScopedTenant(): ?Tenant
|
protected static function resolveScopedTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
$routeTenant = request()->route('tenant');
|
$routeTenant = request()->route('tenant');
|
||||||
@ -209,6 +197,32 @@ protected static function resolveScopedTenant(): ?Tenant
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Tenant::current();
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isWorkspaceMember = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isWorkspaceMember) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1436,15 +1436,66 @@ public function startVerification(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||||
|
? (string) $result->run->context['reason_code']
|
||||||
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||||
|
];
|
||||||
|
|
||||||
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||||
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||||
|
|
||||||
|
foreach ($nextSteps as $index => $step) {
|
||||||
|
if (! is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||||
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||||
|
|
||||||
|
if ($label === '' || $url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('next_step_'.$index)
|
||||||
|
->label($label)
|
||||||
|
->url($url);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
->title('Verification blocked')
|
||||||
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
|
->warning()
|
||||||
|
->actions($actions)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = Notification::make()
|
||||||
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
|
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
|
||||||
->success()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||||
])
|
]);
|
||||||
->send();
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
$notification
|
||||||
|
->body('A verification run is already queued or running.')
|
||||||
|
->warning();
|
||||||
|
} else {
|
||||||
|
$notification->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshVerificationStatus(): void
|
public function refreshVerificationStatus(): void
|
||||||
|
|||||||
@ -15,12 +15,10 @@
|
|||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RbacHealthService;
|
|
||||||
use App\Services\Intune\RbacOnboardingService;
|
use App\Services\Intune\RbacOnboardingService;
|
||||||
use App\Services\Intune\TenantConfigService;
|
|
||||||
use App\Services\Intune\TenantPermissionService;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -471,10 +469,7 @@ public static function table(Table $table): Table
|
|||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
TenantConfigService $configService,
|
StartVerification $verification,
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
RbacHealthService $rbacHealthService,
|
|
||||||
AuditLogger $auditLogger
|
|
||||||
): void {
|
): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -482,18 +477,108 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
if (! $user->canAccessTenant($record)) {
|
||||||
$resolver = app(CapabilityResolver::class);
|
abort(404);
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
$result = $verification->providerConnectionCheckForTenant(
|
||||||
|
tenant: $record,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'surface' => [
|
||||||
|
'kind' => 'tenant_list_row',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Another operation is already running')
|
||||||
|
->body('Please wait for the active run to finish.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification already running')
|
||||||
|
->body('A verification run is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||||
|
? (string) $result->run->context['reason_code']
|
||||||
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
];
|
||||||
|
|
||||||
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||||
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||||
|
|
||||||
|
foreach ($nextSteps as $index => $step) {
|
||||||
|
if (! is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||||
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||||
|
|
||||||
|
if ($label === '' || $url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Actions\Action::make('next_step_'.$index)
|
||||||
|
->label($label)
|
||||||
|
->url($url);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification blocked')
|
||||||
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
|
->warning()
|
||||||
|
->actions($actions)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification started')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
@ -1405,95 +1490,4 @@ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?s
|
|||||||
$response->data['id'] ?? $groupId
|
$response->data['id'] ?? $groupId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function verifyTenant(
|
|
||||||
Tenant $tenant,
|
|
||||||
TenantConfigService $configService,
|
|
||||||
TenantPermissionService $permissionService,
|
|
||||||
RbacHealthService $rbacHealthService,
|
|
||||||
AuditLogger $auditLogger
|
|
||||||
): void {
|
|
||||||
$configResult = $configService->testConnectivity($tenant);
|
|
||||||
// Fetch actual permissions from Graph API with liveCheck=true
|
|
||||||
$permissions = $permissionService->compare($tenant, null, true, true);
|
|
||||||
$rbac = $rbacHealthService->check($tenant);
|
|
||||||
|
|
||||||
$appStatus = $configResult['success']
|
|
||||||
? 'ok'
|
|
||||||
: ($configResult['requires_consent'] ? 'consent_required' : 'error');
|
|
||||||
|
|
||||||
$tenant->update([
|
|
||||||
'app_status' => $appStatus,
|
|
||||||
'app_notes' => $configResult['error_message'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.config.verified',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'app_status' => $appStatus,
|
|
||||||
'error' => $configResult['error_message'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user?->id,
|
|
||||||
actorEmail: $user?->email,
|
|
||||||
actorName: $user?->name,
|
|
||||||
status: $appStatus === 'ok' ? 'success' : 'error',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.permissions.checked',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'overall_status' => $permissions['overall_status'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user?->id,
|
|
||||||
actorEmail: $user?->email,
|
|
||||||
actorName: $user?->name,
|
|
||||||
status: match ($permissions['overall_status']) {
|
|
||||||
'granted' => 'success',
|
|
||||||
'error' => 'error',
|
|
||||||
default => 'partial',
|
|
||||||
},
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant.rbac.checked',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'status' => $rbac['status'],
|
|
||||||
'reason' => $rbac['reason'] ?? null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
status: $rbac['status'] === 'ok' ? 'success' : 'error',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$notification = Notification::make()
|
|
||||||
->title($configResult['success'] ? 'Configuration verified' : 'Verification failed')
|
|
||||||
->body($configResult['success']
|
|
||||||
? 'Graph connectivity confirmed. Permission status: '.$permissions['overall_status']
|
|
||||||
: ($configResult['error_message'] ?? 'Graph connectivity failed'));
|
|
||||||
|
|
||||||
if ($configResult['success']) {
|
|
||||||
$notification->success();
|
|
||||||
} elseif ($configResult['requires_consent']) {
|
|
||||||
$notification->warning();
|
|
||||||
} else {
|
|
||||||
$notification->danger();
|
|
||||||
}
|
|
||||||
|
|
||||||
$notification->send();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,13 @@
|
|||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RbacHealthService;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Services\Intune\TenantConfigService;
|
|
||||||
use App\Services\Intune\TenantPermissionService;
|
|
||||||
use App\Services\Providers\ProviderConnectionResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -28,6 +27,7 @@ protected function getHeaderWidgets(): array
|
|||||||
return [
|
return [
|
||||||
TenantArchivedBanner::class,
|
TenantArchivedBanner::class,
|
||||||
RecentOperationsSummary::class,
|
RecentOperationsSummary::class,
|
||||||
|
TenantVerificationReport::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,59 +63,126 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('verify')
|
Actions\Action::make('verify')
|
||||||
->label('Verify configuration')
|
->label('Verify configuration')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
TenantConfigService $configService,
|
StartVerification $verification,
|
||||||
TenantPermissionService $permissionService,
|
): void {
|
||||||
RbacHealthService $rbacHealthService,
|
$user = auth()->user();
|
||||||
AuditLogger $auditLogger,
|
|
||||||
ProviderConnectionResolver $connectionResolver,
|
|
||||||
ProviderNextStepsRegistry $nextStepsRegistry,
|
|
||||||
) {
|
|
||||||
$resolution = $connectionResolver->resolveDefault($record, 'microsoft');
|
|
||||||
|
|
||||||
if (! $resolution->resolved) {
|
if (! $user instanceof User) {
|
||||||
$reasonCode = $resolution->effectiveReasonCode();
|
abort(403);
|
||||||
$nextSteps = $nextStepsRegistry->forReason($record, $reasonCode, $resolution->connection);
|
}
|
||||||
|
|
||||||
$notification = Notification::make()
|
if (! $user->canAccessTenant($record)) {
|
||||||
->title('Verification blocked')
|
abort(404);
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
}
|
||||||
->warning();
|
|
||||||
|
$result = $verification->providerConnectionCheckForTenant(
|
||||||
|
tenant: $record,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'surface' => [
|
||||||
|
'kind' => 'tenant_view_header',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Another operation is already running')
|
||||||
|
->body('Please wait for the active run to finish.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification already running')
|
||||||
|
->body('A verification run is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||||
|
? (string) $result->run->context['reason_code']
|
||||||
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
];
|
||||||
|
|
||||||
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||||
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||||
|
|
||||||
foreach ($nextSteps as $index => $step) {
|
foreach ($nextSteps as $index => $step) {
|
||||||
if (! is_array($step)) {
|
if (! is_array($step)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = is_string($step['label'] ?? null) ? $step['label'] : null;
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||||
$url = is_string($step['url'] ?? null) ? $step['url'] : null;
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||||
|
|
||||||
if ($label === null || $url === null) {
|
if ($label === '' || $url === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->actions([
|
$actions[] = Actions\Action::make('next_step_'.$index)
|
||||||
Actions\Action::make('next_step_'.$index)
|
|
||||||
->label($label)
|
->label($label)
|
||||||
->url($url),
|
->url($url);
|
||||||
]);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
Notification::make()
|
||||||
|
->title('Verification blocked')
|
||||||
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
|
->warning()
|
||||||
|
->actions($actions)
|
||||||
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
Notification::make()
|
||||||
|
->title('Verification started')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
TenantResource::rbacAction(),
|
TenantResource::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
|
|||||||
@ -16,12 +16,25 @@ class RecentOperationsSummary extends Widget
|
|||||||
|
|
||||||
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
|
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
|
||||||
|
|
||||||
|
public ?Tenant $record = null;
|
||||||
|
|
||||||
|
private function resolveTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->record instanceof Tenant ? $this->record : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = $this->resolveTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
223
app/Filament/Widgets/Tenant/TenantVerificationReport.php
Normal file
223
app/Filament/Widgets/Tenant/TenantVerificationReport.php
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Auth\UiTooltips;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class TenantVerificationReport extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.tenant.tenant-verification-report';
|
||||||
|
|
||||||
|
public ?Tenant $record = null;
|
||||||
|
|
||||||
|
private function resolveTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->record instanceof Tenant ? $this->record : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startVerification(StartVerification $verification): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->resolveTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $verification->providerConnectionCheckForTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'surface' => [
|
||||||
|
'kind' => 'tenant_verification_widget',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Another operation is already running')
|
||||||
|
->body('Please wait for the active run to finish.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification already running')
|
||||||
|
->body('A verification run is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||||
|
? (string) $result->run->context['reason_code']
|
||||||
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
];
|
||||||
|
|
||||||
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
||||||
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||||
|
|
||||||
|
foreach ($nextSteps as $index => $step) {
|
||||||
|
if (! is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||||||
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||||||
|
|
||||||
|
if ($label === '' || $url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('next_step_'.$index)
|
||||||
|
->label($label)
|
||||||
|
->url($url);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification blocked')
|
||||||
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
|
->warning()
|
||||||
|
->actions($actions)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Verification started')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = $this->resolveTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'tenant' => null,
|
||||||
|
'run' => null,
|
||||||
|
'runData' => null,
|
||||||
|
'runUrl' => null,
|
||||||
|
'report' => null,
|
||||||
|
'isInProgress' => false,
|
||||||
|
'canStart' => false,
|
||||||
|
'startTooltip' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$report = $run instanceof OperationRun
|
||||||
|
? VerificationReportViewer::report($run)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$isInProgress = $run instanceof OperationRun
|
||||||
|
&& (string) $run->status !== OperationRunStatus::Completed->value;
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||||
|
$canStart = $isTenantMember
|
||||||
|
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
|
||||||
|
$runData = null;
|
||||||
|
|
||||||
|
if ($run instanceof OperationRun) {
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
|
$runData = [
|
||||||
|
'id' => (int) $run->getKey(),
|
||||||
|
'type' => (string) $run->type,
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'initiator_name' => (string) $run->initiator_name,
|
||||||
|
'started_at' => $run->started_at?->toJSON(),
|
||||||
|
'completed_at' => $run->completed_at?->toJSON(),
|
||||||
|
'target_scope' => $targetScope,
|
||||||
|
'failures' => is_array($run->failure_summary ?? null) ? $run->failure_summary : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'run' => $run,
|
||||||
|
'runData' => $runData,
|
||||||
|
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
|
||||||
|
'report' => $report,
|
||||||
|
'isInProgress' => $isInProgress,
|
||||||
|
'canStart' => $canStart,
|
||||||
|
'startTooltip' => $isTenantMember && ! $canStart ? UiTooltips::insufficientPermission() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,6 +40,22 @@ public function view(User $user, OperationRun $run): Response|bool
|
|||||||
->where('user_id', (int) $user->getKey())
|
->where('user_id', (int) $user->getKey())
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
return $isMember ? true : Response::denyAsNotFound();
|
if (! $isMember) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) ($run->tenant_id ?? 0);
|
||||||
|
|
||||||
|
if ($tenantId > 0) {
|
||||||
|
$hasTenantEntitlement = $user->tenantMemberships()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $hasTenantEntitlement) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class TenantPermissionService
|
class TenantPermissionService
|
||||||
{
|
{
|
||||||
@ -44,6 +46,7 @@ public function getGrantedPermissions(Tenant $tenant): array
|
|||||||
* @return array{
|
* @return array{
|
||||||
* overall_status:string,
|
* overall_status:string,
|
||||||
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
||||||
|
* last_refreshed_at:?string,
|
||||||
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
|
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@ -210,6 +213,7 @@ public function compare(
|
|||||||
$payload = [
|
$payload = [
|
||||||
'overall_status' => $overall,
|
'overall_status' => $overall,
|
||||||
'permissions' => $results,
|
'permissions' => $results,
|
||||||
|
'last_refreshed_at' => $this->lastRefreshedAtIso($tenant),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($liveCheckMeta['attempted'] === true) {
|
if ($liveCheckMeta['attempted'] === true) {
|
||||||
@ -389,4 +393,25 @@ private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = nul
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function lastRefreshedAtIso(Tenant $tenant): ?string
|
||||||
|
{
|
||||||
|
$lastCheckedAt = TenantPermission::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->max('last_checked_at');
|
||||||
|
|
||||||
|
if ($lastCheckedAt instanceof DateTimeInterface) {
|
||||||
|
return Carbon::instance($lastCheckedAt)->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($lastCheckedAt) && $lastCheckedAt !== '') {
|
||||||
|
try {
|
||||||
|
return Carbon::parse($lastCheckedAt)->toIso8601String();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class TenantRequiredPermissionsViewModelBuilder
|
class TenantRequiredPermissionsViewModelBuilder
|
||||||
{
|
{
|
||||||
@ -16,7 +18,8 @@ class TenantRequiredPermissionsViewModelBuilder
|
|||||||
* overview: array{
|
* overview: array{
|
||||||
* overall: string,
|
* overall: string,
|
||||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||||
* feature_impacts: array<int, FeatureImpact>
|
* feature_impacts: array<int, FeatureImpact>,
|
||||||
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||||
* },
|
* },
|
||||||
* permissions: array<int, TenantPermissionRow>,
|
* permissions: array<int, TenantPermissionRow>,
|
||||||
* filters: FilterState,
|
* filters: FilterState,
|
||||||
@ -48,6 +51,7 @@ public function build(Tenant $tenant, array $filters = []): array
|
|||||||
$state = self::normalizeFilterState($filters);
|
$state = self::normalizeFilterState($filters);
|
||||||
|
|
||||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||||
|
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant' => [
|
'tenant' => [
|
||||||
@ -56,9 +60,10 @@ public function build(Tenant $tenant, array $filters = []): array
|
|||||||
'name' => (string) $tenant->name,
|
'name' => (string) $tenant->name,
|
||||||
],
|
],
|
||||||
'overview' => [
|
'overview' => [
|
||||||
'overall' => self::deriveOverallStatus($allPermissions),
|
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
|
||||||
'counts' => self::deriveCounts($allPermissions),
|
'counts' => self::deriveCounts($allPermissions),
|
||||||
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
||||||
|
'freshness' => $freshness,
|
||||||
],
|
],
|
||||||
'permissions' => $filteredPermissions,
|
'permissions' => $filteredPermissions,
|
||||||
'filters' => $state,
|
'filters' => $state,
|
||||||
@ -72,7 +77,7 @@ public function build(Tenant $tenant, array $filters = []): array
|
|||||||
/**
|
/**
|
||||||
* @param array<int, TenantPermissionRow> $permissions
|
* @param array<int, TenantPermissionRow> $permissions
|
||||||
*/
|
*/
|
||||||
public static function deriveOverallStatus(array $permissions): string
|
public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string
|
||||||
{
|
{
|
||||||
$hasMissingApplication = collect($permissions)->contains(
|
$hasMissingApplication = collect($permissions)->contains(
|
||||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
||||||
@ -90,13 +95,35 @@ public static function deriveOverallStatus(array $permissions): string
|
|||||||
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($hasErrors || $hasMissingDelegated) {
|
if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) {
|
||||||
return VerificationReportOverall::NeedsAttention->value;
|
return VerificationReportOverall::NeedsAttention->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return VerificationReportOverall::Ready->value;
|
return VerificationReportOverall::Ready->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{last_refreshed_at:?string,is_stale:bool}
|
||||||
|
*/
|
||||||
|
public static function deriveFreshness(?CarbonInterface $lastRefreshedAt, ?CarbonInterface $referenceTime = null): array
|
||||||
|
{
|
||||||
|
$reference = $referenceTime instanceof Carbon
|
||||||
|
? $referenceTime->copy()
|
||||||
|
: ($referenceTime !== null ? Carbon::instance($referenceTime) : now());
|
||||||
|
|
||||||
|
$lastRefreshed = $lastRefreshedAt instanceof Carbon
|
||||||
|
? $lastRefreshedAt
|
||||||
|
: ($lastRefreshedAt !== null ? Carbon::instance($lastRefreshedAt) : null);
|
||||||
|
|
||||||
|
$isStale = $lastRefreshed === null
|
||||||
|
|| $lastRefreshed->lt($reference->copy()->subDays(30));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'last_refreshed_at' => $lastRefreshed?->toIso8601String(),
|
||||||
|
'is_stale' => $isStale,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, TenantPermissionRow> $permissions
|
* @param array<int, TenantPermissionRow> $permissions
|
||||||
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
||||||
@ -386,4 +413,25 @@ private static function normalizePermissionRow(array $row): array
|
|||||||
'details' => $details,
|
'details' => $details,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function parseLastRefreshedAt(mixed $value): ?Carbon
|
||||||
|
{
|
||||||
|
if ($value instanceof Carbon) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof CarbonInterface) {
|
||||||
|
return Carbon::instance($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Verification\BlockedVerificationReportFactory;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use ReflectionFunction;
|
use ReflectionFunction;
|
||||||
@ -164,6 +166,16 @@ private function startBlocked(
|
|||||||
message: $reasonMessage,
|
message: $reasonMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($operationType === 'provider.connection.check') {
|
||||||
|
VerificationReportWriter::write(
|
||||||
|
run: $run,
|
||||||
|
checks: BlockedVerificationReportFactory::checks($run),
|
||||||
|
identity: BlockedVerificationReportFactory::identity($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return ProviderOperationStartResult::blocked($run);
|
return ProviderOperationStartResult::blocked($run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Services\Providers\ProviderOperationStartResult;
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
final class StartVerification
|
final class StartVerification
|
||||||
@ -31,6 +32,51 @@ public function providerConnectionCheck(
|
|||||||
ProviderConnection $connection,
|
ProviderConnection $connection,
|
||||||
User $initiator,
|
User $initiator,
|
||||||
array $extraContext = [],
|
array $extraContext = [],
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
return $this->providerConnectionCheckUsingConnection(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: $extraContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (or dedupe) a provider-connection verification run for the tenant default connection.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
public function providerConnectionCheckForTenant(
|
||||||
|
Tenant $tenant,
|
||||||
|
User $initiator,
|
||||||
|
array $extraContext = [],
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
if (! $initiator->canAccessTenant($tenant)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
|
||||||
|
return $this->providers->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: null,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator),
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: $extraContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (or dedupe) a provider-connection verification run for an explicit connection.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
public function providerConnectionCheckUsingConnection(
|
||||||
|
Tenant $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
User $initiator,
|
||||||
|
array $extraContext = [],
|
||||||
): ProviderOperationStartResult {
|
): ProviderOperationStartResult {
|
||||||
if (! $initiator->canAccessTenant($tenant)) {
|
if (! $initiator->canAccessTenant($tenant)) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
@ -42,16 +88,26 @@ public function providerConnectionCheck(
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
operationType: 'provider.connection.check',
|
operationType: 'provider.connection.check',
|
||||||
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
|
dispatcher: fn (OperationRun $run): mixed => $this->dispatchConnectionHealthCheck($run, $tenant, $initiator),
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $connection->getKey(),
|
|
||||||
operationRun: $run,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
extraContext: $extraContext,
|
extraContext: $extraContext,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function dispatchConnectionHealthCheck(OperationRun $run, Tenant $tenant, User $initiator): mixed
|
||||||
|
{
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($providerConnectionId)) {
|
||||||
|
throw new InvalidArgumentException('Provider connection id is missing from run context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnectionHealthCheckJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
providerConnectionId: (int) $providerConnectionId,
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
app/Support/Verification/BlockedVerificationReportFactory.php
Normal file
119
app/Support/Verification/BlockedVerificationReportFactory.php
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
|
final class BlockedVerificationReportFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function checks(OperationRun $run): array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
|
||||||
|
$reasonCode = self::normalizedReasonCode($context['reason_code'] ?? null);
|
||||||
|
$message = self::blockedMessage($run);
|
||||||
|
|
||||||
|
$nextSteps = $context['next_steps'] ?? [];
|
||||||
|
$nextSteps = VerificationReportSanitizer::sanitizeNextStepsPayload($nextSteps);
|
||||||
|
|
||||||
|
return [[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection preflight',
|
||||||
|
'status' => 'fail',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'blocking' => true,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
'evidence' => self::evidence($run, $context),
|
||||||
|
'next_steps' => $nextSteps,
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function identity(OperationRun $run): array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
|
||||||
|
$identity = [];
|
||||||
|
|
||||||
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
if (is_numeric($providerConnectionId)) {
|
||||||
|
$identity['provider_connection_id'] = (int) $providerConnectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||||
|
$identity['entra_tenant_id'] = trim($entraTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizedReasonCode(mixed $reasonCode): string
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode)) {
|
||||||
|
return ProviderReasonCodes::UnknownError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function blockedMessage(OperationRun $run): string
|
||||||
|
{
|
||||||
|
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||||
|
$firstFailure = $failures[0] ?? null;
|
||||||
|
|
||||||
|
if (is_array($firstFailure) && is_string($firstFailure['message'] ?? null) && trim((string) $firstFailure['message']) !== '') {
|
||||||
|
return trim((string) $firstFailure['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Operation blocked due to provider configuration.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<int, array{kind: string, value: int|string}>
|
||||||
|
*/
|
||||||
|
private static function evidence(OperationRun $run, array $context): array
|
||||||
|
{
|
||||||
|
$evidence = [];
|
||||||
|
|
||||||
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
if (is_numeric($providerConnectionId)) {
|
||||||
|
$evidence[] = [
|
||||||
|
'kind' => 'provider_connection_id',
|
||||||
|
'value' => (int) $providerConnectionId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetScope = $context['target_scope'] ?? [];
|
||||||
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||||
|
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||||
|
$evidence[] = [
|
||||||
|
'kind' => 'entra_tenant_id',
|
||||||
|
'value' => trim($entraTenantId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence[] = [
|
||||||
|
'kind' => 'operation_run_id',
|
||||||
|
'value' => (int) $run->getKey(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $evidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
$tenant = $this->currentTenant();
|
$tenant = $this->currentTenant();
|
||||||
|
|
||||||
@ -9,6 +10,7 @@
|
|||||||
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
||||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||||
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
|
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
|
||||||
|
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
||||||
|
|
||||||
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
||||||
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
|
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
|
||||||
@ -47,17 +49,77 @@
|
|||||||
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
|
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
|
||||||
|
|
||||||
$reRunUrl = $this->reRunVerificationUrl();
|
$reRunUrl = $this->reRunVerificationUrl();
|
||||||
|
$manageProviderConnectionUrl = $this->manageProviderConnectionUrl();
|
||||||
|
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
|
||||||
|
$lastRefreshedLabel = $lastRefreshedAt ? Carbon::parse($lastRefreshedAt)->diffForHumans() : 'Unknown';
|
||||||
|
$isStale = (bool) ($freshness['is_stale'] ?? true);
|
||||||
|
$hasStoredPermissionData = $lastRefreshedAt !== null;
|
||||||
|
|
||||||
|
$issues = [];
|
||||||
|
|
||||||
|
if ($missingApplication > 0) {
|
||||||
|
$issues[] = [
|
||||||
|
'severity' => 'Blocker',
|
||||||
|
'title' => 'Missing application permissions',
|
||||||
|
'description' => "{$missingApplication} required application permission(s) are missing.",
|
||||||
|
'links' => array_values(array_filter([
|
||||||
|
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
|
||||||
|
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : null,
|
||||||
|
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
|
||||||
|
])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingDelegated > 0) {
|
||||||
|
$issues[] = [
|
||||||
|
'severity' => 'Warning',
|
||||||
|
'title' => 'Missing delegated permissions',
|
||||||
|
'description' => "{$missingDelegated} delegated permission(s) are missing.",
|
||||||
|
'links' => [
|
||||||
|
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
|
||||||
|
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errorCount > 0) {
|
||||||
|
$issues[] = [
|
||||||
|
'severity' => 'Warning',
|
||||||
|
'title' => 'Verification results need review',
|
||||||
|
'description' => "{$errorCount} permission row(s) are in an unknown/error state and require follow-up.",
|
||||||
|
'links' => [
|
||||||
|
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
|
||||||
|
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : ['label' => 'Admin consent guide', 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), 'external' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isStale) {
|
||||||
|
$issues[] = [
|
||||||
|
'severity' => 'Warning',
|
||||||
|
'title' => 'Freshness warning',
|
||||||
|
'description' => $hasStoredPermissionData
|
||||||
|
? "Permission data is older than 30 days (last refresh {$lastRefreshedLabel})."
|
||||||
|
: 'No stored verification data is available yet.',
|
||||||
|
'links' => [
|
||||||
|
['label' => 'Start verification', 'url' => $reRunUrl, 'external' => false],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament::page>
|
<x-filament::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<x-filament::section>
|
<x-filament::section heading="Summary">
|
||||||
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
|
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Review what’s missing for this tenant and copy the missing permissions for admin consent.
|
Review what’s missing for this tenant and copy the missing permissions for admin consent.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}.
|
||||||
|
</div>
|
||||||
|
|
||||||
@if ($overallSpec)
|
@if ($overallSpec)
|
||||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||||
@ -86,6 +148,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (! $hasStoredPermissionData)
|
||||||
|
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
|
||||||
|
<div class="font-semibold">Keine Daten verfügbar</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor.
|
||||||
|
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
|
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
|
||||||
<div class="mt-2 space-y-1">
|
<div class="mt-2 space-y-1">
|
||||||
@ -322,7 +394,75 @@ class="mt-4 space-y-2"
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|
||||||
<x-filament::section heading="Details">
|
<x-filament::section heading="Issues">
|
||||||
|
@if ($issues === [])
|
||||||
|
<div class="rounded-xl border border-success-200 bg-success-50 p-4 text-sm text-success-800 dark:border-success-800 dark:bg-success-950/30 dark:text-success-200">
|
||||||
|
No blockers or warnings detected from stored data.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($issues as $issue)
|
||||||
|
@php
|
||||||
|
$severity = (string) ($issue['severity'] ?? 'Warning');
|
||||||
|
$severityColor = $severity === 'Blocker' ? 'danger' : 'warning';
|
||||||
|
$title = (string) ($issue['title'] ?? 'Issue');
|
||||||
|
$description = (string) ($issue['description'] ?? '');
|
||||||
|
$links = is_array($issue['links'] ?? null) ? $issue['links'] : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$severityColor" size="sm">{{ $severity }}</x-filament::badge>
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">{{ $description }}</div>
|
||||||
|
@if ($links !== [])
|
||||||
|
<div class="mt-3 flex flex-wrap gap-3 text-sm">
|
||||||
|
@foreach ($links as $link)
|
||||||
|
@php
|
||||||
|
$label = is_array($link) ? (string) ($link['label'] ?? '') : '';
|
||||||
|
$url = is_array($link) ? (string) ($link['url'] ?? '') : '';
|
||||||
|
$external = is_array($link) ? (bool) ($link['external'] ?? false) : false;
|
||||||
|
@endphp
|
||||||
|
@if ($label !== '' && $url !== '')
|
||||||
|
<a
|
||||||
|
href="{{ $url }}"
|
||||||
|
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||||
|
@if ($external)
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
{{ $label }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Passed">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||||
|
<div class="font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $presentCount }} permission(s) currently pass.
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ $requiredTotal > 0 ? "Out of {$requiredTotal} required permissions, {$presentCount} are currently granted." : 'No required permissions are configured yet.' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section heading="Technical details">
|
||||||
|
<details data-testid="technical-details" class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<summary class="cursor-pointer list-none text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Expand technical details
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
@if (! $tenant)
|
@if (! $tenant)
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
No tenant selected.
|
No tenant selected.
|
||||||
@ -507,6 +647,8 @@ class="align-top"
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::page>
|
</x-filament::page>
|
||||||
|
|||||||
@ -0,0 +1,150 @@
|
|||||||
|
@php
|
||||||
|
$run = $run ?? null;
|
||||||
|
$run = $run instanceof \App\Models\OperationRun ? $run : null;
|
||||||
|
|
||||||
|
$runData = $runData ?? null;
|
||||||
|
$runData = is_array($runData) ? $runData : null;
|
||||||
|
|
||||||
|
$runUrl = $runUrl ?? null;
|
||||||
|
$runUrl = is_string($runUrl) && trim($runUrl) !== '' ? trim($runUrl) : null;
|
||||||
|
|
||||||
|
$report = $report ?? null;
|
||||||
|
$report = is_array($report) ? $report : null;
|
||||||
|
|
||||||
|
$isInProgress = (bool) ($isInProgress ?? false);
|
||||||
|
$canStart = (bool) ($canStart ?? false);
|
||||||
|
|
||||||
|
$startTooltip = $startTooltip ?? null;
|
||||||
|
$startTooltip = is_string($startTooltip) && trim($startTooltip) !== '' ? trim($startTooltip) : null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
heading="Verification report"
|
||||||
|
description="Latest verification state for this tenant (DB-only rendering)."
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@if ($run === null)
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||||
|
No verification run has been started yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if ($canStart)
|
||||||
|
<x-filament::button
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
wire:click="startVerification"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<x-filament::button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
:title="$startTooltip"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
@if ($startTooltip)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $startTooltip }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@elseif ($isInProgress)
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||||
|
Verification is currently in progress. This section reads only stored run state and does not call external services.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if ($runUrl)
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
:href="$runUrl"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
View run
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($canStart)
|
||||||
|
<x-filament::button
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
wire:click="startVerification"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<x-filament::button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
:title="$startTooltip"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
@if ($startTooltip)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $startTooltip }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
@include('filament.components.verification-report-viewer', [
|
||||||
|
'run' => $runData,
|
||||||
|
'report' => $report,
|
||||||
|
])
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if ($runUrl)
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
:href="$runUrl"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
View run
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($canStart)
|
||||||
|
<x-filament::button
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
wire:click="startVerification"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<x-filament::button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
:title="$startTooltip"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
@if ($startTooltip)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $startTooltip }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
# Specification Quality Checklist: Canonical Required Permissions (Manage) Hardening & Enterprise UX
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-08
|
||||||
|
**Feature**: [specs/083-required-permissions-hardening/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
|
|
||||||
|
Validation run (2026-02-08):
|
||||||
|
- Spec includes explicit 404 vs 403 semantics (deny-as-not-found for non-entitlement).
|
||||||
|
- Legacy URL non-existence is explicitly required and covered by test requirements.
|
||||||
|
- DB-only rendering constraint is explicitly required and test-covered.
|
||||||
27
specs/083-required-permissions-hardening/contracts/routes.md
Normal file
27
specs/083-required-permissions-hardening/contracts/routes.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Route Contract — Spec 083
|
||||||
|
|
||||||
|
This contract defines the **Required Permissions** routes and their **404/403 semantics**.
|
||||||
|
|
||||||
|
## Canonical management surface (must exist)
|
||||||
|
|
||||||
|
- `GET /admin/tenants/{tenant}/required-permissions`
|
||||||
|
|
||||||
|
Identifier contract:
|
||||||
|
- `{tenant}` is `Tenant.external_id` (Entra tenant GUID)
|
||||||
|
|
||||||
|
Authorization contract:
|
||||||
|
- Not authenticated → handled by Filament auth middleware
|
||||||
|
- Workspace not selected → 404 (deny-as-not-found)
|
||||||
|
- Not a workspace member → 404
|
||||||
|
- Workspace member but **not tenant-entitled** (no `tenant_memberships` row) → 404
|
||||||
|
- Tenant-entitled (including read-only) → 200
|
||||||
|
|
||||||
|
Action contract:
|
||||||
|
- This page is read-only. Any mutations are only linked to and executed on other surfaces.
|
||||||
|
- Mutations on other surfaces must enforce capability checks server-side (missing capability → 403).
|
||||||
|
- "Re-run verification" links canonical to the start-verification surface: `GET /admin/onboarding` (generated via route helper, not hardcoded legacy paths).
|
||||||
|
|
||||||
|
## Removed tenant-plane route (must 404)
|
||||||
|
|
||||||
|
The following route MUST NOT exist and MUST return 404 (no redirects, no aliases):
|
||||||
|
- `GET /admin/t/{tenant}/required-permissions`
|
||||||
40
specs/083-required-permissions-hardening/data-model.md
Normal file
40
specs/083-required-permissions-hardening/data-model.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Data Model — Spec 083
|
||||||
|
|
||||||
|
This feature is primarily **read-only UX + authorization hardening**. No new tables are required.
|
||||||
|
|
||||||
|
## Existing entities (relevant)
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
- **Purpose**: Isolation boundary for tenant management surfaces.
|
||||||
|
- **Key fields**: `id`.
|
||||||
|
|
||||||
|
### WorkspaceMembership
|
||||||
|
- **Purpose**: Establishes user membership in a workspace.
|
||||||
|
- **Key fields**: `workspace_id`, `user_id`, `role`.
|
||||||
|
|
||||||
|
### Tenant
|
||||||
|
- **Purpose**: Managed Entra tenant (scoped to a workspace).
|
||||||
|
- **Key fields**: `id`, `external_id` (Entra tenant GUID), `workspace_id`, `status`, `name`.
|
||||||
|
|
||||||
|
### TenantMembership
|
||||||
|
- **Purpose**: Tenant entitlement (read-only access at minimum).
|
||||||
|
- **Key fields**: `tenant_id`, `user_id`, `role`, `source`, `source_ref`.
|
||||||
|
|
||||||
|
### TenantPermission
|
||||||
|
- **Purpose**: Stored permission inventory used by Required Permissions page.
|
||||||
|
- **Key fields**: `tenant_id`, `permission_key`, `status` (`granted|missing|error`), `details` (JSON), `last_checked_at`.
|
||||||
|
|
||||||
|
## Derived / computed values
|
||||||
|
|
||||||
|
### "Last refreshed"
|
||||||
|
- **Definition**: `max(tenant_permissions.last_checked_at)` for the tenant.
|
||||||
|
- **Stale rule** (Spec 083): stale if missing OR older than 30 days.
|
||||||
|
|
||||||
|
### Summary overall status
|
||||||
|
Derived from stored permission rows (and freshness):
|
||||||
|
- **Blocked**: any missing `application` permission.
|
||||||
|
- **Needs attention**: any warning exists (missing delegated OR error rows folded into warning OR stale freshness).
|
||||||
|
- **Ready**: no blockers, no warnings.
|
||||||
|
|
||||||
|
## State transitions
|
||||||
|
- None introduced here (page remains read-only). Mutations happen on other surfaces (verification start, provider connection management) and must enforce capability checks there.
|
||||||
204
specs/083-required-permissions-hardening/plan.md
Normal file
204
specs/083-required-permissions-hardening/plan.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Implementation Plan: 083-required-permissions-hardening
|
||||||
|
|
||||||
|
**Branch**: `083-required-permissions-hardening` | **Date**: 2026-02-08 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from [spec.md](spec.md)
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden the canonical Required Permissions manage surface so it is only accessible via `GET /admin/tenants/{tenant}/required-permissions`, enforces deny-as-not-found (404) when the actor is not workspace-member or not tenant-entitled, removes any cross-plane tenant-context fallback, and presents issues-first UX using **stored DB data only** (no provider calls on render).
|
||||||
|
|
||||||
|
Research decisions are captured in [research.md](research.md).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||||
|
for the project. The structure here is presented in advisory capacity to guide
|
||||||
|
the iteration process.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Filament v5 + Livewire v4, PostgreSQL, Tailwind CSS v4
|
||||||
|
**Storage**: PostgreSQL (Sail)
|
||||||
|
**Testing**: Pest v4 (run via Sail)
|
||||||
|
**Target Platform**: Web app (Laravel) running in Docker via Sail
|
||||||
|
**Project Type**: Web application (Laravel + Filament admin panel)
|
||||||
|
**Performance Goals**: Fast, DB-only page render (no outbound HTTP / Graph calls)
|
||||||
|
**Constraints**: Strict 404 vs 403 semantics (deny-as-not-found), no cross-plane tenant fallback
|
||||||
|
**Scale/Scope**: Single page hardening + view-model/UX changes + targeted tests
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||||
|
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||||
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
|
- RBAC-UX: manage surface (`/admin/tenants/...`), tenant plane (`/admin/t/{tenant}/...`), and platform plane (`/system/...`) remain clearly separated; cross-plane access is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||||
|
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||||
|
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||||
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
|
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||||
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
|
|
||||||
|
### Gate evaluation (pre-design)
|
||||||
|
|
||||||
|
- **Inventory-first / DB-only**: PASS. This surface renders from stored `tenant_permissions` only.
|
||||||
|
- **Read/write separation**: PASS. The page is read-only; it only links to mutation surfaces.
|
||||||
|
- **Graph contract path**: PASS. No Graph calls on render; any verification runs remain elsewhere.
|
||||||
|
- **Deterministic capabilities**: PASS. Access is entitlement-based via tenant membership; capability checks remain on mutation surfaces.
|
||||||
|
- **RBAC-UX semantics**: PASS (planned). Implement explicit 404 denial for non-members/non-entitled and remove implicit tenant fallback.
|
||||||
|
- **BADGE-001**: PASS (planned). Use existing overall status enum values (`Blocked`, `NeedsAttention`, `Ready`) and render via existing badge mechanisms.
|
||||||
|
- **Filament Action Surface Contract**: PASS (exempt-by-design). This is a Filament Page (not a List/Table CRUD surface). It has no row/bulk actions; it is read-only and link-only.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/[###-feature]/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||||
|
for this feature. Delete unused options and expand the chosen structure with
|
||||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||||
|
not include Option labels.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── TenantRequiredPermissions.php
|
||||||
|
│ └── Pages/Workspaces/
|
||||||
|
│ └── ManagedTenantOnboardingWizard.php # Start verification surface (CTA target)
|
||||||
|
├── Models/
|
||||||
|
│ ├── Tenant.php
|
||||||
|
│ ├── TenantPermission.php
|
||||||
|
│ ├── TenantMembership.php
|
||||||
|
│ ├── WorkspaceMembership.php
|
||||||
|
│ └── User.php
|
||||||
|
└── Services/
|
||||||
|
├── Auth/CapabilityResolver.php
|
||||||
|
└── Intune/
|
||||||
|
├── TenantPermissionService.php
|
||||||
|
└── TenantRequiredPermissionsViewModelBuilder.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/pages/tenant-required-permissions.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── RequiredPermissions/ # to be created in Phase 2
|
||||||
|
│ │ ├── RequiredPermissionsAccessTest.php
|
||||||
|
│ │ ├── RequiredPermissionsDbOnlyRenderTest.php
|
||||||
|
│ │ ├── RequiredPermissionsEmptyStateTest.php
|
||||||
|
│ │ ├── RequiredPermissionsLegacyRouteTest.php
|
||||||
|
│ │ └── RequiredPermissionsLinksTest.php
|
||||||
|
└── Unit/
|
||||||
|
├── TenantRequiredPermissionsFreshnessTest.php
|
||||||
|
└── TenantRequiredPermissionsOverallStatusTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Web application (Laravel + Filament admin panel). Changes are localized to the Filament Page, its view-model builder, Blade view, and new targeted tests.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (complete)
|
||||||
|
|
||||||
|
- Consolidated repo reality (existing canonical route, current tenant resolution fallback, current view-model behavior) and made explicit decisions in [research.md](research.md).
|
||||||
|
- No remaining NEEDS CLARIFICATION items for Spec 083.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (complete)
|
||||||
|
|
||||||
|
- Data model notes captured in [data-model.md](data-model.md).
|
||||||
|
- Route/semantics contract captured in [contracts/routes.md](contracts/routes.md).
|
||||||
|
- Developer quickstart captured in [quickstart.md](quickstart.md).
|
||||||
|
|
||||||
|
## Constitution Check (post-design re-check)
|
||||||
|
|
||||||
|
- **Tenant isolation / deny-as-not-found**: PASS (design enforces explicit 404 for non-member/non-entitled).
|
||||||
|
- **Cross-plane separation**: PASS (design removes `Tenant::current()` fallback on this surface).
|
||||||
|
- **Read/write separation**: PASS (read-only page; mutation remains capability-gated on other surfaces).
|
||||||
|
- **DB-only render**: PASS (stored `tenant_permissions` + derived freshness).
|
||||||
|
- **Filament action contract**: PASS (page is read-only; no list/table actions introduced).
|
||||||
|
|
||||||
|
## Phase 1 — Agent context update (required)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.specify/scripts/bash/update-agent-context.sh copilot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2 — Implementation plan (input for tasks.md)
|
||||||
|
|
||||||
|
1. **Authorization + 404 semantics (page entry)**
|
||||||
|
- Update `App\Filament\Pages\TenantRequiredPermissions` to enforce deny-as-not-found (404) when:
|
||||||
|
- workspace not selected / tenant not found / tenant not in workspace
|
||||||
|
- actor not workspace member
|
||||||
|
- actor not tenant-entitled (`User::canAccessTenant($tenant)` false)
|
||||||
|
- Ensure the checks run on initial page mount, not only in navigation gating.
|
||||||
|
|
||||||
|
2. **Remove cross-plane tenant fallback**
|
||||||
|
- Make `resolveScopedTenant()` strict: only resolve from route `{tenant}` (bound model or `external_id` lookup). If absent/invalid → treat as not found.
|
||||||
|
|
||||||
|
3. **DB-only render guarantees**
|
||||||
|
- Confirm the view-model builder continues to call `TenantPermissionService::compare(... liveCheck:false ...)`.
|
||||||
|
- Add tests to ensure no outbound HTTP is performed during render.
|
||||||
|
|
||||||
|
4. **Issues-first UX + canonical CTAs**
|
||||||
|
- Update the Blade view to present:
|
||||||
|
- Summary (overall, counts, freshness)
|
||||||
|
- Issues (Blockers + Warnings only; no separate “Error” category)
|
||||||
|
- Passed / Technical details (de-emphasized, Technical collapsed by default)
|
||||||
|
- Add a dedicated empty-data state (“Keine Daten verfügbar”) with a links-only CTA to start verification.
|
||||||
|
- Update “Re-run verification” / “Start verification” link-only CTA to point canonical to `/admin/onboarding` via route helper generation.
|
||||||
|
|
||||||
|
5. **Freshness / stale detection**
|
||||||
|
- Extend the view-model to include:
|
||||||
|
- `last_refreshed_at` derived from stored `tenant_permissions.last_checked_at` (max)
|
||||||
|
- `is_stale` (missing OR > 30 days)
|
||||||
|
- Update overall status derivation to include stale as a warning.
|
||||||
|
|
||||||
|
6. **Tests (Pest) — minimum set**
|
||||||
|
- Feature tests for:
|
||||||
|
- 404 for non-workspace-member
|
||||||
|
- 404 for workspace-member but not tenant-entitled
|
||||||
|
- 200 for tenant-entitled read-only
|
||||||
|
- empty-data state (“Keine Daten verfügbar”) with canonical start-verification CTA
|
||||||
|
- 404 for legacy route `/admin/t/{tenant}/required-permissions`
|
||||||
|
- 404 when route tenant missing/invalid (no fallback)
|
||||||
|
- Summary status mapping + stale threshold
|
||||||
|
- Technical details rendered after Issues/Passed and collapsed by default
|
||||||
|
- “Re-run verification” links to `/admin/onboarding`
|
||||||
|
|
||||||
|
7. **Scope boundary for FR-083-009**
|
||||||
|
- This feature does not modify mutation endpoints.
|
||||||
|
- Capability-based 403 enforcement remains on the linked target surfaces and is treated as an explicit dependency, not newly implemented behavior in Spec 083.
|
||||||
|
|
||||||
|
8. **Formatting + verification**
|
||||||
|
- Run `vendor/bin/sail bin pint --dirty`.
|
||||||
|
- Run the targeted tests via `vendor/bin/sail artisan test --compact ...`.
|
||||||
31
specs/083-required-permissions-hardening/quickstart.md
Normal file
31
specs/083-required-permissions-hardening/quickstart.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Quickstart — Spec 083
|
||||||
|
|
||||||
|
## Dev prerequisites
|
||||||
|
- Run via Sail (local): Docker + `vendor/bin/sail` available.
|
||||||
|
|
||||||
|
## What to validate
|
||||||
|
|
||||||
|
### Route semantics
|
||||||
|
- Canonical (must exist): `GET /admin/tenants/{tenant}/required-permissions`
|
||||||
|
- Legacy (must 404): `GET /admin/t/{tenant}/required-permissions`
|
||||||
|
|
||||||
|
### Authorization semantics
|
||||||
|
- Non-workspace-member → 404
|
||||||
|
- Workspace-member but not tenant-entitled → 404
|
||||||
|
- Tenant-entitled (including read-only) → 200
|
||||||
|
|
||||||
|
### Render behavior
|
||||||
|
- Page render uses stored DB data only (no Graph / no outbound HTTP).
|
||||||
|
- If no stored permission data exists, page shows "Keine Daten verfügbar" with a canonical CTA to `/admin/onboarding`.
|
||||||
|
- "Technical details" appears after Issues/Passed and is collapsed by default.
|
||||||
|
|
||||||
|
## Run targeted tests
|
||||||
|
- `vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions/*`
|
||||||
|
- (Exact filenames to be created in Phase 2 tasks.)
|
||||||
|
|
||||||
|
## Manual smoke test
|
||||||
|
1. Log in to admin panel.
|
||||||
|
2. Select a workspace.
|
||||||
|
3. Open `/admin/tenants/{external_id}/required-permissions` for a tenant you are a member of.
|
||||||
|
4. Confirm Summary + Issues-first layout and that "Re-run verification" links to `/admin/onboarding`.
|
||||||
|
5. As a user without tenant entitlement, confirm the same URL returns 404.
|
||||||
63
specs/083-required-permissions-hardening/research.md
Normal file
63
specs/083-required-permissions-hardening/research.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Research — Spec 083 (Required Permissions hardening)
|
||||||
|
|
||||||
|
## Context recap
|
||||||
|
- Canonical manage-plane surface already exists as a Filament Page: `App\Filament\Pages\TenantRequiredPermissions` with slug `tenants/{tenant}/required-permissions` (admin panel).
|
||||||
|
- A legacy tenant-plane path prefix exists (`/admin/t/...`) via the tenant panel; spec requires `/admin/t/{tenant}/required-permissions` to remain non-existent and return 404.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1 — Canonical route stays `/admin/tenants/{tenant}/required-permissions`
|
||||||
|
- **Chosen**: Keep the canonical manage URL exactly as specified in Spec 083.
|
||||||
|
- **Rationale**: Already aligned with the existing page slug and with the established route contract in Spec 080.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Redirect from `/admin/t/...` → rejected (spec requires 404, no redirect).
|
||||||
|
|
||||||
|
### Decision 2 — Deny-as-not-found is implemented explicitly (404), not via “canAccess() only”
|
||||||
|
- **Chosen**: Enforce 404 using explicit `abort(404)` checks on request entry (e.g., `mount()`), instead of relying solely on Filament’s `canAccess()` return value.
|
||||||
|
- **Rationale**: Filament’s `canAccess()` may produce behavior that is not guaranteed to be a 404. Spec 083 requires strict 404 semantics for non-members / non-entitled.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Only `canAccess()` returning false → rejected (status code semantics uncertain).
|
||||||
|
- Route-level middleware on just this page → possible, but still needs explicit entitlement checks; can be added later if desired.
|
||||||
|
|
||||||
|
### Decision 3 — Tenant entitlement is checked via `User::canAccessTenant($tenant)`
|
||||||
|
- **Chosen**: Use `User::canAccessTenant()` for tenant entitlement (no workspace-wide “view all tenants” override).
|
||||||
|
- **Rationale**: This matches existing patterns across the codebase, uses `tenant_memberships`, and aligns with the clarification outcome.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Workspace membership only → rejected (Spec 083 requires tenant entitlement).
|
||||||
|
- Capability checks for read-only view → rejected (read-only access is entitlement-only; mutations are capability-gated elsewhere).
|
||||||
|
|
||||||
|
### Decision 4 — Remove cross-plane tenant fallback (`Tenant::current()`) from this surface
|
||||||
|
- **Chosen**: `TenantRequiredPermissions::resolveScopedTenant()` must be strict: resolve only from route parameter `{tenant}` (external_id or bound model). If absent/invalid → 404.
|
||||||
|
- **Rationale**: `Tenant::current()` can leak cross-plane context and violates FR-083-007.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Keep fallback for convenience → rejected (security hardening goal).
|
||||||
|
|
||||||
|
### Decision 5 — DB-only render is guaranteed by using stored `tenant_permissions`
|
||||||
|
- **Chosen**: Continue using `TenantPermissionService::compare(... liveCheck:false ...)` for this page (no Graph calls).
|
||||||
|
- **Rationale**: With `liveCheck=false`, compare reads stored `tenant_permissions` only.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Allow live-check on render → rejected (violates FR-083-010).
|
||||||
|
|
||||||
|
### Decision 6 — Freshness (“Last refreshed”) comes from `tenant_permissions.last_checked_at`
|
||||||
|
- **Chosen**: Define page “Last refreshed” as the max timestamp of stored permission checks for the tenant. Stale if missing or older than 30 days.
|
||||||
|
- **Rationale**: This is already stored in the database and does not require provider calls.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Use latest verification run timestamps → possible, but increases coupling; not necessary for Spec 083.
|
||||||
|
|
||||||
|
### Decision 7 — Summary status logic is centralized in the view-model builder
|
||||||
|
- **Chosen**: Update `TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus()` so:
|
||||||
|
- Blocked if any missing application permission (blocker)
|
||||||
|
- Else Needs attention if any warning exists (missing delegated, error rows folded into warning, or stale freshness)
|
||||||
|
- Else Ready
|
||||||
|
- **Rationale**: Aligns with Spec 083 summary rules and keeps mapping centralized.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Compute in Blade view → rejected (harder to test, risks drift).
|
||||||
|
|
||||||
|
### Decision 8 — “Re-run verification” CTA links to `/admin/onboarding` (“Start verification” surface)
|
||||||
|
- **Chosen**: Link-only CTA points to the existing onboarding wizard page (admin panel slug `onboarding`).
|
||||||
|
- **Rationale**: Clarification outcome; capability gating occurs on the start/execute surface, not on this read-only page.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Link to provider connection edit → rejected (not the requested primary action).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
None remaining for Spec 083 (clarifications already settled).
|
||||||
214
specs/083-required-permissions-hardening/spec.md
Normal file
214
specs/083-required-permissions-hardening/spec.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# Feature Specification: Canonical Required Permissions (Manage) Hardening & Enterprise UX
|
||||||
|
|
||||||
|
**Feature Branch**: `083-required-permissions-hardening`
|
||||||
|
**Created**: 2026-02-08
|
||||||
|
**Status**: Ready for implementation
|
||||||
|
**Input**: User description: "Harden the canonical Required Permissions manage surface: enforce tenant entitlement, keep legacy URL non-existent (404), remove cross-plane fallbacks, and improve issues-first UX without any provider calls."
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-08
|
||||||
|
|
||||||
|
- Q: Soll die optionale Workspace-weite Ausnahme „alle Tenants ansehen“ (ohne TenantMembership) Teil von Spec 083 sein? → A: Nein. Spec 083 basiert ausschließlich auf Tenant-Entitlement; kein „view all tenants“ Override.
|
||||||
|
- Q: Wie genau soll die Summary-Status-Logik (Blocked / Needs attention / Ready) definiert werden? → A: Blocked wenn mind. 1 Blocker; sonst Needs attention wenn mind. 1 Warning (inkl. stale); sonst Ready.
|
||||||
|
- Q: Ab wann gilt „Freshness“ als „stale“ (Warning)? → A: Warnung, wenn „Last refreshed“ fehlt oder älter als 30 Tage ist.
|
||||||
|
- Q: Soll die Seite einen expliziten „Error“-Issue-Typ anzeigen, oder nur Blocker/Warnings basierend auf gespeicherten Permission-Daten? → A: Kein „Error“-Issue-Typ in Spec 083. Nur Blocker (missing application) + Warnings (delegated/stale/unknown).
|
||||||
|
- Q: Wohin soll der links-only CTA „Re-run verification“ canonical führen? → A: Zur „Start verification“ Surface (Wizard/Startseite), damit ein neuer Run gestartet werden kann (capability-gated dort).
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - Required Permissions sicher ansehen (Priority: P1)
|
||||||
|
|
||||||
|
Als Workspace-Mitglied mit Tenant-Entitlement möchte ich die "Required Permissions" Seite eines Tenants öffnen, um sofort zu erkennen, ob administrative Berechtigungen fehlen (Blocker) oder ob nur Hinweise/Warnings bestehen — ohne dass dadurch externe Provider-Aufrufe ausgelöst werden.
|
||||||
|
|
||||||
|
**Why this priority**: Das ist die primäre, risikorelevante Enterprise-UX: Security- und Operations-Teams müssen schnell und sicher einschätzen können, ob Handlungsbedarf besteht.
|
||||||
|
|
||||||
|
**Independent Test**: Kann vollständig über einen einzelnen GET-Aufruf auf die Canonical-URL getestet werden, inklusive 200/404 Semantik, UI-Sektionen und „keine externen Calls“.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** ein User ist Workspace-Mitglied und tenant-entitled, **When** er die Canonical-URL für den Tenant öffnet, **Then** erhält er 200 und sieht eine issues-first Zusammenfassung (Summary → Issues → Passed → Technical).
|
||||||
|
2. **Given** die Seite wird aufgerufen, **When** sie gerendert wird, **Then** werden keine externen Provider-Anfragen ausgelöst (nur gespeicherte Daten werden verwendet).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Next steps finden, ohne Mutationsrechte zu benötigen (Priority: P2)
|
||||||
|
|
||||||
|
Als tenant-entitled User möchte ich auf der Seite klare "Next steps" sehen (links-only), um fehlende Berechtigungen zu beheben oder eine erneute Verifikation anzustoßen, ohne dass ich selbst zwingend Mutationsrechte habe.
|
||||||
|
|
||||||
|
**Why this priority**: In Enterprise-Umgebungen sind Rollen getrennt: Viewer müssen Probleme erkennen und korrekt eskalieren können, ohne selbst Änderungen durchführen zu dürfen.
|
||||||
|
|
||||||
|
**Independent Test**: Kann über Render-Assertions getestet werden: Issue-Karten enthalten ausschließlich Links zu passenden Folgeseiten, und die Links sind canonical.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** es existieren Blocker/Warnings, **When** die Seite gerendert wird, **Then** enthält jede Issue eine klare, links-only Handlungsempfehlung (z.B. „Admin consent dokumentieren“, „Verifikation erneut starten“, „Provider-Verbindung verwalten“).
|
||||||
|
2. **Given** Next-step Links werden angezeigt, **When** die URLs geprüft werden, **Then** verweisen sie auf die canonical Manage-Surfaces und nicht auf Legacy-Tenant-Plane URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Tenant-Discovery verhindern (Deny-as-not-found) (Priority: P3)
|
||||||
|
|
||||||
|
Als Security Owner möchte ich, dass Workspace-Mitglieder ohne Tenant-Entitlement weder über URL-Varianten noch über Fehlermeldungen Hinweise auf die Existenz eines Tenants oder dessen Security-Posture erhalten.
|
||||||
|
|
||||||
|
**Why this priority**: Verhindert Tenant-Leakage und erzwingt eine konsistente Enterprise-Sicherheitsposition.
|
||||||
|
|
||||||
|
**Independent Test**: Kann isoliert über negative Access-Tests (404 Semantik) für verschiedene Benutzerzustände getestet werden.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** ein User ist Workspace-Mitglied ohne Tenant-Entitlement, **When** er die canonical Required-Permissions URL eines Tenants aufruft, **Then** erhält er 404 (deny-as-not-found).
|
||||||
|
2. **Given** ein User ruft eine Legacy-Tenant-Plane URL-Variante auf, **When** der Request verarbeitet wird, **Then** ist das Ergebnis 404 (keine Redirects, keine Aliases).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Tenant-ID ist syntaktisch ungültig oder verweist auf keinen Tenant → 404.
|
||||||
|
- Tenant gehört nicht zum aktuell selektierten Workspace → 404.
|
||||||
|
- Workspace ist nicht selektiert / User ist kein Workspace-Mitglied → 404.
|
||||||
|
- Es existieren keine gespeicherten Daten (noch nie verifiziert / gelöscht) → Seite erklärt „keine Daten verfügbar“ und verlinkt zur Verifikation.
|
||||||
|
- Daten sind alt (stale) → Warning + Link zu „erneut verifizieren“.
|
||||||
|
- Freshness ist unbekannt (kein „Last refreshed“) → Warning + Link zu „erneut verifizieren“.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||||
|
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||||
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
|
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||||
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
|
- explicitly define 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right functional requirements.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
#### Surfaces & Routing
|
||||||
|
|
||||||
|
- **FR-083-001**: Die Required-Permissions Oberfläche MUSS ausschließlich auf der canonical Manage-URL verfügbar sein: `GET /admin/tenants/{tenant}/required-permissions`.
|
||||||
|
- **FR-083-002**: Eine Legacy-Tenant-Plane Variante MUSS nicht existieren und MUSS 404 liefern: `GET /admin/t/{tenant}/required-permissions` (keine Redirects, keine Aliases).
|
||||||
|
|
||||||
|
#### Authorization (Enterprise Hardening)
|
||||||
|
|
||||||
|
- **FR-083-003**: Die Seite MUSS deny-as-not-found (404) verwenden, wenn der User kein Workspace-Mitglied ist.
|
||||||
|
- **FR-083-004**: Die Seite MUSS deny-as-not-found (404) verwenden, wenn der User Workspace-Mitglied ist, aber kein Tenant-Entitlement besitzt.
|
||||||
|
- **FR-083-005**: Die Seite MUSS 200 liefern, wenn der User Workspace-Mitglied ist und Tenant-Entitlement besitzt (inkl. Readonly-Entitlement).
|
||||||
|
- **FR-083-006**: Der Route-Parameter `{tenant}` MUSS vorhanden sein und einem Tenant im aktuell selektierten Workspace entsprechen; fehlt der Parameter oder ist er ungültig, MUSS 404 zurückgegeben werden.
|
||||||
|
- **FR-083-007**: Die Seite MUSS strikt an den URL-Tenant gebunden sein; es darf keinen impliziten Fallback auf einen „aktuellen“ Tenant-Kontext geben.
|
||||||
|
|
||||||
|
#### 404 vs 403 Semantik (RBAC-UX)
|
||||||
|
|
||||||
|
- **FR-083-008**: 404-Antworten bei Membership-/Entitlement-Denial MÜSSEN generisch bleiben und dürfen keinen Ablehnungsgrund offenlegen (kein Tenant-Leakage).
|
||||||
|
- **FR-083-009**: Falls auf der Seite Aktionen/Mutations verlinkt werden (z.B. „Verifikation starten“), MUSS die eigentliche Mutation server-seitig capability-gated sein und bei fehlender Fähigkeit 403 liefern. Die Required-Permissions Seite selbst bleibt read-only; die 403-Durchsetzung wird auf den Ziel-Surfaces umgesetzt (kein zusätzlicher Mutations-Endpunkt in Spec 083).
|
||||||
|
|
||||||
|
#### Data Source & External Calls
|
||||||
|
|
||||||
|
- **FR-083-010**: Das Anzeigen der Seite MUSS ausschließlich gespeicherte Daten verwenden und darf keine externen Provider-Aufrufe auslösen.
|
||||||
|
|
||||||
|
#### UX (Issues-first)
|
||||||
|
|
||||||
|
- **FR-083-011**: Die Seite MUSS oben eine Summary zeigen, die die Gesamtlage verständlich einordnet (z.B. „Blocked / Needs attention / Ready“) und die wichtigsten Counts enthält.
|
||||||
|
- **FR-083-011a**: Die Summary-Status-Logik MUSS eindeutig sein: **Blocked** wenn mindestens ein Blocker vorliegt; sonst **Needs attention** wenn mindestens ein Warning vorliegt (inkl. „stale“); sonst **Ready**.
|
||||||
|
- **FR-083-012**: Die Seite MUSS prominent eine Issues-Sektion bereitstellen, die Blocker (fehlende Application-Berechtigungen) und Warnings (z.B. delegated gaps, stale data) priorisiert.
|
||||||
|
- **FR-083-012a**: Die Issues-Sektion MUSS sich in Spec 083 auf **Blocker** und **Warnings** beschränken; ein separater „Error“-Issue-Typ ist nicht Teil des Umfangs.
|
||||||
|
- **FR-083-013**: Jede Issue MUSS links-only Next steps enthalten (keine eingebetteten Mutations) und klar zwischen „Beheben“ und „erneut verifizieren“ unterscheiden.
|
||||||
|
- **FR-083-013a**: Der links-only CTA „Re-run verification“ MUSS canonical zur „Start verification“ Surface `/admin/onboarding` führen und über zentrale Route-Generierung erstellt werden (kein hardcodierter Legacy-Pfad). Die capability-basierte Durchsetzung (403) erfolgt dort, nicht auf der Required-Permissions Seite.
|
||||||
|
- **FR-083-014**: Die Seite MUSS einen Hinweis enthalten, dass die Anzeige auf gespeicherten Daten basiert, inkl. Freshness/Last refreshed Information, sofern aus gespeicherten Daten ableitbar.
|
||||||
|
- **FR-083-014a**: Freshness MUSS als Warning gelten, wenn „Last refreshed“ fehlt oder älter als 30 Tage ist.
|
||||||
|
- **FR-083-014b**: Wenn keine gespeicherten Permission-Daten vorhanden sind, MUSS die Seite einen klaren Empty State („Keine Daten verfügbar“) rendern und einen links-only CTA zur Start-verification Surface anzeigen.
|
||||||
|
- **FR-083-015**: „Technical details“ MUSS verfügbar sein, aber nachrangig: die Sektion MUSS nach „Issues“ und „Passed“ erscheinen und standardmäßig eingeklappt sein.
|
||||||
|
|
||||||
|
#### Link Consistency
|
||||||
|
|
||||||
|
- **FR-083-016**: In-App Links zur Required-Permissions Oberfläche MÜSSEN canonical sein und konsistent generiert werden (keine hardcodierten Legacy-Pfade).
|
||||||
|
|
||||||
|
#### Dependencies & Assumptions
|
||||||
|
|
||||||
|
- **FR-083-017**: Die Seite baut auf existierenden Manage-Surfaces für Tenants, Verifikation und Provider-Verbindungen auf (nur Verlinkung; keine neue Surface wird dadurch eingeführt).
|
||||||
|
- **FR-083-018**: Es existiert ein Konzept von Workspace-Mitgliedschaft und Tenant-Entitlement; Entitlement ist die Voraussetzung für read-only Zugriff.
|
||||||
|
|
||||||
|
#### Test Requirements (Mandatory)
|
||||||
|
|
||||||
|
- **T-083-001**: Kein Workspace-Mitglied → 404.
|
||||||
|
- **T-083-002**: Workspace-Mitglied ohne Tenant-Entitlement → 404.
|
||||||
|
- **T-083-003**: Tenant-entitled User (Readonly) → 200.
|
||||||
|
- **T-083-004**: Keine gespeicherten Daten → Seite zeigt „Keine Daten verfügbar“ und einen canonical CTA zur Start-verification Surface.
|
||||||
|
- **T-083-005**: DB-only Render: canonical URL rendert ohne externe Provider-Requests und ohne Hintergrundarbeit auszulösen.
|
||||||
|
- **T-083-006**: Legacy URL bleibt 404: `/admin/t/{tenant}/required-permissions`.
|
||||||
|
- **T-083-007**: Link canonicalization: Next steps enthalten ausschließlich canonical Manage-Links.
|
||||||
|
- **T-083-008**: Cross-plane fallback Regression: Aufruf ohne gültigen Route-Tenant darf keinen impliziten „aktuellen Tenant“ nutzen → 404.
|
||||||
|
- **T-083-009**: Summary-Status-Logik: Blocker → „Blocked“; nur Warnings/Stale → „Needs attention“; keine Issues → „Ready“.
|
||||||
|
- **T-083-010**: Stale-Threshold: „Last refreshed“ fehlt oder älter als 30 Tage → Warning; jünger/gleich 30 Tage → kein Freshness-Warning.
|
||||||
|
- **T-083-011**: Issues-Typen: Seite zeigt keine separate „Error“-Issue-Kategorie (nur Blocker + Warnings).
|
||||||
|
- **T-083-012**: „Re-run verification“ Link führt canonical zur „Start verification“ Surface (kein Link auf „latest report“ als Primärziel).
|
||||||
|
- **T-083-013**: „Technical details“ ist standardmäßig eingeklappt und erscheint nach „Issues“ und „Passed“.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
Für jede betroffene UI-Oberfläche: liste die sichtbaren Actions/CTAs, ob sie destruktiv sind (Bestätigung erforderlich),
|
||||||
|
welche Autorisierung gilt (Entitlement vs. Fähigkeit für Mutationen), und ob ein Audit-Eintrag erwartet wird.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Admin Page: Required Permissions | Admin → Tenants → Required permissions | None (read-only) | N/A | None | None | Links-only: “Start verification”, “Manage provider connection” | N/A | N/A | No (view-only) | Verlinkte Mutations/Aktionen liegen auf anderen Surfaces und müssen dort 403/capability-gated sein |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Workspace**: Sicherheits- und Sichtbarkeitsgrenze; ein User muss Mitglied sein, um Tenant-Surfaces überhaupt sehen zu können.
|
||||||
|
- **Tenant**: Mandant im Workspace; Required Permissions sind tenant-spezifisch.
|
||||||
|
- **Workspace Membership**: Belegt, dass ein User zum Workspace gehört.
|
||||||
|
- **Tenant Entitlement (Tenant Membership)**: Belegt, dass ein User in diesem Tenant lesen darf (inkl. Readonly).
|
||||||
|
- **Permission Inventory Snapshot**: Gespeicherte Datenbasis, aus der Required-Permissions Status/Issues abgeleitet werden.
|
||||||
|
- **Verification Evidence / Report**: Gespeicherte Ergebnisse, die Freshness/Last refreshed und Issues erklären und auf „erneut verifizieren“ verlinken.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Define measurable success criteria.
|
||||||
|
These must be technology-agnostic and measurable.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-083-001**: 100% der Requests von nicht-entitled Workspace-Mitgliedern auf die Required-Permissions Seite enden in 404 (kein Tenant-Leakage über Statuscodes).
|
||||||
|
- **SC-083-002**: 100% der Requests auf die Legacy-URL-Variante `/admin/t/{tenant}/required-permissions` enden in 404 (keine Redirects).
|
||||||
|
- **SC-083-003**: Beim Anzeigen der Seite werden 0 externe Provider-Anfragen ausgelöst (verifizierbar über Tests/Instrumentation).
|
||||||
|
- **SC-083-004**: Tenant-entitled Nutzer können in ≤ 30 Sekunden mindestens einen Blocker identifizieren und den passenden Next-step Link finden (Usability/UX-Verifikation).
|
||||||
|
- **SC-083-005**: In Staging liegt für `GET /admin/tenants/{tenant}/required-permissions` bei einer typischen Tenant-Datenmenge (mindestens 200 gespeicherte Permission-Zeilen) die p95-Server-Antwortzeit bei ≤ 500 ms (DB-only, ohne externe Provider-Calls).
|
||||||
162
specs/083-required-permissions-hardening/tasks.md
Normal file
162
specs/083-required-permissions-hardening/tasks.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Spec 083-required-permissions-hardening"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: 083-required-permissions-hardening
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/083-required-permissions-hardening/`
|
||||||
|
|
||||||
|
- Spec: [spec.md](spec.md)
|
||||||
|
- Plan: [plan.md](plan.md)
|
||||||
|
- Research: [research.md](research.md)
|
||||||
|
- Data model: [data-model.md](data-model.md)
|
||||||
|
- Contracts: [contracts/routes.md](contracts/routes.md)
|
||||||
|
- Quickstart: [quickstart.md](quickstart.md)
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) — runtime behavior changes.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
- [X] T001 Run prerequisites check via .specify/scripts/bash/check-prerequisites.sh --json
|
||||||
|
- [X] T002 Ensure agent context is up to date via .specify/scripts/bash/update-agent-context.sh copilot
|
||||||
|
- [X] T003 [P] Create feature test directory tests/Feature/RequiredPermissions/ (add .gitkeep if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
- [X] T004 Review current canonical page implementation in app/Filament/Pages/TenantRequiredPermissions.php (identify tenant fallback + current access checks)
|
||||||
|
- [X] T005 [P] Review existing DB-only render guard patterns in tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php (copy the Http::preventStrayRequests() approach)
|
||||||
|
- [X] T006 [P] Review existing cross-plane 404 patterns in tests/Feature/Auth/CrossScopeAccessTest.php (align with 404 semantics)
|
||||||
|
- [X] T007 [P] Confirm factories exist for required models (Workspace, WorkspaceMembership, Tenant, TenantMembership, TenantPermission, User) under database/factories/
|
||||||
|
|
||||||
|
**Checkpoint**: Foundational ready — implement US1/US2/US3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Required Permissions sicher ansehen (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Canonical manage surface renders issues-first from DB-only state with correct 200/404 semantics.
|
||||||
|
|
||||||
|
**Independent Test**: A single GET to `/admin/tenants/{external_id}/required-permissions` returns 200 for tenant-entitled users and triggers no outbound HTTP.
|
||||||
|
|
||||||
|
### Tests (US1)
|
||||||
|
|
||||||
|
- [X] T008 [P] [US1] Add DB-only render test in tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php
|
||||||
|
- [X] T009 [P] [US1] Add happy-path entitlement test (tenant-entitled → 200) in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
|
||||||
|
- [X] T030 [P] [US1] Add empty-data state test ("Keine Daten verfügbar" + Start verification CTA) in tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php
|
||||||
|
- [X] T031 [P] [US1] Add test that "Technical details" is rendered after Issues/Passed and is collapsed by default in tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
|
||||||
|
|
||||||
|
### Implementation (US1)
|
||||||
|
|
||||||
|
- [X] T010 [US1] Enforce explicit 404 denial rules on page entry in app/Filament/Pages/TenantRequiredPermissions.php (workspace selected, tenant in workspace, workspace member, tenant-entitled)
|
||||||
|
- [X] T011 [US1] Remove cross-plane fallback by making resolveScopedTenant() strict (no Tenant::current()) in app/Filament/Pages/TenantRequiredPermissions.php
|
||||||
|
- [X] T012 [US1] Add freshness derivation (last_refreshed_at, is_stale) based on tenant_permissions.last_checked_at in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||||
|
- [X] T013 [US1] Update summary overall status derivation to treat stale freshness as a warning (Blocked > Needs attention > Ready) in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||||
|
- [X] T014 [US1] Render Summary → Issues → Passed → Technical layout (issues-first) using viewModel fields in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||||
|
- [X] T032 [US1] Render explicit empty-data state and keep "Technical details" collapsed by default in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Next steps finden, ohne Mutationsrechte zu benötigen (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Each issue includes link-only next steps that point to canonical manage surfaces; re-run verification links to Start verification.
|
||||||
|
|
||||||
|
**Independent Test**: Page renders next-step links that are canonical and the “Re-run verification” CTA points to `/admin/onboarding`.
|
||||||
|
|
||||||
|
### Tests (US2)
|
||||||
|
|
||||||
|
- [X] T015 [P] [US2] Add CTA/link assertion test for re-run verification pointing to /admin/onboarding in tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
|
||||||
|
- [X] T016 [P] [US2] Add test asserting no legacy tenant-plane links are emitted (no /admin/t/...) in tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
|
||||||
|
|
||||||
|
### Implementation (US2)
|
||||||
|
|
||||||
|
- [X] T017 [US2] Change reRunVerificationUrl() to return the canonical Start verification surface via route helper (target: /admin/onboarding) in app/Filament/Pages/TenantRequiredPermissions.php
|
||||||
|
- [X] T018 [US2] Ensure issue cards only contain link-only next steps and canonical manage URLs in resources/views/filament/pages/tenant-required-permissions.blade.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Tenant-Discovery verhindern (Deny-as-not-found) (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Non-entitled users cannot discover tenant existence/posture via status codes or legacy routes.
|
||||||
|
|
||||||
|
**Independent Test**: Requests for non-members/non-entitled return 404, and legacy `/admin/t/{tenant}/required-permissions` is 404.
|
||||||
|
|
||||||
|
### Tests (US3)
|
||||||
|
|
||||||
|
- [X] T019 [P] [US3] Add test: workspace-member but not tenant-entitled → 404 in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
|
||||||
|
- [X] T020 [P] [US3] Add test: not a workspace member → 404 in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
|
||||||
|
- [X] T021 [P] [US3] Add test: legacy /admin/t/{tenant}/required-permissions returns 404 in tests/Feature/RequiredPermissions/RequiredPermissionsLegacyRouteTest.php
|
||||||
|
- [X] T022 [P] [US3] Add regression test: route tenant invalid does not fall back to a current tenant context (still 404) in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
|
||||||
|
|
||||||
|
### Implementation (US3)
|
||||||
|
|
||||||
|
- [X] T023 [US3] Ensure all deny-as-not-found conditions abort(404) (not 403) in app/Filament/Pages/TenantRequiredPermissions.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T024 [P] Update existing unit coverage for overall status if signature/logic changed in tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
|
||||||
|
- [X] T025 [P] Add new unit tests for freshness/stale threshold (missing or >30 days) in tests/Unit/TenantRequiredPermissionsFreshnessTest.php
|
||||||
|
- [X] T026 Run formatting via vendor/bin/sail bin pint --dirty
|
||||||
|
- [X] T027 Run targeted tests via vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
|
||||||
|
- [X] T028 Run targeted unit tests via vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissions
|
||||||
|
- [X] T029 Validate quickstart steps remain accurate in specs/083-required-permissions-hardening/quickstart.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### User Story completion order
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
P1[US1: View canonical page safely] --> P2[US2: Canonical next steps links]
|
||||||
|
P1 --> P3[US3: Deny-as-not-found + legacy 404]
|
||||||
|
P2 --> Polish[Polish & regression coverage]
|
||||||
|
P3 --> Polish
|
||||||
|
```
|
||||||
|
|
||||||
|
- Setup (T001–T003) → Foundational (T004–T007) → US1 (T008–T014, T030–T032) → US2 (T015–T018) + US3 (T019–T023) → Polish (T024–T029)
|
||||||
|
|
||||||
|
### Parallel opportunities
|
||||||
|
- Phase 1: T003 can run in parallel.
|
||||||
|
- Phase 2: T005–T007 are parallel.
|
||||||
|
- US1 tests (T008–T009, T030–T031) can be written in parallel.
|
||||||
|
- US2 tests (T015–T016) can be written in parallel.
|
||||||
|
- US3 tests (T019–T022) can be written in parallel.
|
||||||
|
- Polish: T024–T025 are parallel; T026–T028 are sequential validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel execution examples (per story)
|
||||||
|
|
||||||
|
### US1
|
||||||
|
- Run in parallel:
|
||||||
|
- T008: tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php
|
||||||
|
- T009: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
|
||||||
|
- T030: tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php
|
||||||
|
- T031: tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
|
||||||
|
|
||||||
|
### US2
|
||||||
|
- Run in parallel:
|
||||||
|
- T015: tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php (CTA)
|
||||||
|
- T016: tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php (no legacy links)
|
||||||
|
|
||||||
|
### US3
|
||||||
|
- Run in parallel:
|
||||||
|
- T019: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php (non-entitled 404)
|
||||||
|
- T020: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php (non-member 404)
|
||||||
|
- T021: tests/Feature/RequiredPermissions/RequiredPermissionsLegacyRouteTest.php
|
||||||
|
- T022: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php (no fallback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task completeness validation
|
||||||
|
|
||||||
|
- Every user story has:
|
||||||
|
- At least one independently runnable verification test task
|
||||||
|
- Implementation tasks with concrete file paths
|
||||||
|
- A clear checkpoint goal and independent test criteria
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Verification Surfaces Unification
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-09
|
||||||
|
**Feature**: [specs/084-verification-surfaces-unification/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The spec references “Filament” only to satisfy the required UI Action Matrix section; it does not prescribe implementation details beyond user-visible actions and authorization semantics.
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "operation-run-context.provider-connection-check.schema.json",
|
||||||
|
"title": "OperationRun Context — provider.connection.check",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"provider": { "type": "string", "minLength": 1 },
|
||||||
|
"module": { "type": "string", "minLength": 1 },
|
||||||
|
"provider_connection_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"target_scope": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"entra_tenant_id": { "type": "string", "minLength": 1 },
|
||||||
|
"entra_tenant_name": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["entra_tenant_id"]
|
||||||
|
},
|
||||||
|
"reason_code": { "type": "string", "minLength": 1 },
|
||||||
|
"next_steps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["label", "url"],
|
||||||
|
"properties": {
|
||||||
|
"label": { "type": "string", "minLength": 1 },
|
||||||
|
"url": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verification_report": {
|
||||||
|
"description": "Stored verification report document. For completed blocked runs, this MUST be present and schema-valid per verification-report.v1_5 schema.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["provider", "module", "target_scope"]
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
# Contracts — Verification Surfaces Unification (Spec 084)
|
||||||
|
|
||||||
|
This document describes the user action surfaces and canonical linking rules.
|
||||||
|
|
||||||
|
## Canonical run link
|
||||||
|
|
||||||
|
- Canonical run viewer route: `/admin/operations/{runId}`
|
||||||
|
- Helper: `OperationRunLinks::tenantlessView($runId)`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
- All verification surfaces MUST link to the canonical tenantless viewer.
|
||||||
|
- Tenantless run viewing MUST still enforce:
|
||||||
|
- workspace membership AND
|
||||||
|
- tenant entitlement (when the run is tenant-associated)
|
||||||
|
|
||||||
|
Missing either MUST be deny-as-not-found (404).
|
||||||
|
|
||||||
|
## Start surfaces
|
||||||
|
|
||||||
|
### Tenant detail — “Verify configuration”
|
||||||
|
|
||||||
|
- Trigger: Filament action on tenant view page.
|
||||||
|
- Behavior:
|
||||||
|
- Authorize capability.
|
||||||
|
- Start/dedupe `OperationRun` with `type = provider.connection.check`.
|
||||||
|
- Dispatch `ProviderConnectionHealthCheckJob` when newly created.
|
||||||
|
- Notify with “View run” (canonical URL).
|
||||||
|
|
||||||
|
### Onboarding — “Verify access”
|
||||||
|
|
||||||
|
- Trigger: Filament wizard action.
|
||||||
|
- Behavior:
|
||||||
|
- Authorize onboarding capability.
|
||||||
|
- Start/dedupe the same run type.
|
||||||
|
- Notify with “View run” (canonical URL).
|
||||||
|
|
||||||
|
## Viewer surfaces (DB-only)
|
||||||
|
|
||||||
|
### Tenant embedded viewer
|
||||||
|
|
||||||
|
- Select latest `provider.connection.check` run attempt for the tenant.
|
||||||
|
- States:
|
||||||
|
- Empty (no run yet): shows “Start verification” CTA.
|
||||||
|
- In progress (active run; no report yet): shows DB-only in-progress UI.
|
||||||
|
- Completed: shows stored `verification_report`.
|
||||||
|
|
||||||
|
### Operations run viewer
|
||||||
|
|
||||||
|
- Uses `OperationRun` as the source of truth.
|
||||||
|
- Verification report rendered from `context.verification_report` only.
|
||||||
|
|
||||||
|
## Blocked completion invariant
|
||||||
|
|
||||||
|
For `provider.connection.check` runs:
|
||||||
|
|
||||||
|
- If the run is completed with outcome `blocked`, `context.verification_report` MUST exist and be schema-valid.
|
||||||
|
- Viewers MUST NOT fabricate a report at render time.
|
||||||
82
specs/084-verification-surfaces-unification/data-model.md
Normal file
82
specs/084-verification-surfaces-unification/data-model.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Data Model — Verification Surfaces Unification (Spec 084)
|
||||||
|
|
||||||
|
This feature reuses existing persisted entities. The main change is a stronger invariant about `OperationRun.context.verification_report` for blocked verification completions.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
Source of truth for observability and verification report storage.
|
||||||
|
|
||||||
|
- Table: `operation_runs`
|
||||||
|
- Key fields (existing):
|
||||||
|
- `id` (int)
|
||||||
|
- `workspace_id` (int)
|
||||||
|
- `tenant_id` (int, nullable for some platform runs; verification is tenant-associated)
|
||||||
|
- `type` (string) — verification uses `provider.connection.check`
|
||||||
|
- `status` (string) — includes active vs completed
|
||||||
|
- `outcome` (string) — includes `succeeded`, `failed`, `blocked`
|
||||||
|
- `context` (json/jsonb) — stores `verification_report` and run metadata
|
||||||
|
- `failure_summary` (array/json)
|
||||||
|
- timestamps: `created_at`, `started_at`, `completed_at`
|
||||||
|
|
||||||
|
#### Verification context (provider connection check)
|
||||||
|
|
||||||
|
For `OperationRun.type = provider.connection.check`, the context is expected to include:
|
||||||
|
|
||||||
|
- `provider` (string)
|
||||||
|
- `module` (string)
|
||||||
|
- `provider_connection_id` (int)
|
||||||
|
- `target_scope` (object):
|
||||||
|
- `entra_tenant_id` (string)
|
||||||
|
- optionally `entra_tenant_name` (string)
|
||||||
|
- `verification_report` (object) — see “VerificationReport” below
|
||||||
|
|
||||||
|
Additionally, for blocked starts (preflight):
|
||||||
|
|
||||||
|
- `reason_code` (string)
|
||||||
|
- `next_steps` (array of `{label, url}`)
|
||||||
|
- `verification_report` MUST exist once the run is completed as `blocked`.
|
||||||
|
|
||||||
|
### VerificationReport (stored document)
|
||||||
|
|
||||||
|
A stored document under `operation_runs.context.verification_report`.
|
||||||
|
|
||||||
|
- Schema: `specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json`
|
||||||
|
- Key properties:
|
||||||
|
- `schema_version`
|
||||||
|
- `flow` (aligns with `OperationRun.type`)
|
||||||
|
- `generated_at`
|
||||||
|
- `fingerprint`
|
||||||
|
- `previous_report_id`
|
||||||
|
- `summary.overall`, `summary.counts`
|
||||||
|
- `checks[]`
|
||||||
|
|
||||||
|
#### Invariant introduced by this feature
|
||||||
|
|
||||||
|
- If a verification run completes with outcome `blocked`, `verification_report` MUST be present and schema-valid.
|
||||||
|
- For in-progress runs, `verification_report` may be absent until the job writes it.
|
||||||
|
|
||||||
|
### TenantOnboardingSession (existing)
|
||||||
|
|
||||||
|
Onboarding stores pointers to the current verification run.
|
||||||
|
|
||||||
|
- `state.verification_operation_run_id` (int)
|
||||||
|
- `state.provider_connection_id` (int)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- `OperationRun` belongs to `Tenant` (tenant-scoped verification)
|
||||||
|
- `VerificationCheckAcknowledgement` belongs to `OperationRun` (acknowledgements enrich viewer; unchanged)
|
||||||
|
|
||||||
|
## State transitions
|
||||||
|
|
||||||
|
For `provider.connection.check`:
|
||||||
|
|
||||||
|
- `queued/running` → `completed`
|
||||||
|
- Outcomes:
|
||||||
|
- `succeeded` when provider connection is healthy and permission inventory refresh succeeds
|
||||||
|
- `failed` when provider check fails (with `failure_summary`)
|
||||||
|
- `blocked` when prerequisites prevent starting (preflight), still with a stub report
|
||||||
|
|
||||||
|
No database schema migrations are expected for this feature.
|
||||||
131
specs/084-verification-surfaces-unification/plan.md
Normal file
131
specs/084-verification-surfaces-unification/plan.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Implementation Plan: Verification Surfaces Unification
|
||||||
|
|
||||||
|
**Branch**: `084-verification-surfaces-unification` | **Date**: 2026-02-09 | **Spec**: `specs/084-verification-surfaces-unification/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/084-verification-surfaces-unification/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Unify tenant “Verify configuration” and onboarding “Verify access” to the same `OperationRun`-based flow (`provider.connection.check`), with DB-only viewing and canonical tenantless run links. Ensure any **completed blocked** verification run persists a **schema-valid** `context.verification_report` stub so viewers never show “report unavailable” for blocked completions.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
|
||||||
|
**Storage**: PostgreSQL (JSONB-backed `OperationRun.context`)
|
||||||
|
**Testing**: Pest v4 (Feature tests), Filament/Livewire component testing where applicable
|
||||||
|
**Target Platform**: Web application (Sail-first local dev)
|
||||||
|
|
||||||
|
**Performance Goals**:
|
||||||
|
- Tenant detail + onboarding verification surfaces render DB-only with no external provider/Graph calls.
|
||||||
|
- Start action returns quickly (authorize → create/dedupe run → enqueue job → notify + “View run”).
|
||||||
|
|
||||||
|
**Constraints**:
|
||||||
|
- RBAC isolation: non-members are deny-as-not-found (404); members missing capability are 403 on execution.
|
||||||
|
- `OperationRun` active dedupe enforced (already handled via `OperationRunService::ensureRunWithIdentity()` + active-run checks).
|
||||||
|
|
||||||
|
**Scale/Scope**: Tenant-scoped verification for provider connection health + permission inventory refresh (existing behavior).
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first / Read-write separation: PASS (verification is an explicit user-triggered operation; viewing is read-only).
|
||||||
|
- Graph contract path: PASS (provider verification uses `ProviderGateway` + `GraphClientInterface`; no render-time Graph calls).
|
||||||
|
- Workspace/Tenant isolation: PASS (tenantless canonical views must still enforce workspace + tenant entitlement; missing either is 404).
|
||||||
|
- RBAC-UX 404/403 split: PASS (start is 403 for members missing capability; non-members 404; Livewire calls included).
|
||||||
|
- Run observability: PASS (verification is queued and tracked as `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only).
|
||||||
|
- Data minimization/safe logging: PASS (verification report stored in `OperationRun.context`; no secrets; next steps are link-only).
|
||||||
|
- Filament action safety: PASS (verification start uses `->action(...)`; any destructive action confirmations remain required).
|
||||||
|
|
||||||
|
No constitution violations are required for this feature.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/084-verification-surfaces-unification/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
└── contracts/
|
||||||
|
├── operation-run-context.provider-connection-check.schema.json
|
||||||
|
└── verification-surfaces.routes.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (existing, relevant)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── TenantResource.php
|
||||||
|
│ │ └── TenantResource/Pages/ViewTenant.php
|
||||||
|
│ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
│ └── Support/VerificationReportViewer.php
|
||||||
|
├── Jobs/ProviderConnectionHealthCheckJob.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── Providers/ProviderOperationStartGate.php
|
||||||
|
│ ├── OperationRunService.php
|
||||||
|
│ └── Verification/StartVerification.php
|
||||||
|
└── Support/
|
||||||
|
├── OperationRunLinks.php
|
||||||
|
└── Verification/VerificationReportWriter.php
|
||||||
|
resources/views/filament/components/verification-report-viewer.blade.php
|
||||||
|
resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research
|
||||||
|
|
||||||
|
### Key decisions (grounded in current code)
|
||||||
|
|
||||||
|
- Use `provider.connection.check` as the unified verification run type.
|
||||||
|
- Already used by onboarding (`ManagedTenantOnboardingWizard::startVerification()`) and by `StartVerification::providerConnectionCheck()`.
|
||||||
|
- Tenant verification start surfaces (tenant detail and tenant list actions) will be refactored to start/dedupe `provider.connection.check` via `StartVerification`, always resolve the tenant's default provider connection, and always offer a canonical “View run” link.
|
||||||
|
- `StartVerification` API changes remain non-breaking in this feature (keep existing explicit-connection method; add a tenant-default start helper rather than replacing signatures).
|
||||||
|
- Blocked runs must write a schema-valid stub report:
|
||||||
|
- Implement stub generation immediately after `OperationRunService::finalizeBlockedRun()` for the `provider.connection.check` operation type, using `VerificationReportWriter::write(...)`.
|
||||||
|
- Tenantless canonical run viewing for tenant-associated runs is a foundational blocker and must enforce workspace membership + tenant entitlement with deny-as-not-found semantics before user-story completion.
|
||||||
|
|
||||||
|
### Outputs
|
||||||
|
|
||||||
|
- `research.md` records decisions + rationale + alternatives.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts
|
||||||
|
|
||||||
|
### Data model (no DB migration expected)
|
||||||
|
|
||||||
|
- Store verification report exclusively in `operation_runs.context.verification_report` (existing pattern).
|
||||||
|
- Enforce: when a `provider.connection.check` run completes with outcome `blocked`, `context.verification_report` is present and schema-valid.
|
||||||
|
|
||||||
|
### Contracts
|
||||||
|
|
||||||
|
- `contracts/operation-run-context.provider-connection-check.schema.json`
|
||||||
|
- Documents expected `OperationRun.context` keys for the verification run type.
|
||||||
|
- `contracts/verification-surfaces.routes.md`
|
||||||
|
- Documents user actions → routes/surfaces and the canonical run viewer URL.
|
||||||
|
|
||||||
|
### Outputs
|
||||||
|
|
||||||
|
- `data-model.md`, `contracts/*`, `quickstart.md`.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Planning (for `/speckit.tasks`)
|
||||||
|
|
||||||
|
Planned work items to convert into `tasks.md`:
|
||||||
|
|
||||||
|
1. Refactor tenant verification actions (tenant detail + tenant list) to use the unified start path (`provider.connection.check`) with default connection resolution, returning started/deduped/busy outcomes with a canonical run URL.
|
||||||
|
2. Add tenant embedded verification viewer:
|
||||||
|
- Select latest `provider.connection.check` run attempt for the tenant.
|
||||||
|
- Show DB-only empty state when none exists.
|
||||||
|
- Show DB-only “in progress” state when active with no report yet.
|
||||||
|
3. Ensure blocked verification runs always store a schema-valid stub report:
|
||||||
|
- Post-`finalizeBlockedRun()` write via `VerificationReportWriter` for `provider.connection.check`.
|
||||||
|
4. Authorization + isolation (blocking):
|
||||||
|
- Non-members: 404 for tenant routes and tenantless operations viewer of tenant-associated runs.
|
||||||
|
- Members missing capability: UI visible-but-disabled; server returns 403.
|
||||||
|
5. Tests (Pest):
|
||||||
|
- Blocked start produces a completed blocked run with a schema-valid `verification_report`.
|
||||||
|
- Tenant page and onboarding viewer render from stored report only (no external calls during render).
|
||||||
|
- Tenant render path never persists permission inventory updates and never uses synchronous verification paths.
|
||||||
|
- Canonical run links point to `admin.operations.view` (tenantless).
|
||||||
57
specs/084-verification-surfaces-unification/quickstart.md
Normal file
57
specs/084-verification-surfaces-unification/quickstart.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Quickstart — Verification Surfaces Unification (Spec 084)
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
- Start containers: `vendor/bin/sail up -d`
|
||||||
|
- Run tests (targeted): `vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
|
## Manual verification (UI)
|
||||||
|
|
||||||
|
### 1) Tenant detail: start verification
|
||||||
|
|
||||||
|
1. Open a tenant record in the admin panel.
|
||||||
|
2. Use the header action: “Verify configuration”.
|
||||||
|
3. Confirm the action.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- A queued `OperationRun` of type `provider.connection.check` is created or deduped.
|
||||||
|
- A notification includes “View run”, linking to the canonical tenantless route `/admin/operations/{run}`.
|
||||||
|
|
||||||
|
### 1b) Tenant list: row action verify
|
||||||
|
|
||||||
|
1. Open the tenant list in the admin panel.
|
||||||
|
2. On any active tenant row, choose “Verify configuration”.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Uses the same `provider.connection.check` start/dedupe semantics as tenant detail.
|
||||||
|
- Notification “View run” links to `/admin/operations/{run}`.
|
||||||
|
|
||||||
|
### 2) Tenant detail: embedded viewer
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- The tenant page shows the latest verification run attempt.
|
||||||
|
- If no run exists yet, it shows an empty state with “Start verification”.
|
||||||
|
- If a run is active and no report exists yet, it shows a DB-only “in progress” state.
|
||||||
|
- When completed, the viewer shows the stored report.
|
||||||
|
|
||||||
|
### 3) Blocked run report stub
|
||||||
|
|
||||||
|
Create a situation where verification is blocked (e.g., missing provider connection / consent).
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- A completed blocked `OperationRun` exists.
|
||||||
|
- The embedded viewer (and the operations run viewer) renders a stub report (no “report unavailable” state for completed blocked runs).
|
||||||
|
|
||||||
|
### 4) Onboarding: verify access
|
||||||
|
|
||||||
|
1. Go to managed tenant onboarding.
|
||||||
|
2. In the “Verify access” step, click “Start verification”.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Same semantics: run started/deduped/busy and “View run” links to `/admin/operations/{run}`.
|
||||||
|
|
||||||
|
## Authorization checks
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Missing workspace membership or tenant entitlement: 404 (deny-as-not-found) for tenant routes and canonical run viewer of tenant-associated runs.
|
||||||
|
- Member without capability: action visible-but-disabled with helper text; server enforces 403 if invoked.
|
||||||
70
specs/084-verification-surfaces-unification/research.md
Normal file
70
specs/084-verification-surfaces-unification/research.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Research — Verification Surfaces Unification (Spec 084)
|
||||||
|
|
||||||
|
Date: 2026-02-09
|
||||||
|
|
||||||
|
## Decision 1: Unify on `provider.connection.check` OperationRun type
|
||||||
|
|
||||||
|
- Decision: Use the existing `OperationRun.type = provider.connection.check` as the single verification run type for both:
|
||||||
|
- Tenant detail “Verify configuration”
|
||||||
|
- Onboarding “Verify access”
|
||||||
|
- Rationale:
|
||||||
|
- This run type already exists and is used by onboarding (`ManagedTenantOnboardingWizard::startVerification()`).
|
||||||
|
- The job (`ProviderConnectionHealthCheckJob`) already produces a schema-valid verification report via `VerificationReportWriter::write(...)`.
|
||||||
|
- Dedupe and “scope busy” semantics are already implemented in `ProviderOperationStartGate`.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Create a new run type (e.g., `tenant.verification`). Rejected because it would duplicate existing job logic and complicate dedupe and viewer behavior.
|
||||||
|
|
||||||
|
## Decision 2: Tenant verification start uses `StartVerification` / `ProviderOperationStartGate` (enqueue-only)
|
||||||
|
|
||||||
|
- Decision: Replace the tenant detail synchronous verification (`TenantResource::verifyTenant()`) with an enqueue-only start that:
|
||||||
|
1) authorizes,
|
||||||
|
2) creates/dedupes an `OperationRun`,
|
||||||
|
3) dispatches `ProviderConnectionHealthCheckJob`,
|
||||||
|
4) returns a canonical “View run” link.
|
||||||
|
- Rationale:
|
||||||
|
- Constitution requires external calls be observable and performed asynchronously via `OperationRun`.
|
||||||
|
- The current tenant action performs Graph calls inline; onboarding already uses the queued run model.
|
||||||
|
- Unifies UX and operational auditability.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Keep tenant verification synchronous and only add a “view last run” viewer. Rejected because it preserves inconsistency and violates run observability for remote calls.
|
||||||
|
|
||||||
|
## Decision 3: Completed blocked verification runs MUST always have a schema-valid stub report
|
||||||
|
|
||||||
|
- Decision: When a verification run is finalized as blocked (outcome `blocked`) for `provider.connection.check`, immediately write a stub `context.verification_report` using `VerificationReportWriter`.
|
||||||
|
- Rationale:
|
||||||
|
- Both verification viewers render DB-only and expect a report for completed runs.
|
||||||
|
- `OperationRunService::finalizeBlockedRun()` currently sets `context.reason_code` and `context.next_steps` but does not write a report, which produces a “report unavailable” state.
|
||||||
|
- A stub report can encode the reason code and next steps in a consistent, schema-valid format.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Modify `VerificationReportViewer` to fabricate a report at render time if blocked. Rejected because rendering must be DB-only and deterministic, and should not create derived data in the UI layer.
|
||||||
|
- Add report writing inside `OperationRunService::finalizeBlockedRun()` for all operations. Rejected because not all blocked operations are “verification” and we should not inject verification reports into unrelated runs.
|
||||||
|
|
||||||
|
## Decision 4: Embedded tenant viewer selects latest run attempt for tenant + type
|
||||||
|
|
||||||
|
- Decision: In the tenant view, select the latest `OperationRun` attempt by:
|
||||||
|
- `tenant_id = current tenant`,
|
||||||
|
- `type = provider.connection.check`,
|
||||||
|
- ordered by `id desc`.
|
||||||
|
- Rationale:
|
||||||
|
- Matches the clarified spec requirement: latest attempt even if queued/running.
|
||||||
|
- Avoids coupling selection to provider connection id.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Select by `context.provider_connection_id` and only show the default connection’s run. Rejected because it can hide recent verification attempts started against a different (now selected) connection.
|
||||||
|
|
||||||
|
## Decision 5: Canonical tenantless run links are mandatory
|
||||||
|
|
||||||
|
- Decision: All “View run” CTAs use `OperationRunLinks::tenantlessView($runId)` (route `admin.operations.view`).
|
||||||
|
- Rationale:
|
||||||
|
- Canonical URL improves supportability and reduces ambiguity.
|
||||||
|
- Tenantless views must still enforce workspace + tenant entitlement (404 if missing).
|
||||||
|
- Alternatives considered:
|
||||||
|
- Use tenant-scoped run URLs for tenant pages. Rejected because canonical linking is a core requirement.
|
||||||
|
|
||||||
|
## Decision 6: Authorization semantics follow RBAC-UX 404/403 split
|
||||||
|
|
||||||
|
- Decision:
|
||||||
|
- Non-members (missing workspace membership or tenant entitlement): deny-as-not-found (404) for tenant routes and tenantless operation views of tenant-associated runs.
|
||||||
|
- Members without capability: show action visible-but-disabled (UX), but server enforces 403 on attempt.
|
||||||
|
- Rationale:
|
||||||
|
- Matches constitution RBAC-UX-002 and RBAC-UX-003.
|
||||||
|
|
||||||
173
specs/084-verification-surfaces-unification/spec.md
Normal file
173
specs/084-verification-surfaces-unification/spec.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# Feature Specification: Verification Surfaces Unification
|
||||||
|
|
||||||
|
**Feature Branch**: `084-verification-surfaces-unification`
|
||||||
|
**Created**: 2026-02-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Unify tenant + onboarding verification so all verification uses the same run-based mechanism with DB-only viewing and always-available reports (including blocked)."
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-09
|
||||||
|
|
||||||
|
- Q: For tenant members who are in-scope but lack the capability to start verification, how should the “Verify configuration” action behave in the UI? → A: Visible but disabled, with helper text explaining the missing capability (server still enforces 403 if invoked).
|
||||||
|
- Q: When rendering the embedded verification viewer on the tenant detail page, which run should be selected as “the last relevant run”? → A: Latest run attempt for that tenant+type (even if queued/running); if active and no report yet, show a DB-only “in progress” state.
|
||||||
|
- Q: On the tenant detail page, when no verification run exists yet, what should the embedded verification section do? → A: Show a DB-only empty state with a “Start verification” CTA.
|
||||||
|
- Q: For the canonical tenantless run viewer (/admin/operations/{run}), what should be required to view a run that is associated with a tenant? → A: Require both workspace membership AND tenant entitlement; missing either is deny-as-not-found (404).
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - Verify tenant configuration consistently (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace member with the appropriate permission, I can start a tenant verification from the tenant detail view and immediately see the latest stored verification results, without the page performing external provider calls during rendering.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary operational entry-point and must be fast, safe, and predictable.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by starting verification from the tenant detail view, asserting a run record exists, and asserting the embedded viewer renders using stored data.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant with an eligible provider connection, **When** I click “Verify configuration”, **Then** a verification run is started or deduped, and the tenant page renders the report viewer using stored data only.
|
||||||
|
2. **Given** a tenant where verification cannot proceed (e.g., missing consent/credentials), **When** I click “Verify configuration”, **Then** a completed “blocked” run exists and the embedded viewer shows a stub/preflight report (not an “unavailable” state).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### User Story 2 - Onboarding verify access behaves identically (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace member with onboarding verification capability, I can start onboarding verification and receive the same run, dedupe/busy, blocked-report, and canonical link behavior as the tenant verification surfaces.
|
||||||
|
|
||||||
|
**Why this priority**: Removes confusion and reduces operational variance between onboarding and day-2 operations.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by starting verification in onboarding and asserting identical run outcomes and viewer behavior to the tenant surface.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** onboarding verification is started while another verification run is already active for the same target, **When** I start verification again, **Then** I receive a busy/deduped result that points to the existing active run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Use canonical run links everywhere (Priority: P3)
|
||||||
|
|
||||||
|
As a workspace member, I can open the canonical run viewer link from any verification surface and it consistently resolves to the same “tenantless” run route.
|
||||||
|
|
||||||
|
**Why this priority**: Improves supportability and prevents broken/ambiguous deep links.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by starting verification, then asserting all “View run” links point to the canonical run route and the page is accessible only to authorized members.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a verification run exists, **When** I click “View run”, **Then** I am taken to the canonical run viewer route for that run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- Verification is started while an existing run is queued/running for the same target (dedupe/busy behavior must be consistent across surfaces).
|
||||||
|
- Verification cannot proceed due to missing consent/credentials (a completed blocked run must still have a schema-valid report).
|
||||||
|
- Viewer is opened by a non-member (deny-as-not-found behavior).
|
||||||
|
- Stored report data is present but incomplete/invalid (viewer must fail safe with a clear non-leaky message and no external calls).
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-001 (DB-only render)**: Tenant detail, onboarding verification display, and canonical run viewer rendering MUST be DB-only, with no external provider or Graph calls during mount/render/poll/refresh interactions.
|
||||||
|
- **NFR-002 (start path latency)**: Verification start interactions (`started`, `deduped`, `scope_busy`, `blocked`) SHOULD complete request handling in under 1 second under normal local/staging conditions because they only authorize, create/dedupe run state, enqueue, and notify.
|
||||||
|
- **NFR-003 (refresh/polling discipline)**: Verification UI refresh behavior MUST read persisted `OperationRun` state only and MUST NOT trigger inventory refresh or Graph permission reconciliation during display refresh.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||||
|
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||||
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
|
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||||
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
|
- explicitly define 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right functional requirements.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST provide a single, unified verification start mechanism used by both the tenant detail “Verify configuration” and onboarding “Verify access” surfaces.
|
||||||
|
- **FR-002**: Starting verification MUST create (or dedupe to) a single “verification run” record for the target, and surface a stable link to view that run.
|
||||||
|
- **FR-003**: When a verification run cannot proceed due to missing prerequisites, the system MUST finalize the run as completed “blocked” and persist a schema-valid stub/preflight verification report.
|
||||||
|
- **DB-only render invariant**: Verification viewers are read-only projections of persisted run/report data; they MUST NOT perform provider/Graph calls and MUST NOT persist permission inventory updates.
|
||||||
|
- **FR-004**: For any verification run that is completed (including blocked), the embedded/onboarding viewers MUST render the verification report using stored data only.
|
||||||
|
- **FR-004a**: The tenant detail embedded viewer MUST select the latest verification run attempt for the tenant and verification type; if that run is active (queued/running) and no report is yet available, the UI MUST render a DB-only “in progress” state.
|
||||||
|
- **FR-004b**: If no verification run exists yet for the tenant and verification type, the tenant detail embedded section MUST show a DB-only empty state with a “Start verification” CTA.
|
||||||
|
- **FR-005**: UI page rendering (including mount/load/summary components) MUST NOT trigger external provider calls directly or indirectly.
|
||||||
|
- **FR-006**: Dedupe rules MUST ensure at most one active run (queued/running) exists per target and verification type; repeated starts during an active run MUST return a busy/deduped outcome.
|
||||||
|
- **FR-007**: The system MUST persist any permissions inventory updates only as part of the verification job’s execution, and MUST NOT persist these updates during page rendering.
|
||||||
|
- **FR-008**: All “View run” links exposed by verification surfaces MUST use the canonical tenantless run viewer route.
|
||||||
|
- **FR-009**: Authorization MUST be enforced server-side:
|
||||||
|
- missing workspace membership OR missing tenant entitlement MUST be deny-as-not-found (404) for tenant-scoped routes/actions and tenantless canonical views of tenant-associated records,
|
||||||
|
- members lacking the required capability to start verification MUST see the action visible-but-disabled with helper text, and MUST receive a forbidden response (403) if invoked.
|
||||||
|
- **FR-010**: The system MUST emit run observability sufficient for operations (run type, outcome, timestamps, target scope) and MUST be test-covered.
|
||||||
|
|
||||||
|
### Assumptions & Dependencies
|
||||||
|
|
||||||
|
- This change unifies surfaces and report availability; it does not expand the set of verification checks beyond what is already produced today.
|
||||||
|
- For tenant verification surfaces (`ViewTenant` header action, tenant embedded CTA, and tenant list verify action), “eligible provider connection” means the resolved **default** provider connection for provider `microsoft`.
|
||||||
|
- Onboarding verification continues to use the session-selected provider connection, and still runs through the same unified operation type and run orchestration.
|
||||||
|
- Verification reports are stored with the verification run record and are treated as the sole source for UI rendering.
|
||||||
|
- External provider calls are permitted only as part of explicit user-triggered verification runs and their execution (never during page rendering).
|
||||||
|
- Existing authorization capabilities and membership rules remain the source of truth; this feature standardizes how they apply across surfaces.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
|
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant detail view | Tenant admin area | Verify configuration | Embedded viewer + View run link | n/a | n/a | Start verification (empty state) | n/a | n/a | Yes | DB-only render; starts/dupes run; if missing capability: visible but disabled with helper |
|
||||||
|
| Tenant list (Tenants table) | Tenant admin area | n/a | Clickable row + View action | Verify configuration (grouped action) | grouped | n/a | n/a | n/a | Yes | Uses same unified start path and canonical run links as tenant detail |
|
||||||
|
| Onboarding verification step | Onboarding wizard | Start verification | Embedded viewer + View run link | n/a | n/a | n/a | n/a | n/a | Yes | Same semantics as tenant surface |
|
||||||
|
| Tenantless run viewer | Operations area | n/a | n/a | n/a | n/a | n/a | n/a | n/a | Yes | Requires workspace membership + tenant entitlement; otherwise 404 |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Verification Run**: An immutable operational record representing one attempt to verify access/configuration for a target scope. It captures status/outcome and a canonical link for viewing.
|
||||||
|
- **Verification Report**: A schema-valid, stored report attached to a verification run. It is always present for completed runs, including blocked runs (stub/preflight).
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: 100% of completed blocked verification runs display a usable stub report (no "report unavailable" state for completed blocked runs).
|
||||||
|
- **SC-002**: Tenant detail pages render without any external provider calls; verification-related external calls occur only after an explicit start action.
|
||||||
|
- **SC-003**: When a verification run is already active for the same target and type, repeated starts return a busy/deduped response in under 1 second.
|
||||||
|
- **SC-004**: All verification surfaces provide a canonical "View run" link, and support can use that single URL to review outcomes.
|
||||||
160
specs/084-verification-surfaces-unification/tasks.md
Normal file
160
specs/084-verification-surfaces-unification/tasks.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Spec 084 implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Verification Surfaces Unification
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/084-verification-surfaces-unification/`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Establish a safe baseline and align on touched surfaces.
|
||||||
|
|
||||||
|
- [X] T001 Capture baseline behavior by running existing verification tests in tests/Feature/Verification/VerificationStartDedupeTest.php and tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php
|
||||||
|
- [X] T002 Identify current tenant “Verify configuration” surface implementation in app/Filament/Resources/TenantResource/Pages/ViewTenant.php and document expected deltas in specs/084-verification-surfaces-unification/quickstart.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Shared primitives required by ALL user stories.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Add schema-valid stub verification report for blocked provider.connection.check runs in app/Services/Providers/ProviderOperationStartGate.php (write context.verification_report after finalizeBlockedRun)
|
||||||
|
- [X] T004 [P] Add helper to build blocked/preflight verification checks payload in app/Support/Verification/ (e.g., new BlockedVerificationReportFactory.php used by ProviderOperationStartGate)
|
||||||
|
- [X] T005 Add a non-breaking tenant-default start helper in app/Services/Verification/StartVerification.php (keep existing explicit-connection API; delegate default-connection resolution to ProviderOperationStartGate)
|
||||||
|
- [X] T006 Ensure blocked stub report includes next steps links from context.next_steps and normalized reason_code from context.reason_code in app/Support/Verification/BlockedVerificationReportFactory.php
|
||||||
|
- [X] T007 Add Pest coverage for blocked stub report invariant in tests/Feature/Verification/ (new test file asserting completed blocked provider.connection.check has context.verification_report and it validates via VerificationReportSchema)
|
||||||
|
- [X] T027 Enforce tenant entitlement for tenant-associated runs in app/Policies/OperationRunPolicy.php and app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (deny-as-not-found when workspace membership or tenant entitlement is missing)
|
||||||
|
|
||||||
|
**Checkpoint**: A blocked provider.connection.check run always has a schema-valid stored report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Verify tenant configuration consistently (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Start verification from tenant detail view and render the latest stored report DB-only.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger the tenant verify action and confirm it creates/dedupes an OperationRun and the tenant embedded viewer renders from stored run context only.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [X] T008 [P] [US1] Update tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php to assert tenant verify start now creates a blocked OperationRun and includes a schema-valid verification_report stub
|
||||||
|
- [X] T009 [P] [US1] Add tenant verify happy-path test in tests/Feature/Filament/ (new test file calling ViewTenant action and asserting ProviderConnectionHealthCheckJob dispatch + canonical view-run link)
|
||||||
|
- [X] T010 [P] [US1] Add tenant embedded viewer DB-only test in tests/Feature/Filament/ (new test asserting the widget/view reads OperationRun.context only; no provider calls during render)
|
||||||
|
- [X] T032 [P] [US1] Add FR-007 regression test in tests/Feature/Filament/ asserting tenant view render path never triggers synchronous verification or permission inventory persistence
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [X] T011 [US1] Refactor “Verify configuration” action in app/Filament/Resources/TenantResource/Pages/ViewTenant.php to start/dedupe provider.connection.check via app/Services/Verification/StartVerification.php (no synchronous Graph work)
|
||||||
|
- [X] T012 [US1] Apply UI enforcement to the tenant verify action in app/Filament/Resources/TenantResource/Pages/ViewTenant.php using App\Support\Rbac\UiEnforcement with Capabilities::PROVIDER_RUN (visible-but-disabled; server still 403)
|
||||||
|
- [X] T013 [US1] Ensure tenant verify notifications include a canonical tenantless “View run” link using App\Support\OperationRunLinks::tenantlessView in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||||
|
- [X] T033 [US1] Refactor tenant list “Verify configuration” action in app/Filament/Resources/TenantResource.php to the same unified provider.connection.check start path + canonical tenantless run links
|
||||||
|
- [X] T014 [P] [US1] Create a tenant verification viewer widget class in app/Filament/Widgets/Tenant/TenantVerificationReport.php (select latest provider.connection.check run for the current tenant)
|
||||||
|
- [X] T015 [P] [US1] Create widget view resources/views/filament/widgets/tenant/tenant-verification-report.blade.php with states: empty (Start verification CTA), in-progress, completed (render filament.components.verification-report-viewer)
|
||||||
|
- [X] T016 [US1] Register the tenant verification viewer widget on the tenant view page by updating app/Filament/Resources/TenantResource/Pages/ViewTenant.php getHeaderWidgets() to include TenantVerificationReport
|
||||||
|
- [X] T017 [US1] Implement the embedded viewer “Start verification” CTA to invoke the same unified start path (StartVerification) in app/Filament/Widgets/Tenant/TenantVerificationReport.php
|
||||||
|
- [X] T018 [US1] Ensure the embedded viewer “View run” link uses OperationRunLinks::tenantlessView in app/Filament/Widgets/Tenant/TenantVerificationReport.php and resources/views/filament/widgets/tenant/tenant-verification-report.blade.php
|
||||||
|
|
||||||
|
**Checkpoint**: US1 works end-to-end using queued OperationRun and DB-only rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Onboarding verify access behaves identically (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Onboarding verification uses the same run type, dedupe/busy semantics, and viewer behavior.
|
||||||
|
|
||||||
|
**Independent Test**: Start verification in onboarding twice and assert dedupe/busy behavior and canonical links match tenant surface.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- [X] T019 [P] [US2] Update tests/Feature/Onboarding/OnboardingVerificationTest.php to assert busy/deduped notifications link to the canonical tenantless operations viewer route
|
||||||
|
- [X] T020 [P] [US2] Add regression test ensuring onboarding verification uses provider.connection.check and stores verification_report in tests/Feature/Onboarding/OnboardingVerificationTest.php
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [X] T021 [US2] Ensure app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php startVerification() uses the same unified provider.connection.check start mechanism (ProviderOperationStartGate) and remains enqueue-only
|
||||||
|
- [X] T022 [US2] Ensure onboarding “View run” links remain canonical via OperationRunLinks::tenantlessView (or equivalent tenantlessOperationRunUrl) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
- [X] T023 [US2] Confirm onboarding verification report rendering uses App\Filament\Support\VerificationReportViewer only (no external calls) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Use canonical run links everywhere (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Every verification surface links to the same tenantless run viewer route, and tenantless viewing enforces workspace + tenant entitlement (404 when missing).
|
||||||
|
|
||||||
|
**Independent Test**: From both tenant and onboarding surfaces, follow “View run” and confirm it resolves to the same canonical route and is deny-as-not-found for non-entitled actors.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- [X] T024 [P] [US3] Add canonical link assertions for tenant verify notifications in tests/Feature/Filament/ (update test from T009 to assert route name admin.operations.view)
|
||||||
|
- [X] T025 [P] [US3] Add canonical link assertions for onboarding notifications in tests/Feature/Onboarding/OnboardingVerificationTest.php
|
||||||
|
- [X] T026 [P] [US3] Add authorization test for tenantless run viewer requiring workspace membership + tenant entitlement (404 semantics) in tests/Feature/RunAuthorizationTenantIsolationTest.php
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [X] T028 [US3] Ensure all verification surfaces use OperationRunLinks::tenantlessView (or identical canonical route helper) in app/Filament/Resources/TenantResource/Pages/ViewTenant.php and app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Formatting, stability, and quickstart validation.
|
||||||
|
|
||||||
|
- [X] T034 Fix tenant page widgets to resolve tenant from record context (Admin panel) and add regression coverage for Recent operations + Start verification disabled state
|
||||||
|
- [X] T029 Run formatting on changed files using vendor/bin/sail bin pint --dirty (e.g., app/Filament/Resources/TenantResource/Pages/ViewTenant.php, app/Services/Providers/ProviderOperationStartGate.php, tests/Feature/Verification/*)
|
||||||
|
- [X] T030 Run targeted Pest suite for this feature using vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php
|
||||||
|
- [X] T031 Validate manual flows in specs/084-verification-surfaces-unification/quickstart.md and update it if any step text is now inaccurate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- Phase 1 (Setup) → Phase 2 (Foundational) → user stories.
|
||||||
|
- User stories after Phase 2:
|
||||||
|
- **US1 (P1)** can proceed immediately after Phase 2.
|
||||||
|
- **US2 (P2)** can proceed after Phase 2 (independent of US1).
|
||||||
|
- **US3 (P3)** should run after US1 + US2 (to validate “every surface”).
|
||||||
|
|
||||||
|
### Dependency Graph (stories)
|
||||||
|
|
||||||
|
- Foundation → US1
|
||||||
|
- Foundation → US2
|
||||||
|
- US1 + US2 → US3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### US1 parallelizable tasks
|
||||||
|
|
||||||
|
- T008, T009, T010, and T032 can be developed in parallel (separate test files).
|
||||||
|
- T014 and T015 can be developed in parallel (widget class vs Blade view).
|
||||||
|
|
||||||
|
### US2 parallelizable tasks
|
||||||
|
|
||||||
|
- T019 and T020 can be developed in parallel within tests/Feature/Onboarding/.
|
||||||
|
|
||||||
|
### US3 parallelizable tasks
|
||||||
|
|
||||||
|
- T024, T025, T026 can be developed in parallel (different test targets).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (US1)
|
||||||
|
|
||||||
|
1. Complete Phase 1–2.
|
||||||
|
2. Implement US1 tests (T008–T010, T032) → ensure they fail.
|
||||||
|
3. Implement US1 code (T011–T018, T033) → ensure tests pass.
|
||||||
|
4. Run Phase 6 tasks to format + verify.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Add US2 next to remove onboarding/tenant variance.
|
||||||
|
- Finish with US3 to unify canonical links + tenantless authorization guarantees across all surfaces.
|
||||||
27
tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
Normal file
27
tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('renders recent operations from the record tenant in admin panel context', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'success',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(RecentOperationsSummary::class, ['record' => $tenant])
|
||||||
|
->assertSee('Recent operations')
|
||||||
|
->assertSee('Provider connection check')
|
||||||
|
->assertDontSee('No operations yet.');
|
||||||
|
});
|
||||||
@ -2,57 +2,25 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
|
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ProviderCredential;
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('tenant can be created via filament and verified successfully', function () {
|
test('tenant can be created via filament and verification start enqueues an operation run', function () {
|
||||||
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
|
Queue::fake();
|
||||||
{
|
bindFailHardGraphClient();
|
||||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getOrganization(array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, ['value' => [['id' => $options['tenant'] ?? 'tenant']]], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
// Return all required permissions as granted
|
|
||||||
return new GraphResponse(true, [
|
|
||||||
'permissions' => collect(config('intune_permissions.permissions', []))
|
|
||||||
->pluck('key')
|
|
||||||
->toArray(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -99,56 +67,38 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->callAction('verify');
|
->callAction('verify');
|
||||||
|
|
||||||
$tenant->refresh();
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
expect($tenant->app_status)->toBe('ok');
|
expect($run)->not->toBeNull()
|
||||||
|
->and($run?->status)->toBe('queued')
|
||||||
|
->and($run?->outcome)->toBe('pending')
|
||||||
|
->and($run?->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey());
|
||||||
|
|
||||||
$this->assertDatabaseHas('audit_logs', [
|
$notificationActionUrls = collect(session('filament.notifications', []))
|
||||||
|
->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null)
|
||||||
|
? $notification['actions']
|
||||||
|
: [])
|
||||||
|
->pluck('url')
|
||||||
|
->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView($run));
|
||||||
|
|
||||||
|
Queue::assertPushed(\App\Jobs\ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('audit_logs', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'action' => 'tenant.config.verified',
|
'action' => 'tenant.config.verified',
|
||||||
'status' => 'success',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_permissions', [
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'status' => 'granted',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verify configuration records error when graph fails', function () {
|
test('verify configuration creates a blocked run when default connection credentials are missing', function () {
|
||||||
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
|
Queue::fake();
|
||||||
{
|
|
||||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getOrganization(array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(false, [], 401, ['auth failed']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
// Return error for permissions check
|
|
||||||
return new GraphResponse(false, [], 403, ['Permission denied']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
||||||
{
|
|
||||||
return new GraphResponse(true, []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
@ -168,35 +118,26 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
'status' => 'enabled',
|
'status' => 'connected',
|
||||||
]);
|
|
||||||
|
|
||||||
ProviderCredential::factory()->create([
|
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'type' => 'client_secret',
|
|
||||||
'payload' => [
|
|
||||||
'client_id' => 'client-id',
|
|
||||||
'client_secret' => 'client-secret',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->callAction('verify');
|
->callAction('verify');
|
||||||
|
|
||||||
$tenant->refresh();
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
expect($tenant->app_status)->toBe('error');
|
expect($run)->not->toBeNull()
|
||||||
|
->and($run?->outcome)->toBe('blocked')
|
||||||
|
->and($run?->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||||
|
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing);
|
||||||
|
|
||||||
$this->assertDatabaseHas('audit_logs', [
|
expect(VerificationReportSchema::isValidReport($run?->context['verification_report'] ?? []))->toBeTrue();
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'action' => 'tenant.config.verified',
|
|
||||||
'status' => 'error',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_permissions', [
|
Queue::assertNothingPushed();
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'status' => 'error',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tenant detail shows required permissions with statuses', function () {
|
test('tenant detail shows required permissions with statuses', function () {
|
||||||
|
|||||||
244
tests/Feature/Filament/TenantVerificationReportWidgetTest.php
Normal file
244
tests/Feature/Filament/TenantVerificationReportWidgetTest.php
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Services\Intune\RbacHealthService;
|
||||||
|
use App\Services\Intune\TenantConfigService;
|
||||||
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('starts tenant verification from header action and links to the canonical run viewer', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'type' => 'client_secret',
|
||||||
|
'payload' => [
|
||||||
|
'client_id' => 'client-id',
|
||||||
|
'client_secret' => 'client-secret',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->callAction('verify');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
|
||||||
|
$notifications = collect(session('filament.notifications', []));
|
||||||
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|
||||||
|
$last = $notifications->last();
|
||||||
|
$actionUrls = collect($last['actions'] ?? [])->pluck('url')->filter()->values()->all();
|
||||||
|
|
||||||
|
expect($actionUrls)->toContain(OperationRunLinks::tenantlessView($run));
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, function (ProviderConnectionHealthCheckJob $job) use ($run, $connection): bool {
|
||||||
|
return (int) $job->providerConnectionId === (int) $connection->getKey()
|
||||||
|
&& (int) ($job->operationRun?->getKey() ?? 0) === (int) ($run?->getKey() ?? 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the tenant verification widget from stored run context only', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection preflight',
|
||||||
|
'status' => 'fail',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'blocking' => true,
|
||||||
|
'reason_code' => 'provider_connection_missing',
|
||||||
|
'message' => 'No provider connection configured.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
'verification_report' => $report,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($user): void {
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantVerificationReport::class)
|
||||||
|
->assertSee('Provider connection preflight')
|
||||||
|
->assertSee('Read-only:')
|
||||||
|
->assertSee('Insufficient permission — ask a tenant Owner.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tenant detail without invoking synchronous verification or permission persistence services', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->mock(TenantConfigService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('testConnectivity')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mock(TenantPermissionService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('compare')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->mock(RbacHealthService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('check')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($user, $tenant): void {
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('filament.admin.resources.tenants.view', array_merge(
|
||||||
|
filamentTenantRouteParams($tenant),
|
||||||
|
['record' => $tenant]
|
||||||
|
)))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification report');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts verification from the embedded widget CTA and uses canonical view-run links', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'type' => 'client_secret',
|
||||||
|
'payload' => [
|
||||||
|
'client_id' => 'client-id',
|
||||||
|
'client_secret' => 'client-secret',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(TenantVerificationReport::class, ['record' => $tenant])
|
||||||
|
->assertSee('No verification run has been started yet.')
|
||||||
|
->call('startVerification');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
|
||||||
|
$notifications = collect(session('filament.notifications', []));
|
||||||
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|
||||||
|
$last = $notifications->last();
|
||||||
|
$actionUrls = collect($last['actions'] ?? [])->pluck('url')->filter()->values()->all();
|
||||||
|
|
||||||
|
expect($actionUrls)->toContain(OperationRunLinks::tenantlessView($run));
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts tenant verification from the tenant list row action via the unified run path', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'type' => 'client_secret',
|
||||||
|
'payload' => [
|
||||||
|
'client_id' => 'client-id',
|
||||||
|
'client_secret' => 'client-secret',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListTenants::class)
|
||||||
|
->callTableAction('verify', $tenant);
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull()
|
||||||
|
->and($run?->context['surface']['kind'] ?? null)->toBe('tenant_list_row');
|
||||||
|
|
||||||
|
$notificationActionUrls = collect(session('filament.notifications', []))
|
||||||
|
->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null)
|
||||||
|
? $notification['actions']
|
||||||
|
: [])
|
||||||
|
->pluck('url')
|
||||||
|
->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView($run));
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
});
|
||||||
@ -3,14 +3,15 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
@ -47,13 +48,15 @@
|
|||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$component->call('startVerification');
|
|
||||||
$component->call('startVerification');
|
|
||||||
|
|
||||||
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||||
|
|
||||||
|
ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->update(['status' => 'connected']);
|
||||||
|
|
||||||
|
$component->call('startVerification');
|
||||||
|
$component->call('startVerification');
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'provider.connection.check')
|
->where('type', 'provider.connection.check')
|
||||||
@ -64,6 +67,17 @@
|
|||||||
->where('type', 'provider.connection.check')
|
->where('type', 'provider.connection.check')
|
||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
|
$notificationActionUrls = collect(session('filament.notifications', []))
|
||||||
|
->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null)
|
||||||
|
? $notification['actions']
|
||||||
|
: [])
|
||||||
|
->pluck('url')
|
||||||
|
->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView($runId));
|
||||||
|
|
||||||
$session = TenantOnboardingSession::query()
|
$session = TenantOnboardingSession::query()
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
->where('entra_tenant_id', $entraTenantId)
|
->where('entra_tenant_id', $entraTenantId)
|
||||||
@ -73,6 +87,73 @@
|
|||||||
expect($session->state['verification_operation_run_id'] ?? null)->toBe($runId);
|
expect($session->state['verification_operation_run_id'] ?? null)->toBe($runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores a blocked verification report and canonical link when onboarding verification cannot proceed', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$entraTenantId = '72727272-7272-7272-7272-727272727272';
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||||
|
|
||||||
|
$component->call('identifyManagedTenant', [
|
||||||
|
'entra_tenant_id' => $entraTenantId,
|
||||||
|
'environment' => 'prod',
|
||||||
|
'name' => 'Blocked Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $entraTenantId,
|
||||||
|
'display_name' => 'Blocked connection',
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->call('selectProviderConnection', (int) $connection->getKey());
|
||||||
|
$component->call('startVerification');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull()
|
||||||
|
->and($run?->outcome)->toBe('blocked');
|
||||||
|
|
||||||
|
$report = $run?->context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
expect($report)->toBeArray();
|
||||||
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||||
|
|
||||||
|
$notificationActionUrls = collect(session('filament.notifications', []))
|
||||||
|
->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null)
|
||||||
|
? $notification['actions']
|
||||||
|
: [])
|
||||||
|
->pluck('url')
|
||||||
|
->filter(static fn (mixed $url): bool => is_string($url) && trim($url) !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($notificationActionUrls)->toContain(OperationRunLinks::tenantlessView((int) $run?->getKey()));
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders stored verification findings in the wizard report section', function (): void {
|
it('renders stored verification findings in the wizard report section', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
it('Spec081 blocks provider operation starts when default connection is missing', function (): void {
|
it('Spec081 blocks provider operation starts when default connection is missing', function (): void {
|
||||||
@ -29,7 +30,8 @@
|
|||||||
->and($result->run->status)->toBe(OperationRunStatus::Completed->value)
|
->and($result->run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing)
|
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing)
|
||||||
->and($result->run->context['next_steps'] ?? [])->not->toBeEmpty();
|
->and($result->run->context['next_steps'] ?? [])->not->toBeEmpty()
|
||||||
|
->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Spec081 blocks provider operation starts when default connection has no credential', function (): void {
|
it('Spec081 blocks provider operation starts when default connection has no credential', function (): void {
|
||||||
@ -52,7 +54,8 @@
|
|||||||
expect($result->status)->toBe('blocked')
|
expect($result->status)->toBe('blocked')
|
||||||
->and($result->run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
->and($result->run->context['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||||
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
|
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
|
||||||
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
|
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Spec081 returns deterministic invalid reason when data corruption creates multiple defaults', function (): void {
|
it('Spec081 returns deterministic invalid reason when data corruption creates multiple defaults', function (): void {
|
||||||
@ -90,5 +93,6 @@
|
|||||||
expect($result->status)->toBe('blocked')
|
expect($result->status)->toBe('blocked')
|
||||||
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionInvalid)
|
->and($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionInvalid)
|
||||||
->and($result->run->context['reason_code_extension'] ?? null)->toBe('ext.multiple_defaults_detected')
|
->and($result->run->context['reason_code_extension'] ?? null)->toBe('ext.multiple_defaults_detected')
|
||||||
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
|
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -41,6 +42,10 @@
|
|||||||
->and($run?->outcome)->toBe('blocked')
|
->and($run?->outcome)->toBe('blocked')
|
||||||
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing);
|
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing);
|
||||||
|
|
||||||
|
$report = $run?->context['verification_report'] ?? null;
|
||||||
|
expect($report)->toBeArray();
|
||||||
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||||
|
|
||||||
$notifications = collect(session('filament.notifications', []));
|
$notifications = collect(session('filament.notifications', []));
|
||||||
expect($notifications)->not->toBeEmpty();
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|
||||||
@ -64,6 +69,20 @@
|
|||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->callAction('verify');
|
->callAction('verify');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull()
|
||||||
|
->and($run?->outcome)->toBe('blocked')
|
||||||
|
->and($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
|
|
||||||
|
$report = $run?->context['verification_report'] ?? null;
|
||||||
|
expect($report)->toBeArray();
|
||||||
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||||
|
|
||||||
$notifications = collect(session('filament.notifications', []));
|
$notifications = collect(session('filament.notifications', []));
|
||||||
expect($notifications)->not->toBeEmpty();
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
it('returns 200 for tenant-entitled readonly members on the canonical required permissions route', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for workspace members without tenant entitlement on the canonical route', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||||
|
])
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for users who are not workspace members', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||||
|
])
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
Tenant::query()->whereKey((int) $tenant->getKey())->update(['is_current' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/tenants/invalid-tenant-id/required-permissions')
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -13,7 +13,7 @@
|
|||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Guidance')
|
->assertSee('Guidance')
|
||||||
->assertSee('Who can fix this?', false)
|
->assertSee('Who can fix this?', false)
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('renders the required permissions page without Graph or outbound HTTP calls', function (): void {
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
it('renders the canonical required permissions page without Graph, outbound HTTP, or queue dispatches', function (): void {
|
||||||
bindFailHardGraphClient();
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($user, $tenant): void {
|
assertNoOutboundHttp(function () use ($user, $tenant): void {
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Keine Daten verfügbar')
|
||||||
|
->assertSee('/admin/onboarding', false)
|
||||||
|
->assertSee('Start verification');
|
||||||
|
});
|
||||||
@ -51,7 +51,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$missingResponse = $this->actingAs($user)
|
$missingResponse = $this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('All required permissions are present', false);
|
->assertSee('All required permissions are present', false);
|
||||||
|
|
||||||
@ -61,7 +61,7 @@
|
|||||||
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
|
||||||
|
|
||||||
$presentResponse = $this->actingAs($user)
|
$presentResponse = $this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('wire:model.live="status"', false);
|
->assertSee('wire:model.live="status"', false);
|
||||||
|
|
||||||
@ -71,7 +71,7 @@
|
|||||||
->assertSee('data-permission-key="Gamma.Manage.All"', false);
|
->assertSee('data-permission-key="Gamma.Manage.All"', false);
|
||||||
|
|
||||||
$delegatedResponse = $this->actingAs($user)
|
$delegatedResponse = $this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present&type=delegated")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present&type=delegated")
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$delegatedResponse
|
$delegatedResponse
|
||||||
@ -85,7 +85,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$featureResponse = $this->actingAs($user)
|
$featureResponse = $this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions?{$featureQuery}")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$featureResponse
|
$featureResponse
|
||||||
@ -94,7 +94,7 @@
|
|||||||
->assertDontSee('data-permission-key="Beta.Read.All"', false);
|
->assertDontSee('data-permission-key="Beta.Read.All"', false);
|
||||||
|
|
||||||
$searchResponse = $this->actingAs($user)
|
$searchResponse = $this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions?status=all&search=delegated")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all&search=delegated")
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$searchResponse
|
$searchResponse
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('returns 404 for the legacy tenant-plane required permissions route', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Re-run verification')
|
||||||
|
->assertSee('/admin/onboarding', false)
|
||||||
|
->assertDontSee('/admin/t/', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sections in summary-issues-passed-technical order and keeps technical details collapsed by default', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details'])
|
||||||
|
->assertSee('<details data-testid="technical-details"', false)
|
||||||
|
->assertDontSee('data-testid="technical-details" open', false);
|
||||||
|
});
|
||||||
@ -26,7 +26,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Blocked', false)
|
->assertSee('Blocked', false)
|
||||||
->assertSee('applyFeatureFilter', false)
|
->assertSee('applyFeatureFilter', false)
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
|
|
||||||
it('returns 404 for non-members accessing required permissions', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get("/admin/t/{$otherTenant->external_id}/required-permissions")
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for members without tenant.view capability accessing required permissions', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$this->mock(CapabilityResolver::class, function ($mock): void {
|
|
||||||
$mock->shouldReceive('isMember')
|
|
||||||
->andReturn(true);
|
|
||||||
|
|
||||||
$mock->shouldReceive('can')
|
|
||||||
->andReturnUsing(fn ($user, $tenant, $capability): bool => $capability !== Capabilities::TENANT_VIEW);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get("/admin/t/{$tenant->external_id}/required-permissions")
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
@ -109,3 +109,32 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Operation run');
|
->assertSee('Operation run');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tenant-associated run viewer requires tenant entitlement even for workspace members', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
|
|
||||||
|
it('stores a schema-valid blocked verification report stub for blocked provider connection checks', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: null,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: static function (): void {},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $result->run->refresh();
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
|
||||||
|
$report = $context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
expect($result->status)->toBe('blocked')
|
||||||
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
|
->and($report)->toBeArray();
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $report */
|
||||||
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||||
|
|
||||||
|
$checks = $report['checks'] ?? null;
|
||||||
|
$checks = is_array($checks) ? $checks : [];
|
||||||
|
|
||||||
|
expect($checks)->toHaveCount(1)
|
||||||
|
->and($checks[0]['key'] ?? null)->toBe('provider.connection.check')
|
||||||
|
->and($checks[0]['reason_code'] ?? null)->toBe($context['reason_code'] ?? null)
|
||||||
|
->and($checks[0]['next_steps'] ?? null)->toBe($context['next_steps'] ?? null);
|
||||||
|
});
|
||||||
@ -56,3 +56,49 @@
|
|||||||
|
|
||||||
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dedupes tenant-default verification starts while a run is active', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
'status' => 'connected',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$starter = app(StartVerification::class);
|
||||||
|
|
||||||
|
$first = $starter->providerConnectionCheckForTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: ['surface' => ['kind' => 'tenant_view_header']],
|
||||||
|
);
|
||||||
|
|
||||||
|
$second = $starter->providerConnectionCheckForTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: ['surface' => ['kind' => 'tenant_view_header']],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($first->run->getKey())->toBe($second->run->getKey());
|
||||||
|
expect($first->status)->toBe('started');
|
||||||
|
expect($second->status)->toBe('deduped');
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
});
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -153,4 +154,6 @@
|
|||||||
expect($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
|
expect($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value);
|
||||||
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
expect($result->run->context['next_steps'] ?? [])->not->toBeEmpty();
|
expect($result->run->context['next_steps'] ?? [])->not->toBeEmpty();
|
||||||
|
expect($result->run->context['verification_report'] ?? null)->toBeArray();
|
||||||
|
expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
34
tests/Unit/TenantRequiredPermissionsFreshnessTest.php
Normal file
34
tests/Unit/TenantRequiredPermissionsFreshnessTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
it('marks freshness as stale when last refreshed is missing', function (): void {
|
||||||
|
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||||
|
null,
|
||||||
|
CarbonImmutable::parse('2026-02-08 12:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($freshness['last_refreshed_at'])->toBeNull()
|
||||||
|
->and($freshness['is_stale'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks freshness as stale when last refreshed is older than 30 days', function (): void {
|
||||||
|
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||||
|
CarbonImmutable::parse('2026-01-08 11:59:59'),
|
||||||
|
CarbonImmutable::parse('2026-02-08 12:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($freshness['is_stale'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks freshness as not stale when last refreshed is exactly 30 days old', function (): void {
|
||||||
|
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||||
|
CarbonImmutable::parse('2026-01-09 12:00:00'),
|
||||||
|
CarbonImmutable::parse('2026-02-08 12:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($freshness['is_stale'])->toBeFalse();
|
||||||
|
});
|
||||||
@ -98,3 +98,27 @@
|
|||||||
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
|
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
|
||||||
->toBe(VerificationReportOverall::Ready->value);
|
->toBe(VerificationReportOverall::Ready->value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps overall to needs_attention when freshness is stale without explicit permission gaps', function (): void {
|
||||||
|
$rows = [
|
||||||
|
[
|
||||||
|
'key' => 'A',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => null,
|
||||||
|
'features' => ['backup'],
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'B',
|
||||||
|
'type' => 'delegated',
|
||||||
|
'description' => null,
|
||||||
|
'features' => ['backup'],
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows, true))
|
||||||
|
->toBe(VerificationReportOverall::NeedsAttention->value);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user