feat: neutralize provider connection target-scope surfaces (#274)
Some checks are pending
Main Confidence / confidence (push) Waiting to run
Some checks are pending
Main Confidence / confidence (push) Waiting to run
## Summary - add a shared provider target-scope descriptor, normalizer, identity-context metadata, and surface-summary layer - update provider connection list, detail, create, edit, and onboarding surfaces to use neutral target-scope vocabulary while keeping Microsoft identity contextual - align provider connection audit and resolver output with the neutral target-scope contract and add focused guard/unit/feature coverage for regressions ## Validation - browser smoke: opened the tenant-scoped provider connection list, drilled into detail, and verified the edit/create surfaces in local admin context ## Notes - this PR comes from the session branch created for the active feature work - no additional runtime or persistence layer was introduced in this slice Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #274
This commit is contained in:
parent
bd26e209de
commit
110245a9ec
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -252,6 +252,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
|
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
|
||||||
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
|
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
|
||||||
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
|
||||||
|
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -286,9 +288,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||||
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
||||||
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
|
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
|
||||||
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
Normal file
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
---
|
||||||
|
name: spec-kit-one-shot-prep
|
||||||
|
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||||
|
|
||||||
|
Define the functionality provided by this skill, including detailed instructions and examples
|
||||||
|
---
|
||||||
|
name: spec-kit-one-shot-prep
|
||||||
|
description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Spec Kit One-Shot Preparation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass:
|
||||||
|
|
||||||
|
1. `spec.md`
|
||||||
|
2. `plan.md`
|
||||||
|
3. `tasks.md`
|
||||||
|
|
||||||
|
This skill prepares implementation work, but it must not perform implementation.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature idea / roadmap item / spec candidate
|
||||||
|
→ one-shot spec + plan + tasks preparation
|
||||||
|
→ manual repo-based analysis/review
|
||||||
|
→ explicit implementation step later
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this skill when the user asks to create or prepare Spec Kit artifacts from:
|
||||||
|
|
||||||
|
- a feature idea
|
||||||
|
- a spec candidate
|
||||||
|
- a roadmap item
|
||||||
|
- a product or UX requirement
|
||||||
|
- a governance/platform improvement
|
||||||
|
- an architecture cleanup candidate
|
||||||
|
- a refactoring preparation request
|
||||||
|
- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec
|
||||||
|
|
||||||
|
Typical user prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mach daraus spec, plan und tasks in einem Rutsch.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nimm diesen spec candidate und bereite spec/plan/tasks vor.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- Work strictly repo-based.
|
||||||
|
- Do not implement application code.
|
||||||
|
- Do not modify production code.
|
||||||
|
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
||||||
|
- Do not execute implementation commands.
|
||||||
|
- Do not run destructive commands.
|
||||||
|
- Do not expand scope beyond the provided feature idea.
|
||||||
|
- Do not invent architecture that conflicts with repository truth.
|
||||||
|
- Do not create broad platform rewrites when a smaller implementable spec is possible.
|
||||||
|
- Prefer small, reviewable, implementation-ready specs.
|
||||||
|
- Preserve TenantPilot/TenantAtlas terminology.
|
||||||
|
- Follow the repository constitution and existing Spec Kit conventions.
|
||||||
|
- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation.
|
||||||
|
- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates.
|
||||||
|
|
||||||
|
## Required Inputs
|
||||||
|
|
||||||
|
The user should provide at least one of:
|
||||||
|
|
||||||
|
- feature title and short goal
|
||||||
|
- full spec candidate
|
||||||
|
- roadmap item
|
||||||
|
- rough problem statement
|
||||||
|
- UX or architecture improvement idea
|
||||||
|
|
||||||
|
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely.
|
||||||
|
|
||||||
|
## Required Repository Checks
|
||||||
|
|
||||||
|
Before creating or updating Spec Kit artifacts, inspect the relevant repository sources.
|
||||||
|
|
||||||
|
Always check:
|
||||||
|
|
||||||
|
1. `.specify/memory/constitution.md`
|
||||||
|
2. `.specify/templates/`
|
||||||
|
3. `specs/`
|
||||||
|
4. `docs/product/spec-candidates.md`
|
||||||
|
5. relevant roadmap documents under `docs/product/`
|
||||||
|
6. nearby existing specs with related terminology or scope
|
||||||
|
|
||||||
|
Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code.
|
||||||
|
|
||||||
|
## Spec Directory Rules
|
||||||
|
|
||||||
|
Create a new spec directory using the next valid spec number and a kebab-case slug:
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/<number>-<slug>/
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact number must be derived from the current repository state and existing numbering conventions.
|
||||||
|
|
||||||
|
Create or update only these preparation artifacts inside the selected spec directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/<number>-<slug>/spec.md
|
||||||
|
specs/<number>-<slug>/plan.md
|
||||||
|
specs/<number>-<slug>/tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files.
|
||||||
|
|
||||||
|
## `spec.md` Requirements
|
||||||
|
|
||||||
|
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Feature title
|
||||||
|
- Problem statement
|
||||||
|
- Business/product value
|
||||||
|
- Primary users/operators
|
||||||
|
- User stories
|
||||||
|
- Functional requirements
|
||||||
|
- Non-functional requirements
|
||||||
|
- UX requirements
|
||||||
|
- RBAC/security requirements
|
||||||
|
- Auditability/observability requirements
|
||||||
|
- Data/truth-source requirements where relevant
|
||||||
|
- Out of scope
|
||||||
|
- Acceptance criteria
|
||||||
|
- Success criteria
|
||||||
|
- Risks
|
||||||
|
- Assumptions
|
||||||
|
- Open questions
|
||||||
|
|
||||||
|
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
||||||
|
|
||||||
|
- workspace/tenant isolation
|
||||||
|
- capability-first RBAC
|
||||||
|
- auditability
|
||||||
|
- operation/result truth separation
|
||||||
|
- source-of-truth clarity
|
||||||
|
- calm enterprise operator UX
|
||||||
|
- progressive disclosure where useful
|
||||||
|
- no false positive calmness
|
||||||
|
|
||||||
|
## `plan.md` Requirements
|
||||||
|
|
||||||
|
The plan must be repo-aware and implementation-oriented, but still must not implement.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Technical approach
|
||||||
|
- Existing repository surfaces likely affected
|
||||||
|
- Domain/model implications
|
||||||
|
- UI/Filament implications
|
||||||
|
- Livewire implications where relevant
|
||||||
|
- OperationRun/monitoring implications where relevant
|
||||||
|
- RBAC/policy implications
|
||||||
|
- Audit/logging/evidence implications where relevant
|
||||||
|
- Data/migration implications where relevant
|
||||||
|
- Test strategy
|
||||||
|
- Rollout considerations
|
||||||
|
- Risk controls
|
||||||
|
- Implementation phases
|
||||||
|
|
||||||
|
The plan should clearly distinguish:
|
||||||
|
|
||||||
|
- execution truth
|
||||||
|
- artifact truth
|
||||||
|
- backup/snapshot truth
|
||||||
|
- recovery/evidence truth
|
||||||
|
- operator next action
|
||||||
|
|
||||||
|
Use those distinctions only where relevant to the feature.
|
||||||
|
|
||||||
|
## `tasks.md` Requirements
|
||||||
|
|
||||||
|
Tasks must be ordered, small, and verifiable.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- checkbox tasks
|
||||||
|
- phase grouping
|
||||||
|
- tests before or alongside implementation tasks where practical
|
||||||
|
- final validation tasks
|
||||||
|
- documentation/update tasks if needed
|
||||||
|
- explicit non-goals where useful
|
||||||
|
|
||||||
|
Avoid vague tasks such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Clean up code
|
||||||
|
Refactor UI
|
||||||
|
Improve performance
|
||||||
|
Make it enterprise-ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer concrete tasks such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
|
||||||
|
- [ ] Update <specific Filament page/resource> to display <specific state>.
|
||||||
|
- [ ] Add policy coverage for <specific capability>.
|
||||||
|
```
|
||||||
|
|
||||||
|
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
|
||||||
|
|
||||||
|
## Scope Control
|
||||||
|
|
||||||
|
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
|
||||||
|
|
||||||
|
Examples of follow-up candidates:
|
||||||
|
|
||||||
|
- assigned findings
|
||||||
|
- pending approvals
|
||||||
|
- personal work queue
|
||||||
|
- notification delivery settings
|
||||||
|
- evidence pack export hardening
|
||||||
|
- operation monitoring refinements
|
||||||
|
- autonomous governance decision surfaces
|
||||||
|
|
||||||
|
Do not force all follow-up candidates into the primary spec.
|
||||||
|
|
||||||
|
## Final Response Requirements
|
||||||
|
|
||||||
|
After creating or updating the artifacts, respond with:
|
||||||
|
|
||||||
|
1. Created or updated spec directory
|
||||||
|
2. Files created or updated
|
||||||
|
3. Important repo-based adjustments made
|
||||||
|
4. Assumptions made
|
||||||
|
5. Open questions, if any
|
||||||
|
6. Recommended next manual analysis prompt
|
||||||
|
7. Explicit statement that no implementation was performed
|
||||||
|
|
||||||
|
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||||
|
|
||||||
|
## Required Next Manual Analysis Prompt
|
||||||
|
|
||||||
|
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||||
|
|
||||||
|
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Implementierung.
|
||||||
|
- Keine Codeänderungen.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Prüfe nur gegen Repo-Wahrheit.
|
||||||
|
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
||||||
|
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
||||||
|
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Invocation
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
1. Inspect constitution, templates, specs, roadmap, and candidate docs.
|
||||||
|
2. Determine the next valid spec number.
|
||||||
|
3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory.
|
||||||
|
4. Keep scope tight.
|
||||||
|
5. Do not implement.
|
||||||
|
6. Return the summary and next manual analysis prompt.
|
||||||
@ -51,6 +51,9 @@
|
|||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
@ -317,7 +320,7 @@ public function content(Schema $schema): Schema
|
|||||||
Section::make('Tenant')
|
Section::make('Tenant')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra Tenant ID (GUID)')
|
->label('Tenant ID (GUID)')
|
||||||
->required()
|
->required()
|
||||||
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
||||||
->rules(['uuid'])
|
->rules(['uuid'])
|
||||||
@ -423,7 +426,8 @@ public function content(Schema $schema): Schema
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Directory (tenant) ID')
|
->label('Target scope ID')
|
||||||
|
->helperText('Provider-owned Microsoft tenant detail for this selected target scope.')
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false),
|
->dehydrated(false),
|
||||||
Toggle::make('uses_dedicated_override')
|
Toggle::make('uses_dedicated_override')
|
||||||
@ -461,6 +465,13 @@ public function content(Schema $schema): Schema
|
|||||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||||
|
TextInput::make('new_connection.target_scope_id')
|
||||||
|
->label('Target scope ID')
|
||||||
|
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false)
|
||||||
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||||
|
->helperText('The provider connection will point to this tenant target scope.'),
|
||||||
TextInput::make('new_connection.connection_type')
|
TextInput::make('new_connection.connection_type')
|
||||||
->label('Connection type')
|
->label('Connection type')
|
||||||
->default('Platform connection')
|
->default('Platform connection')
|
||||||
@ -657,7 +668,7 @@ public function content(Schema $schema): Schema
|
|||||||
UnorderedList::make([
|
UnorderedList::make([
|
||||||
'Tenant status will be set to Active.',
|
'Tenant status will be set to Active.',
|
||||||
'Backup, inventory, and compliance operations become available.',
|
'Backup, inventory, and compliance operations become available.',
|
||||||
'The provider connection will be used for all Graph API calls.',
|
'The provider connection will be used for provider API calls.',
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
Toggle::make('override_blocked')
|
Toggle::make('override_blocked')
|
||||||
@ -1593,6 +1604,7 @@ private function initializeWizardData(): void
|
|||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
||||||
|
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id;
|
||||||
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
||||||
$this->data['name'] ??= (string) $tenant->name;
|
$this->data['name'] ??= (string) $tenant->name;
|
||||||
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
||||||
@ -1676,14 +1688,56 @@ private function providerConnectionOptions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnection::query()
|
return ProviderConnection::query()
|
||||||
|
->with('tenant')
|
||||||
->where('workspace_id', (int) $this->workspace->getKey())
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->orderByDesc('is_default')
|
->orderByDesc('is_default')
|
||||||
->orderBy('display_name')
|
->orderBy('display_name')
|
||||||
->pluck('display_name', 'id')
|
->get()
|
||||||
|
->mapWithKeys(fn (ProviderConnection $connection): array => [
|
||||||
|
(int) $connection->getKey() => sprintf(
|
||||||
|
'%s — %s',
|
||||||
|
(string) $connection->display_name,
|
||||||
|
$this->providerConnectionTargetScopeSummary($connection),
|
||||||
|
),
|
||||||
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return 'Target scope needs review';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return array_merge([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'target_scope' => [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
'scope_identifier' => (string) $connection->entra_tenant_id,
|
||||||
|
'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id),
|
||||||
|
'shared_label' => 'Target scope',
|
||||||
|
'shared_help_text' => 'The platform scope this provider connection represents.',
|
||||||
|
],
|
||||||
|
'provider_identity_context' => [],
|
||||||
|
], $extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function verificationStatusLabel(): string
|
private function verificationStatusLabel(): string
|
||||||
{
|
{
|
||||||
return BadgeCatalog::spec(
|
return BadgeCatalog::spec(
|
||||||
@ -2599,12 +2653,11 @@ public function selectProviderConnection(int $providerConnectionId): void
|
|||||||
workspace: $this->workspace,
|
workspace: $this->workspace,
|
||||||
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'tenant_db_id' => (int) $tenant->getKey(),
|
'tenant_db_id' => (int) $tenant->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actor: $user,
|
actor: $user,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@ -2657,6 +2710,22 @@ public function createProviderConnection(array $data): void
|
|||||||
abort(422);
|
abort(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: (string) $tenant->tenant_id,
|
||||||
|
scopeDisplayName: $displayName,
|
||||||
|
providerSpecificIdentity: [
|
||||||
|
'microsoft_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'new_connection.target_scope_id' => $targetScope['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($usesDedicatedCredential) {
|
if ($usesDedicatedCredential) {
|
||||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||||
}
|
}
|
||||||
@ -2733,14 +2802,11 @@ public function createProviderConnection(array $data): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.created',
|
action: 'provider_connection.created',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'connection_type' => $connection->connection_type->value,
|
'connection_type' => $connection->connection_type->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -2756,15 +2822,12 @@ public function createProviderConnection(array $data): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.connection_type_changed',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'from_connection_type' => $previousConnectionType->value,
|
'from_connection_type' => $previousConnectionType->value,
|
||||||
'to_connection_type' => $connection->connection_type->value,
|
'to_connection_type' => $connection->connection_type->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -4304,15 +4367,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
|||||||
tenant: $this->managedTenant,
|
tenant: $this->managedTenant,
|
||||||
action: 'provider_connection.connection_type_changed',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'from_connection_type' => $existingType->value,
|
'from_connection_type' => $existingType->value,
|
||||||
'to_connection_type' => $targetType->value,
|
'to_connection_type' => $targetType->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: (int) $user->getKey(),
|
actorId: (int) $user->getKey(),
|
||||||
actorEmail: (string) $user->email,
|
actorEmail: (string) $user->email,
|
||||||
@ -4328,15 +4388,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
|||||||
tenant: $this->managedTenant,
|
tenant: $this->managedTenant,
|
||||||
action: 'provider_connection.updated',
|
action: 'provider_connection.updated',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'fields' => $changedFields,
|
|
||||||
'connection_type' => $targetType->value,
|
'connection_type' => $targetType->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: (int) $user->getKey(),
|
actorId: (int) $user->getKey(),
|
||||||
actorEmail: (string) $user->email,
|
actorEmail: (string) $user->email,
|
||||||
|
|||||||
@ -26,6 +26,8 @@
|
|||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
@ -50,6 +52,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
@ -484,6 +487,62 @@ private static function verificationStatusLabelFromState(mixed $state): string
|
|||||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function targetScopeHelpText(): string
|
||||||
|
{
|
||||||
|
return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function targetScopeSummary(?ProviderConnection $record): string
|
||||||
|
{
|
||||||
|
if (! $record instanceof ProviderConnection) {
|
||||||
|
return 'Target scope is set when this connection is saved.';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return 'Target scope needs review';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function providerIdentityContext(?ProviderConnection $record): ?string
|
||||||
|
{
|
||||||
|
if (! $record instanceof ProviderConnection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return array_merge([
|
||||||
|
'provider_connection_id' => (int) $record->getKey(),
|
||||||
|
'provider' => (string) $record->provider,
|
||||||
|
'target_scope' => [
|
||||||
|
'provider' => (string) $record->provider,
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => (string) $record->entra_tenant_id,
|
||||||
|
'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id),
|
||||||
|
'shared_label' => 'Target scope',
|
||||||
|
'shared_help_text' => static::targetScopeHelpText(),
|
||||||
|
],
|
||||||
|
'provider_identity_context' => [],
|
||||||
|
], $extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -496,11 +555,17 @@ public static function form(Schema $schema): Schema
|
|||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra tenant ID')
|
->label('Target scope ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
|
->helperText(static::targetScopeHelpText())
|
||||||
|
->validationAttribute('target scope ID')
|
||||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->rules(['uuid']),
|
->rules(['uuid']),
|
||||||
|
Placeholder::make('target_scope_display')
|
||||||
|
->label('Target scope')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||||
|
->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection),
|
||||||
Placeholder::make('connection_type_display')
|
Placeholder::make('connection_type_display')
|
||||||
->label('Connection type')
|
->label('Connection type')
|
||||||
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
||||||
@ -563,8 +628,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Display name'),
|
->label('Display name'),
|
||||||
Infolists\Components\TextEntry::make('provider')
|
Infolists\Components\TextEntry::make('provider')
|
||||||
->label('Provider'),
|
->label('Provider'),
|
||||||
Infolists\Components\TextEntry::make('entra_tenant_id')
|
Infolists\Components\TextEntry::make('target_scope')
|
||||||
->label('Entra tenant ID')
|
->label('Target scope')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||||
->copyable(),
|
->copyable(),
|
||||||
Infolists\Components\TextEntry::make('connection_type')
|
Infolists\Components\TextEntry::make('connection_type')
|
||||||
->label('Connection type')
|
->label('Connection type')
|
||||||
@ -614,6 +680,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Migration review')
|
->label('Migration review')
|
||||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||||
|
Infolists\Components\TextEntry::make('provider_identity_context')
|
||||||
|
->label('Provider identity details')
|
||||||
|
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
|
||||||
|
->placeholder('n/a')
|
||||||
|
->columnSpanFull(),
|
||||||
Infolists\Components\TextEntry::make('last_error_reason_code')
|
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||||
->label('Last error reason')
|
->label('Last error reason')
|
||||||
->placeholder('n/a'),
|
->placeholder('n/a'),
|
||||||
@ -671,9 +742,15 @@ public static function table(Table $table): Table
|
|||||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
|
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('provider')
|
||||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
->label('Provider')
|
||||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
|
||||||
|
Tables\Columns\TextColumn::make('target_scope')
|
||||||
|
->label('Target scope')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||||
|
->copyable(),
|
||||||
|
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('connection_type')
|
Tables\Columns\TextColumn::make('connection_type')
|
||||||
->label('Connection type')
|
->label('Connection type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -949,10 +1026,7 @@ public static function makeSetDefaultAction(): Actions\Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.default_set',
|
action: 'provider_connection.default_set',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record),
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -1014,15 +1088,12 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.connection_type_changed',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'provider_connection_id' => (int) $record->getKey(),
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
'client_id' => (string) $data['client_id'],
|
'client_id' => (string) $data['client_id'],
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -1161,14 +1232,11 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.connection_type_changed',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'provider_connection_id' => (int) $record->getKey(),
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -1233,14 +1301,12 @@ public static function makeEnableConnectionAction(): Actions\Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.enabled',
|
action: 'provider_connection.enabled',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||||
'to_lifecycle' => 'enabled',
|
'to_lifecycle' => 'enabled',
|
||||||
'verification_status' => $verificationStatus->value,
|
'verification_status' => $verificationStatus->value,
|
||||||
'credentials_present' => $hadCredentials,
|
'credentials_present' => $hadCredentials,
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -1302,12 +1368,10 @@ public static function makeDisableConnectionAction(): Actions\Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.disabled',
|
action: 'provider_connection.disabled',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||||
'to_lifecycle' => 'disabled',
|
'to_lifecycle' => 'disabled',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
|
|||||||
@ -9,9 +9,12 @@
|
|||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class CreateProviderConnection extends CreateRecord
|
class CreateProviderConnection extends CreateRecord
|
||||||
{
|
{
|
||||||
@ -28,6 +31,21 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
|
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
|
||||||
|
scopeDisplayName: (string) ($data['display_name'] ?? ''),
|
||||||
|
providerSpecificIdentity: [
|
||||||
|
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'entra_tenant_id' => $targetScope['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -70,11 +88,9 @@ protected function afterCreate(): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.created',
|
action: 'provider_connection.created',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'connection_type' => $record->connection_type->value,
|
'connection_type' => $record->connection_type->value,
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
|
|||||||
@ -19,6 +19,8 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -26,6 +28,7 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class EditProviderConnection extends EditRecord
|
class EditProviderConnection extends EditRecord
|
||||||
{
|
{
|
||||||
@ -77,6 +80,22 @@ protected function mutateFormDataBeforeSave(array $data): array
|
|||||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||||
unset($data['is_default']);
|
unset($data['is_default']);
|
||||||
|
|
||||||
|
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
|
||||||
|
scopeDisplayName: (string) ($data['display_name'] ?? ''),
|
||||||
|
providerSpecificIdentity: [
|
||||||
|
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'entra_tenant_id' => $targetScope['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,11 +138,9 @@ protected function afterSave(): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.updated',
|
action: 'provider_connection.updated',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||||
'provider' => $record->provider,
|
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
]),
|
||||||
'fields' => $changedFields,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -139,10 +156,7 @@ protected function afterSave(): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.default_set',
|
action: 'provider_connection.default_set',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
|
|||||||
@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
|||||||
|
|
||||||
public function getTableEmptyStateHeading(): ?string
|
public function getTableEmptyStateHeading(): ?string
|
||||||
{
|
{
|
||||||
return 'No Microsoft connections found';
|
return 'No provider connections found';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTableEmptyStateDescription(): ?string
|
public function getTableEmptyStateDescription(): ?string
|
||||||
{
|
{
|
||||||
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTableEmptyStateActions(): array
|
public function getTableEmptyStateActions(): array
|
||||||
|
|||||||
@ -452,6 +452,11 @@ private function logVerificationResult(
|
|||||||
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
||||||
'credential_source' => $identity->credentialSource,
|
'credential_source' => $identity->credentialSource,
|
||||||
'effective_client_id' => $identity->effectiveClientId,
|
'effective_client_id' => $identity->effectiveClientId,
|
||||||
|
'target_scope' => $identity->targetScope?->toArray(),
|
||||||
|
'provider_identity_context' => array_map(
|
||||||
|
static fn ($detail): array => $detail->toArray(),
|
||||||
|
$identity->contextualIdentityDetails,
|
||||||
|
),
|
||||||
'reason_code' => $reasonCode,
|
'reason_code' => $reasonCode,
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
'previous_consent_status' => $previousConsentStatus,
|
'previous_consent_status' => $previousConsentStatus,
|
||||||
|
|||||||
@ -4,11 +4,19 @@
|
|||||||
|
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||||
|
|
||||||
final class PlatformProviderIdentityResolver
|
final class PlatformProviderIdentityResolver
|
||||||
{
|
{
|
||||||
public function resolve(string $tenantContext): ProviderIdentityResolution
|
/**
|
||||||
{
|
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||||
|
*/
|
||||||
|
public function resolve(
|
||||||
|
string $tenantContext,
|
||||||
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
|
array $contextualIdentityDetails = [],
|
||||||
|
): ProviderIdentityResolution {
|
||||||
$targetTenant = trim($tenantContext);
|
$targetTenant = trim($tenantContext);
|
||||||
$clientId = trim((string) config('graph.client_id'));
|
$clientId = trim((string) config('graph.client_id'));
|
||||||
$clientSecret = trim((string) config('graph.client_secret'));
|
$clientSecret = trim((string) config('graph.client_secret'));
|
||||||
@ -22,6 +30,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
message: 'Provider connection is missing target tenant scope.',
|
message: 'Provider connection is missing target tenant scope.',
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +42,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
|
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
|
||||||
message: 'Platform app identity is not configured.',
|
message: 'Platform app identity is not configured.',
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,6 +54,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
credentialSource: 'platform_config',
|
credentialSource: 'platform_config',
|
||||||
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
|
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||||
message: 'Platform app identity is incomplete.',
|
message: 'Platform app identity is incomplete.',
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +67,13 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
clientSecret: $clientSecret,
|
clientSecret: $clientSecret,
|
||||||
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
|
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
|
||||||
redirectUri: $redirectUri,
|
redirectUri: $redirectUri,
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails !== []
|
||||||
|
? array_values(array_merge($contextualIdentityDetails, array_filter([
|
||||||
|
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
|
||||||
|
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
||||||
|
])))
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ public function enableDedicatedOverride(
|
|||||||
$clientSecret = trim($clientSecret);
|
$clientSecret = trim($clientSecret);
|
||||||
|
|
||||||
if ($clientId === '' || $clientSecret === '') {
|
if ($clientId === '' || $clientSecret === '') {
|
||||||
throw new InvalidArgumentException('Dedicated client_id and client_secret are required.');
|
throw new InvalidArgumentException('Dedicated app (client) ID and client secret are required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {
|
return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {
|
||||||
|
|||||||
@ -4,25 +4,38 @@
|
|||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||||
|
|
||||||
final class ProviderConnectionResolution
|
final class ProviderConnectionResolution
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||||
|
*/
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public readonly bool $resolved,
|
public readonly bool $resolved,
|
||||||
public readonly ?ProviderConnection $connection,
|
public readonly ?ProviderConnection $connection,
|
||||||
public readonly ?string $reasonCode,
|
public readonly ?string $reasonCode,
|
||||||
public readonly ?string $extensionReasonCode,
|
public readonly ?string $extensionReasonCode,
|
||||||
public readonly ?string $message,
|
public readonly ?string $message,
|
||||||
|
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||||
|
public readonly array $contextualIdentityDetails,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function resolved(ProviderConnection $connection): self
|
public static function resolved(ProviderConnection $connection): self
|
||||||
{
|
{
|
||||||
|
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
|
||||||
|
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
resolved: true,
|
resolved: true,
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
reasonCode: null,
|
reasonCode: null,
|
||||||
extensionReasonCode: null,
|
extensionReasonCode: null,
|
||||||
message: null,
|
message: null,
|
||||||
|
targetScope: $normalizer->descriptorForConnection($connection),
|
||||||
|
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,12 +45,29 @@ public static function blocked(
|
|||||||
?string $extensionReasonCode = null,
|
?string $extensionReasonCode = null,
|
||||||
?ProviderConnection $connection = null,
|
?ProviderConnection $connection = null,
|
||||||
): self {
|
): self {
|
||||||
|
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
|
||||||
|
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||||
|
$targetScope = null;
|
||||||
|
$contextualIdentityDetails = [];
|
||||||
|
|
||||||
|
if ($connection instanceof ProviderConnection) {
|
||||||
|
$normalization = $normalizer->normalizeConnection($connection);
|
||||||
|
$descriptor = $normalization['target_scope'] ?? null;
|
||||||
|
|
||||||
|
if ($descriptor instanceof ProviderConnectionTargetScopeDescriptor) {
|
||||||
|
$targetScope = $descriptor;
|
||||||
|
$contextualIdentityDetails = $normalizer->contextualIdentityDetailsForConnection($connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
resolved: false,
|
resolved: false,
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||||
extensionReasonCode: $extensionReasonCode,
|
extensionReasonCode: $extensionReasonCode,
|
||||||
message: $message,
|
message: $message,
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,13 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
|
|
||||||
final class ProviderConnectionResolver
|
final class ProviderConnectionResolver
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProviderIdentityResolver $identityResolver,
|
private readonly ProviderIdentityResolver $identityResolver,
|
||||||
|
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution
|
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution
|
||||||
@ -63,11 +65,19 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
|
$targetScope = $this->targetScopeNormalizer->normalizeConnection($connection);
|
||||||
|
|
||||||
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
|
$failureCode = $targetScope['failure_code'] ?? ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT;
|
||||||
|
|
||||||
return ProviderConnectionResolution::blocked(
|
return ProviderConnectionResolution::blocked(
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
|
||||||
'Provider connection is missing target tenant scope.',
|
? ProviderReasonCodes::ProviderBindingUnsupported
|
||||||
'ext.connection_tenant_missing',
|
: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
$targetScope['message'] ?? 'Provider connection target scope is invalid.',
|
||||||
|
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
|
||||||
|
? 'ext.connection_scope_unsupported'
|
||||||
|
: 'ext.connection_scope_missing',
|
||||||
$connection,
|
$connection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,16 @@
|
|||||||
use App\Services\Providers\Contracts\HealthResult;
|
use App\Services\Providers\Contracts\HealthResult;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
|
||||||
final class ProviderConnectionStateProjector
|
final class ProviderConnectionStateProjector
|
||||||
{
|
{
|
||||||
|
public function surfaceSummary(ProviderConnection $connection): ProviderConnectionSurfaceSummary
|
||||||
|
{
|
||||||
|
return ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* consent_status: ProviderConsentStatus,
|
* consent_status: ProviderConsentStatus,
|
||||||
|
|||||||
@ -4,9 +4,14 @@
|
|||||||
|
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||||
|
|
||||||
final class ProviderIdentityResolution
|
final class ProviderIdentityResolution
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||||
|
*/
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public readonly bool $resolved,
|
public readonly bool $resolved,
|
||||||
public readonly ProviderConnectionType $connectionType,
|
public readonly ProviderConnectionType $connectionType,
|
||||||
@ -18,6 +23,8 @@ private function __construct(
|
|||||||
public readonly ?string $redirectUri,
|
public readonly ?string $redirectUri,
|
||||||
public readonly ?string $reasonCode,
|
public readonly ?string $reasonCode,
|
||||||
public readonly ?string $message,
|
public readonly ?string $message,
|
||||||
|
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||||
|
public readonly array $contextualIdentityDetails,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function resolved(
|
public static function resolved(
|
||||||
@ -28,6 +35,8 @@ public static function resolved(
|
|||||||
?string $clientSecret,
|
?string $clientSecret,
|
||||||
?string $authorityTenant,
|
?string $authorityTenant,
|
||||||
?string $redirectUri,
|
?string $redirectUri,
|
||||||
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
|
array $contextualIdentityDetails = [],
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
resolved: true,
|
resolved: true,
|
||||||
@ -40,6 +49,10 @@ public static function resolved(
|
|||||||
redirectUri: $redirectUri,
|
redirectUri: $redirectUri,
|
||||||
reasonCode: null,
|
reasonCode: null,
|
||||||
message: null,
|
message: null,
|
||||||
|
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails !== []
|
||||||
|
? $contextualIdentityDetails
|
||||||
|
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +62,8 @@ public static function blocked(
|
|||||||
string $credentialSource,
|
string $credentialSource,
|
||||||
string $reasonCode,
|
string $reasonCode,
|
||||||
?string $message = null,
|
?string $message = null,
|
||||||
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
|
array $contextualIdentityDetails = [],
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
resolved: false,
|
resolved: false,
|
||||||
@ -61,6 +76,10 @@ public static function blocked(
|
|||||||
redirectUri: null,
|
redirectUri: null,
|
||||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||||
message: $message,
|
message: $message,
|
||||||
|
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails !== []
|
||||||
|
? $contextualIdentityDetails
|
||||||
|
: self::contextualIdentityDetails($tenantContext),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,4 +87,36 @@ public function effectiveReasonCode(): string
|
|||||||
{
|
{
|
||||||
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor
|
||||||
|
{
|
||||||
|
$identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations';
|
||||||
|
|
||||||
|
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: $identifier,
|
||||||
|
scopeDisplayName: $identifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ProviderIdentityContextMetadata>
|
||||||
|
*/
|
||||||
|
private static function contextualIdentityDetails(
|
||||||
|
string $tenantContext,
|
||||||
|
?string $authorityTenant = null,
|
||||||
|
?string $redirectUri = null,
|
||||||
|
): array {
|
||||||
|
$details = [
|
||||||
|
ProviderIdentityContextMetadata::microsoftTenantId($tenantContext),
|
||||||
|
ProviderIdentityContextMetadata::authorityTenant($authorityTenant),
|
||||||
|
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$details,
|
||||||
|
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderCredentialSource;
|
use App\Support\Providers\ProviderCredentialSource;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@ -15,12 +17,16 @@ final class ProviderIdentityResolver
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PlatformProviderIdentityResolver $platformResolver,
|
private readonly PlatformProviderIdentityResolver $platformResolver,
|
||||||
private readonly CredentialManager $credentials,
|
private readonly CredentialManager $credentials,
|
||||||
|
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
|
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
|
||||||
{
|
{
|
||||||
$tenantContext = trim((string) $connection->entra_tenant_id);
|
$tenantContext = trim((string) $connection->entra_tenant_id);
|
||||||
$connectionType = $this->resolveConnectionType($connection);
|
$connectionType = $this->resolveConnectionType($connection);
|
||||||
|
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
|
||||||
|
$targetScope = $targetScopeResult['target_scope'] ?? null;
|
||||||
|
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
|
||||||
|
|
||||||
if ($connectionType === null) {
|
if ($connectionType === null) {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
@ -29,16 +35,20 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
|
|||||||
credentialSource: 'unknown',
|
credentialSource: 'unknown',
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
||||||
message: 'Provider connection type is invalid.',
|
message: 'Provider connection type is invalid.',
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenantContext === '') {
|
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
return ProviderIdentityResolution::blocked(
|
return ProviderIdentityResolution::blocked(
|
||||||
connectionType: $connectionType,
|
connectionType: $connectionType,
|
||||||
tenantContext: 'organizations',
|
tenantContext: 'organizations',
|
||||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
|
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
message: 'Provider connection is missing target tenant scope.',
|
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,14 +59,25 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
|
|||||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
|
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
|
||||||
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
|
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||||
message: 'Provider connection requires migration review before use.',
|
message: 'Provider connection requires migration review before use.',
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($connectionType === ProviderConnectionType::Platform) {
|
if ($connectionType === ProviderConnectionType::Platform) {
|
||||||
return $this->platformResolver->resolve($tenantContext);
|
return $this->platformResolver->resolve(
|
||||||
|
tenantContext: $tenantContext,
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveDedicatedIdentity($connection, $tenantContext);
|
return $this->resolveDedicatedIdentity(
|
||||||
|
connection: $connection,
|
||||||
|
tenantContext: $tenantContext,
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
|
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
|
||||||
@ -77,6 +98,8 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
|
|||||||
private function resolveDedicatedIdentity(
|
private function resolveDedicatedIdentity(
|
||||||
ProviderConnection $connection,
|
ProviderConnection $connection,
|
||||||
string $tenantContext,
|
string $tenantContext,
|
||||||
|
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||||
|
array $contextualIdentityDetails = [],
|
||||||
): ProviderIdentityResolution {
|
): ProviderIdentityResolution {
|
||||||
try {
|
try {
|
||||||
$credentials = $this->credentials->getClientCredentials($connection);
|
$credentials = $this->credentials->getClientCredentials($connection);
|
||||||
@ -89,6 +112,8 @@ private function resolveDedicatedIdentity(
|
|||||||
? ProviderReasonCodes::DedicatedCredentialInvalid
|
? ProviderReasonCodes::DedicatedCredentialInvalid
|
||||||
: ProviderReasonCodes::DedicatedCredentialMissing,
|
: ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
message: $exception->getMessage(),
|
message: $exception->getMessage(),
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +125,8 @@ private function resolveDedicatedIdentity(
|
|||||||
clientSecret: $credentials['client_secret'],
|
clientSecret: $credentials['client_secret'],
|
||||||
authorityTenant: $tenantContext,
|
authorityTenant: $tenantContext,
|
||||||
redirectUri: trim((string) route('admin.consent.callback')),
|
redirectUri: trim((string) route('admin.consent.callback')),
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -119,6 +119,11 @@ public function providerConnectionCheckUsingConnection(
|
|||||||
'connection_type' => $identity->connectionType->value,
|
'connection_type' => $identity->connectionType->value,
|
||||||
'credential_source' => $identity->credentialSource,
|
'credential_source' => $identity->credentialSource,
|
||||||
'effective_client_id' => $identity->effectiveClientId,
|
'effective_client_id' => $identity->effectiveClientId,
|
||||||
|
'target_scope' => $identity->targetScope?->toArray(),
|
||||||
|
'provider_identity_context' => array_map(
|
||||||
|
static fn ($detail): array => $detail->toArray(),
|
||||||
|
$identity->contextualIdentityDetails,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Providers\TargetScope;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
|
||||||
|
final class ProviderConnectionSurfaceSummary
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $provider,
|
||||||
|
public readonly ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||||
|
public readonly string $consentState,
|
||||||
|
public readonly string $verificationState,
|
||||||
|
public readonly string $readinessSummary,
|
||||||
|
public readonly array $contextualIdentityDetails = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function forConnection(ProviderConnection $connection): self
|
||||||
|
{
|
||||||
|
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
|
||||||
|
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||||
|
$targetScope = $normalizer->descriptorForConnection($connection);
|
||||||
|
$consentState = self::stateValue($connection->consent_status);
|
||||||
|
$verificationState = self::stateValue($connection->verification_status);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
provider: trim((string) $connection->provider),
|
||||||
|
targetScope: $targetScope,
|
||||||
|
consentState: $consentState,
|
||||||
|
verificationState: $verificationState,
|
||||||
|
readinessSummary: self::readinessSummary(
|
||||||
|
isEnabled: (bool) $connection->is_enabled,
|
||||||
|
consentState: $consentState,
|
||||||
|
verificationState: $verificationState,
|
||||||
|
),
|
||||||
|
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetScopeSummary(): string
|
||||||
|
{
|
||||||
|
return $this->targetScope->summary();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contextualIdentityLine(): ?string
|
||||||
|
{
|
||||||
|
if ($this->contextualIdentityDetails === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($this->contextualIdentityDetails)
|
||||||
|
->map(fn (ProviderIdentityContextMetadata $detail): string => sprintf('%s: %s', $detail->detailLabel, $detail->detailValue))
|
||||||
|
->implode("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* target_scope: array<string, string>,
|
||||||
|
* consent_state: string,
|
||||||
|
* verification_state: string,
|
||||||
|
* readiness_summary: string,
|
||||||
|
* contextual_identity_details: list<array<string, string>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'target_scope' => $this->targetScope->toArray(),
|
||||||
|
'consent_state' => $this->consentState,
|
||||||
|
'verification_state' => $this->verificationState,
|
||||||
|
'readiness_summary' => $this->readinessSummary,
|
||||||
|
'contextual_identity_details' => array_map(
|
||||||
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$this->contextualIdentityDetails,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stateValue(mixed $state): string
|
||||||
|
{
|
||||||
|
if ($state instanceof ProviderConsentStatus || $state instanceof ProviderVerificationStatus) {
|
||||||
|
return $state->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function readinessSummary(bool $isEnabled, string $consentState, string $verificationState): string
|
||||||
|
{
|
||||||
|
if (! $isEnabled) {
|
||||||
|
return 'Disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||||
|
return sprintf(
|
||||||
|
'Consent %s',
|
||||||
|
strtolower(BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $consentState)->label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($verificationState) {
|
||||||
|
ProviderVerificationStatus::Healthy->value => 'Ready',
|
||||||
|
ProviderVerificationStatus::Degraded->value => 'Ready with warnings',
|
||||||
|
ProviderVerificationStatus::Blocked->value => 'Verification blocked',
|
||||||
|
ProviderVerificationStatus::Error->value => 'Verification failed',
|
||||||
|
ProviderVerificationStatus::Pending->value => 'Verification pending',
|
||||||
|
default => 'Verification not run',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Providers\TargetScope;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ProviderConnectionTargetScopeDescriptor
|
||||||
|
{
|
||||||
|
public const string SCOPE_KIND_TENANT = 'tenant';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $provider,
|
||||||
|
public readonly string $scopeKind,
|
||||||
|
public readonly string $scopeIdentifier,
|
||||||
|
public readonly string $scopeDisplayName,
|
||||||
|
public readonly string $sharedLabel = 'Target scope',
|
||||||
|
public readonly string $sharedHelpText = 'The platform scope this provider connection represents.',
|
||||||
|
) {
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromConnection(ProviderConnection $connection): self
|
||||||
|
{
|
||||||
|
$tenantName = is_string($connection->tenant?->name) && trim($connection->tenant->name) !== ''
|
||||||
|
? trim($connection->tenant->name)
|
||||||
|
: trim((string) $connection->display_name);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
provider: trim((string) $connection->provider),
|
||||||
|
scopeKind: self::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: trim((string) $connection->entra_tenant_id),
|
||||||
|
scopeDisplayName: $tenantName !== '' ? $tenantName : trim((string) $connection->entra_tenant_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromInput(
|
||||||
|
string $provider,
|
||||||
|
string $scopeKind,
|
||||||
|
string $scopeIdentifier,
|
||||||
|
?string $scopeDisplayName = null,
|
||||||
|
): self {
|
||||||
|
$scopeIdentifier = trim($scopeIdentifier);
|
||||||
|
$displayName = trim((string) $scopeDisplayName);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
provider: trim($provider),
|
||||||
|
scopeKind: trim($scopeKind),
|
||||||
|
scopeIdentifier: $scopeIdentifier,
|
||||||
|
scopeDisplayName: $displayName !== '' ? $displayName : $scopeIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): string
|
||||||
|
{
|
||||||
|
if ($this->scopeDisplayName !== '' && $this->scopeDisplayName !== $this->scopeIdentifier) {
|
||||||
|
return sprintf('%s (%s)', $this->scopeDisplayName, $this->scopeIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->scopeIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* scope_identifier: string,
|
||||||
|
* scope_display_name: string,
|
||||||
|
* shared_label: string,
|
||||||
|
* shared_help_text: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'scope_kind' => $this->scopeKind,
|
||||||
|
'scope_identifier' => $this->scopeIdentifier,
|
||||||
|
'scope_display_name' => $this->scopeDisplayName,
|
||||||
|
'shared_label' => $this->sharedLabel,
|
||||||
|
'shared_help_text' => $this->sharedHelpText,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
if ($this->provider === '') {
|
||||||
|
throw new InvalidArgumentException('Provider is required for target-scope descriptors.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->scopeKind !== self::SCOPE_KIND_TENANT) {
|
||||||
|
throw new InvalidArgumentException('Unsupported provider connection target-scope kind.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->scopeIdentifier === '') {
|
||||||
|
throw new InvalidArgumentException('Target scope identifier is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->scopeDisplayName === '') {
|
||||||
|
throw new InvalidArgumentException('Target scope display name is required.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Providers\TargetScope;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ProviderConnectionTargetScopeNormalizer
|
||||||
|
{
|
||||||
|
public const string STATUS_NORMALIZED = 'normalized';
|
||||||
|
|
||||||
|
public const string STATUS_BLOCKED = 'blocked';
|
||||||
|
|
||||||
|
public const string FAILURE_NONE = 'none';
|
||||||
|
|
||||||
|
public const string FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION = 'unsupported_provider_scope_combination';
|
||||||
|
|
||||||
|
public const string FAILURE_MISSING_PROVIDER_CONTEXT = 'missing_provider_context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $providerSpecificIdentity
|
||||||
|
* @return array{
|
||||||
|
* status: string,
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* target_scope?: ProviderConnectionTargetScopeDescriptor,
|
||||||
|
* contextual_identity_details?: list<ProviderIdentityContextMetadata>,
|
||||||
|
* preview_summary?: ?ProviderConnectionSurfaceSummary,
|
||||||
|
* failure_code: string,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function normalizeInput(
|
||||||
|
string $provider,
|
||||||
|
string $scopeKind,
|
||||||
|
string $scopeIdentifier,
|
||||||
|
?string $scopeDisplayName = null,
|
||||||
|
array $providerSpecificIdentity = [],
|
||||||
|
): array {
|
||||||
|
$provider = trim($provider);
|
||||||
|
$scopeKind = trim($scopeKind);
|
||||||
|
$scopeIdentifier = trim($scopeIdentifier);
|
||||||
|
|
||||||
|
if ($provider !== 'microsoft' || $scopeKind !== ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) {
|
||||||
|
return $this->blocked(
|
||||||
|
provider: $provider,
|
||||||
|
scopeKind: $scopeKind,
|
||||||
|
failureCode: self::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION,
|
||||||
|
message: 'This provider and target-scope combination is not supported.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scopeIdentifier === '') {
|
||||||
|
return $this->blocked(
|
||||||
|
provider: $provider,
|
||||||
|
scopeKind: $scopeKind,
|
||||||
|
failureCode: self::FAILURE_MISSING_PROVIDER_CONTEXT,
|
||||||
|
message: 'A target scope identifier is required for this provider connection.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||||
|
provider: $provider,
|
||||||
|
scopeKind: $scopeKind,
|
||||||
|
scopeIdentifier: $scopeIdentifier,
|
||||||
|
scopeDisplayName: $scopeDisplayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => self::STATUS_NORMALIZED,
|
||||||
|
'provider' => $provider,
|
||||||
|
'scope_kind' => $scopeKind,
|
||||||
|
'target_scope' => $descriptor,
|
||||||
|
'contextual_identity_details' => $this->contextualIdentityDetails(
|
||||||
|
provider: $provider,
|
||||||
|
scopeIdentifier: $scopeIdentifier,
|
||||||
|
providerSpecificIdentity: $providerSpecificIdentity,
|
||||||
|
),
|
||||||
|
'preview_summary' => null,
|
||||||
|
'failure_code' => self::FAILURE_NONE,
|
||||||
|
'message' => 'Target scope normalized.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* status: string,
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* target_scope?: ProviderConnectionTargetScopeDescriptor,
|
||||||
|
* contextual_identity_details?: list<ProviderIdentityContextMetadata>,
|
||||||
|
* preview_summary?: ?ProviderConnectionSurfaceSummary,
|
||||||
|
* failure_code: string,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function normalizeConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
return $this->normalizeInput(
|
||||||
|
provider: trim((string) $connection->provider),
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: trim((string) $connection->entra_tenant_id),
|
||||||
|
scopeDisplayName: $connection->tenant?->name ?? $connection->display_name,
|
||||||
|
providerSpecificIdentity: [
|
||||||
|
'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function descriptorForConnection(ProviderConnection $connection): ProviderConnectionTargetScopeDescriptor
|
||||||
|
{
|
||||||
|
$result = $this->normalizeConnection($connection);
|
||||||
|
$descriptor = $result['target_scope'] ?? null;
|
||||||
|
|
||||||
|
if (! $descriptor instanceof ProviderConnectionTargetScopeDescriptor) {
|
||||||
|
throw new InvalidArgumentException($result['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ProviderIdentityContextMetadata>
|
||||||
|
*/
|
||||||
|
public function contextualIdentityDetailsForConnection(ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
return $this->contextualIdentityDetails(
|
||||||
|
provider: trim((string) $connection->provider),
|
||||||
|
scopeIdentifier: trim((string) $connection->entra_tenant_id),
|
||||||
|
providerSpecificIdentity: [
|
||||||
|
'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function auditMetadataForConnection(ProviderConnection $connection, array $extra = []): array
|
||||||
|
{
|
||||||
|
$summary = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'target_scope' => $summary->targetScope->toArray(),
|
||||||
|
'provider_identity_context' => array_map(
|
||||||
|
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||||
|
$summary->contextualIdentityDetails,
|
||||||
|
),
|
||||||
|
], $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $fields
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function auditFieldNames(array $fields): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (string $field): string => $field === 'entra_tenant_id' ? 'target_scope_identifier' : $field,
|
||||||
|
$fields,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $providerSpecificIdentity
|
||||||
|
* @return list<ProviderIdentityContextMetadata>
|
||||||
|
*/
|
||||||
|
private function contextualIdentityDetails(string $provider, string $scopeIdentifier, array $providerSpecificIdentity = []): array
|
||||||
|
{
|
||||||
|
if ($provider !== 'microsoft') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = [
|
||||||
|
ProviderIdentityContextMetadata::microsoftTenantId(
|
||||||
|
$providerSpecificIdentity['microsoft_tenant_id'] ?? $scopeIdentifier,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$details,
|
||||||
|
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* status: string,
|
||||||
|
* provider: string,
|
||||||
|
* scope_kind: string,
|
||||||
|
* failure_code: string,
|
||||||
|
* message: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function blocked(string $provider, string $scopeKind, string $failureCode, string $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => self::STATUS_BLOCKED,
|
||||||
|
'provider' => $provider,
|
||||||
|
'scope_kind' => $scopeKind,
|
||||||
|
'failure_code' => $failureCode,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Providers\TargetScope;
|
||||||
|
|
||||||
|
final class ProviderIdentityContextMetadata
|
||||||
|
{
|
||||||
|
public const string VISIBILITY_CONTEXTUAL_ONLY = 'contextual_only';
|
||||||
|
|
||||||
|
public const string VISIBILITY_AUDIT_ONLY = 'audit_only';
|
||||||
|
|
||||||
|
public const string VISIBILITY_TROUBLESHOOTING_ONLY = 'troubleshooting_only';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $provider,
|
||||||
|
public readonly string $detailKey,
|
||||||
|
public readonly string $detailLabel,
|
||||||
|
public readonly string $detailValue,
|
||||||
|
public readonly string $visibility = self::VISIBILITY_CONTEXTUAL_ONLY,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function microsoftTenantId(?string $value, string $visibility = self::VISIBILITY_CONTEXTUAL_ONLY): ?self
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
provider: 'microsoft',
|
||||||
|
detailKey: 'microsoft_tenant_id',
|
||||||
|
detailLabel: 'Microsoft tenant ID',
|
||||||
|
detailValue: $value,
|
||||||
|
visibility: $visibility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function authorityTenant(?string $value, string $visibility = self::VISIBILITY_TROUBLESHOOTING_ONLY): ?self
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
provider: 'microsoft',
|
||||||
|
detailKey: 'authority_tenant',
|
||||||
|
detailLabel: 'Authority tenant',
|
||||||
|
detailValue: $value,
|
||||||
|
visibility: $visibility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redirectUri(?string $value, string $visibility = self::VISIBILITY_TROUBLESHOOTING_ONLY): ?self
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
provider: 'microsoft',
|
||||||
|
detailKey: 'redirect_uri',
|
||||||
|
detailLabel: 'Redirect URI',
|
||||||
|
detailValue: $value,
|
||||||
|
visibility: $visibility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* provider: string,
|
||||||
|
* detail_key: string,
|
||||||
|
* detail_label: string,
|
||||||
|
* detail_value: string,
|
||||||
|
* visibility: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'detail_key' => $this->detailKey,
|
||||||
|
'detail_label' => $this->detailLabel,
|
||||||
|
'detail_value' => $this->detailValue,
|
||||||
|
'visibility' => $this->visibility,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -63,3 +67,55 @@
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records provider connection create audits with neutral target-scope metadata', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(CreateProviderConnection::class)
|
||||||
|
->fillForm([
|
||||||
|
'display_name' => 'Audit target scope connection',
|
||||||
|
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
|
||||||
|
'is_default' => true,
|
||||||
|
])
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('display_name', 'Audit target scope connection')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$log = AuditLog::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('action', 'provider_connection.created')
|
||||||
|
->where('resource_id', (string) $connection->getKey())
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$metadata = is_array($log->metadata) ? $log->metadata : [];
|
||||||
|
|
||||||
|
expect($metadata)->toHaveKeys([
|
||||||
|
'provider_connection_id',
|
||||||
|
'provider',
|
||||||
|
'target_scope',
|
||||||
|
'provider_identity_context',
|
||||||
|
'connection_type',
|
||||||
|
])
|
||||||
|
->and($metadata)->not->toHaveKey('entra_tenant_id')
|
||||||
|
->and($metadata['target_scope'])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'scope_kind' => 'tenant',
|
||||||
|
'scope_identifier' => '88888888-8888-8888-8888-888888888888',
|
||||||
|
'shared_label' => 'Target scope',
|
||||||
|
])
|
||||||
|
->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'detail_key' => 'microsoft_tenant_id',
|
||||||
|
'detail_label' => 'Microsoft tenant ID',
|
||||||
|
'detail_value' => '88888888-8888-8888-8888-888888888888',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@ -58,15 +58,18 @@
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
|
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
|
||||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
|
||||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||||
expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status');
|
expect($visibleColumnNames)->toContain('provider', 'target_scope', 'is_enabled', 'consent_status', 'verification_status');
|
||||||
expect($visibleColumnNames)->not->toContain('status');
|
expect($visibleColumnNames)->not->toContain('status');
|
||||||
expect($visibleColumnNames)->not->toContain('health_status');
|
expect($visibleColumnNames)->not->toContain('health_status');
|
||||||
|
expect($visibleColumnNames)->not->toContain('entra_tenant_id');
|
||||||
expect($table->getColumn('status'))->toBeNull();
|
expect($table->getColumn('status'))->toBeNull();
|
||||||
expect($table->getColumn('health_status'))->toBeNull();
|
expect($table->getColumn('health_status'))->toBeNull();
|
||||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse();
|
||||||
|
expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope');
|
||||||
|
expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
|
||||||
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('migration_review_required'))->not->toBeNull();
|
expect($table->getColumn('migration_review_required'))->not->toBeNull();
|
||||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||||
|
|||||||
@ -35,6 +35,24 @@
|
|||||||
->assertDontSee('Unauthorized Tenant Connection');
|
->assertDontSee('Unauthorized Tenant Connection');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('non-members cannot reach provider connection detail target-scope metadata', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'display_name' => 'Hidden Scope Connection',
|
||||||
|
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
test('members without capability see provider connection actions disabled with standard tooltip', function () {
|
test('members without capability see provider connection actions disabled with standard tooltip', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
@ -99,3 +117,26 @@
|
|||||||
->assertActionVisible('edit')
|
->assertActionVisible('edit')
|
||||||
->assertActionEnabled('edit');
|
->assertActionEnabled('edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sensitive provider connection mutations remain confirmation and capability gated', function (): void {
|
||||||
|
$source = (string) file_get_contents(repo_path('apps/platform/app/Filament/Resources/ProviderConnectionResource.php'));
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'makeSetDefaultAction',
|
||||||
|
'makeEnableDedicatedOverrideAction',
|
||||||
|
'makeRotateDedicatedCredentialAction',
|
||||||
|
'makeDeleteDedicatedCredentialAction',
|
||||||
|
'makeRevertToPlatformAction',
|
||||||
|
'makeEnableConnectionAction',
|
||||||
|
'makeDisableConnectionAction',
|
||||||
|
] as $method) {
|
||||||
|
$start = strpos($source, 'public static function '.$method);
|
||||||
|
expect($start)->not->toBeFalse();
|
||||||
|
|
||||||
|
$next = strpos($source, "\n public static function ", $start + 1);
|
||||||
|
$block = substr($source, $start, $next === false ? null : $next - $start);
|
||||||
|
|
||||||
|
expect($block)->toContain('->requiresConfirmation()')
|
||||||
|
->and($block)->toContain('->requireCapability(');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -166,19 +166,21 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
|||||||
expect($table->persistsSearchInSession())->toBeTrue();
|
expect($table->persistsSearchInSession())->toBeTrue();
|
||||||
expect($table->persistsSortInSession())->toBeTrue();
|
expect($table->persistsSortInSession())->toBeTrue();
|
||||||
expect($table->persistsFiltersInSession())->toBeTrue();
|
expect($table->persistsFiltersInSession())->toBeTrue();
|
||||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
|
||||||
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.');
|
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.');
|
||||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||||
expect($table->getColumn('provider')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('provider')?->isToggleable())->toBeFalse();
|
||||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse();
|
||||||
|
expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope');
|
||||||
|
expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
|
||||||
expect($table->getColumn('entra_tenant_id')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('entra_tenant_id')?->isToggleable())->toBeTrue();
|
||||||
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('last_error_reason_code')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('last_error_reason_code')?->isToggleable())->toBeTrue();
|
||||||
expect($table->getColumn('last_error_reason_code')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('last_error_reason_code')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('last_error_message')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('last_error_message')?->isToggleable())->toBeTrue();
|
||||||
expect($table->getColumn('last_error_message')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('last_error_message')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('standardizes the findings list around open triage work with hidden forensic detail', function (): void {
|
it('standardizes the findings list around open triage work with hidden forensic detail', function (): void {
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('blocks Microsoft-specific default labels and audit prose on shared provider connection surfaces', function (): void {
|
||||||
|
$root = repo_path('apps/platform');
|
||||||
|
$paths = [
|
||||||
|
'app/Filament/Resources/ProviderConnectionResource.php',
|
||||||
|
'app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php',
|
||||||
|
'app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php',
|
||||||
|
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php',
|
||||||
|
'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
|
||||||
|
'app/Services/Providers/ProviderConnectionMutationService.php',
|
||||||
|
'app/Services/Verification/StartVerification.php',
|
||||||
|
'app/Jobs/ProviderConnectionHealthCheckJob.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
$forbiddenFragments = [
|
||||||
|
'Entra tenant ID',
|
||||||
|
'Entra Tenant ID',
|
||||||
|
'Directory (tenant) ID',
|
||||||
|
'No Microsoft connections found',
|
||||||
|
'Graph API calls',
|
||||||
|
"'entra_tenant_id' => \$record->entra_tenant_id",
|
||||||
|
"'entra_tenant_id' => (string) \$connection->entra_tenant_id",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$contents = (string) file_get_contents($root.'/'.$path);
|
||||||
|
|
||||||
|
foreach ($forbiddenFragments as $fragment) {
|
||||||
|
expect($contents)
|
||||||
|
->not->toContain($fragment, sprintf('%s still contains shared-surface provider-specific default prose [%s].', $path, $fragment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -99,7 +99,7 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the Entra tenant id placeholder for onboarding input guidance', function (): void {
|
it('renders neutral tenant id placeholder guidance for onboarding input', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
@ -114,9 +114,39 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get('/admin/onboarding')
|
->get('/admin/onboarding')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
->assertSee('Tenant ID (GUID)')
|
||||||
->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false);
|
->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses target-scope wording in the onboarding provider setup step', function (): void {
|
||||||
|
$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 = '34343434-3434-3434-3434-343434343434';
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class)
|
||||||
|
->call('identifyManagedTenant', [
|
||||||
|
'entra_tenant_id' => $entraTenantId,
|
||||||
|
'environment' => 'prod',
|
||||||
|
'name' => 'Target Scope Tenant',
|
||||||
|
])
|
||||||
|
->set('data.connection_mode', 'new')
|
||||||
|
->assertSee('Target scope ID')
|
||||||
|
->assertSee('The provider connection will point to this tenant target scope.')
|
||||||
|
->assertSee($entraTenantId)
|
||||||
|
->assertDontSee('Directory (tenant) ID')
|
||||||
|
->assertDontSee('Graph API calls');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void {
|
it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@ -195,7 +225,7 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Skipped - No bootstrap actions selected')
|
->assertSee('Skipped - No bootstrap actions selected')
|
||||||
->assertSee('Tenant status will be set to Active.')
|
->assertSee('Tenant status will be set to Active.')
|
||||||
->assertSee('The provider connection will be used for all Graph API calls.');
|
->assertSee('The provider connection will be used for provider API calls.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {
|
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Providers\ProviderConnectionResolver;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('renders create and edit flows with neutral target-scope labels', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => 'Neutral connection',
|
||||||
|
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ProviderConnectionResource::getUrl('create', ['tenant_id' => $tenant->external_id], panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Target scope ID')
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertDontSee('Entra tenant ID');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Target scope ID')
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertDontSee('Entra tenant ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps list and detail surfaces default-visible around provider target scope consent and verification', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => 'Scope-visible connection',
|
||||||
|
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
'verification_status' => 'healthy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ListProviderConnections::class);
|
||||||
|
$table = $component->instance()->getTable();
|
||||||
|
$visibleColumnNames = collect($table->getVisibleColumns())
|
||||||
|
->map(fn ($column): string => $column->getName())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status')
|
||||||
|
->and($visibleColumnNames)->not->toContain('entra_tenant_id')
|
||||||
|
->and($table->getColumn('target_scope')?->getLabel())->toBe('Target scope')
|
||||||
|
->and($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertSee('Provider identity details')
|
||||||
|
->assertSee('Microsoft tenant ID')
|
||||||
|
->assertSee('Consent')
|
||||||
|
->assertSee('Verification')
|
||||||
|
->assertDontSee('Entra tenant ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses neutral validation attributes when the create flow misses target-scope context', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(CreateProviderConnection::class)
|
||||||
|
->fillForm([
|
||||||
|
'display_name' => 'Missing target scope',
|
||||||
|
'entra_tenant_id' => '',
|
||||||
|
'is_default' => true,
|
||||||
|
])
|
||||||
|
->call('create')
|
||||||
|
->assertHasFormErrors(['entra_tenant_id' => 'required']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks unsupported provider target-scope combinations before provider execution', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'contoso',
|
||||||
|
'display_name' => 'Unsupported provider connection',
|
||||||
|
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolution = app(ProviderConnectionResolver::class)
|
||||||
|
->validateConnection($tenant, 'contoso', $connection->fresh(['tenant']));
|
||||||
|
|
||||||
|
expect($user)->not->toBeNull()
|
||||||
|
->and($resolution->resolved)->toBeFalse()
|
||||||
|
->and($resolution->reasonCode)->toBe('provider_binding_unsupported')
|
||||||
|
->and($resolution->extensionReasonCode)->toBe('ext.connection_scope_unsupported')
|
||||||
|
->and($resolution->message)->toBe('This provider and target-scope combination is not supported.');
|
||||||
|
});
|
||||||
@ -38,11 +38,14 @@
|
|||||||
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
|
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Spec081 Connection')
|
->assertSee('Spec081 Connection')
|
||||||
|
->assertSee('Target scope')
|
||||||
|
->assertSee('Target scope ID')
|
||||||
->assertSee('Lifecycle')
|
->assertSee('Lifecycle')
|
||||||
->assertSee('Enabled')
|
->assertSee('Enabled')
|
||||||
->assertSee('Verification')
|
->assertSee('Verification')
|
||||||
->assertSee('Migration review')
|
->assertSee('Migration review')
|
||||||
->assertSee('Review required')
|
->assertSee('Review required')
|
||||||
|
->assertDontSee('Entra tenant ID')
|
||||||
->assertDontSee('Diagnostic status')
|
->assertDontSee('Diagnostic status')
|
||||||
->assertDontSee('Diagnostic health');
|
->assertDontSee('Diagnostic health');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,12 +39,19 @@
|
|||||||
$blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked');
|
$blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked');
|
||||||
expect($blocked->color)->toBe('danger');
|
expect($blocked->color)->toBe('danger');
|
||||||
expect($blocked->label)->toBe('Blocked');
|
expect($blocked->label)->toBe('Blocked');
|
||||||
|
|
||||||
$degraded = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'degraded');
|
$degraded = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'degraded');
|
||||||
expect($degraded->color)->toBe('warning');
|
expect($degraded->color)->toBe('warning');
|
||||||
expect($degraded->label)->toBe('Degraded');
|
expect($degraded->label)->toBe('Degraded');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reuse consent labels for provider verification summaries', function (): void {
|
||||||
|
expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)->toBe('Required')
|
||||||
|
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label)->toBe('Pending')
|
||||||
|
->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)
|
||||||
|
->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not expose legacy provider status badge domains anymore', function (): void {
|
it('does not expose legacy provider status badge domains anymore', function (): void {
|
||||||
$domainValues = collect(BadgeDomain::cases())
|
$domainValues = collect(BadgeDomain::cases())
|
||||||
->map(fn (BadgeDomain $domain): string => $domain->value)
|
->map(fn (BadgeDomain $domain): string => $domain->value)
|
||||||
|
|||||||
@ -18,6 +18,13 @@
|
|||||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked');
|
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps consent and verification badge domains distinct for provider connection summaries', function (): void {
|
||||||
|
expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)->toBe('Granted')
|
||||||
|
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label)->toBe('Healthy')
|
||||||
|
->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)
|
||||||
|
->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label);
|
||||||
|
});
|
||||||
|
|
||||||
it('maps managed-tenant onboarding verification badge aliases consistently', function (): void {
|
it('maps managed-tenant onboarding verification badge aliases consistently', function (): void {
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'unknown')->label)->toBe('Not started')
|
expect(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'unknown')->label)->toBe('Not started')
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'healthy')->label)->toBe('Ready')
|
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'healthy')->label)->toBe('Ready')
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('normalizes provider connections into neutral target-scope descriptors with contextual Microsoft metadata', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
'display_name' => 'Primary connection',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$descriptor = app(ProviderConnectionTargetScopeNormalizer::class)
|
||||||
|
->descriptorForConnection($connection->fresh(['tenant']));
|
||||||
|
$summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant']));
|
||||||
|
|
||||||
|
expect($user)->not->toBeNull()
|
||||||
|
->and($descriptor->provider)->toBe('microsoft')
|
||||||
|
->and($descriptor->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
|
||||||
|
->and($descriptor->scopeIdentifier)->toBe('11111111-1111-1111-1111-111111111111')
|
||||||
|
->and($descriptor->sharedLabel)->toBe('Target scope')
|
||||||
|
->and($descriptor->summary())->toContain((string) $tenant->name)
|
||||||
|
->and($summary->targetScopeSummary())->toContain('11111111-1111-1111-1111-111111111111')
|
||||||
|
->and($summary->contextualIdentityDetails)->toHaveCount(1)
|
||||||
|
->and($summary->contextualIdentityDetails[0]->detailLabel)->toBe('Microsoft tenant ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks unsupported provider-scope combinations explicitly instead of inheriting Microsoft defaults', function (): void {
|
||||||
|
$result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||||
|
provider: 'unknown-provider',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: 'scope-1',
|
||||||
|
scopeDisplayName: 'Scope 1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED)
|
||||||
|
->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION)
|
||||||
|
->and($result['message'])->toContain('not supported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks missing target-scope context with neutral validation language', function (): void {
|
||||||
|
$result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: '',
|
||||||
|
scopeDisplayName: 'Missing scope',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED)
|
||||||
|
->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT)
|
||||||
|
->and($result['message'])->toBe('A target scope identifier is required for this provider connection.');
|
||||||
|
});
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\ProviderIdentityResolver;
|
||||||
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('exposes neutral target-scope truth beside provider-owned identity metadata', function (): void {
|
||||||
|
config()->set('graph.client_id', 'platform-client-id');
|
||||||
|
config()->set('graph.client_secret', 'platform-client-secret');
|
||||||
|
config()->set('graph.tenant_id', 'platform-home-tenant-id');
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant']));
|
||||||
|
|
||||||
|
expect($resolution->resolved)->toBeTrue()
|
||||||
|
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
||||||
|
->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222')
|
||||||
|
->and($resolution->targetScope)->not->toBeNull()
|
||||||
|
->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
|
||||||
|
->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222')
|
||||||
|
->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all())
|
||||||
|
->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dedicated runtime credentials out of the shared target-scope descriptor', function (): void {
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
|
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'payload' => [
|
||||||
|
'client_id' => 'dedicated-client-id',
|
||||||
|
'client_secret' => 'dedicated-client-secret',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential']));
|
||||||
|
|
||||||
|
expect($resolution->resolved)->toBeTrue()
|
||||||
|
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_id')
|
||||||
|
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret')
|
||||||
|
->and($resolution->effectiveClientId)->toBe('dedicated-client-id');
|
||||||
|
});
|
||||||
@ -3,7 +3,7 @@ # Product Roadmap
|
|||||||
> Strategic thematic blocks and release trajectory.
|
> Strategic thematic blocks and release trajectory.
|
||||||
> This is the "big picture" — not individual specs.
|
> This is the "big picture" — not individual specs.
|
||||||
|
|
||||||
**Last updated**: 2026-04-24
|
**Last updated**: 2026-04-25
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -90,6 +90,7 @@ ### R1.x Foundation Hardening — Governance Platform Anti-Drift
|
|||||||
- Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries
|
- Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries
|
||||||
- Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth
|
- Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth
|
||||||
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core
|
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core
|
||||||
|
- Codebase Quality & Engineering Maturity hardening so the platform remains enterprise-maintainable while the governance surface grows: System Panel least-privilege capabilities, static-analysis baseline, architecture-boundary guard tests, and targeted decomposition of large Filament/service hotspots
|
||||||
- No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only
|
- No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only
|
||||||
|
|
||||||
### R2 Completion — Evidence & Exception Workflows
|
### R2 Completion — Evidence & Exception Workflows
|
||||||
@ -226,8 +227,9 @@ ## Infrastructure & Platform Debt
|
|||||||
| Item | Risk | Status |
|
| Item | Risk | Status |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| No `.env.example` in repo | Onboarding friction | Open |
|
| No `.env.example` in repo | Onboarding friction | Open |
|
||||||
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited | Review needed |
|
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed |
|
||||||
| No PHPStan/Larastan | No static analysis | Open |
|
| No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open |
|
||||||
|
| Thin architecture-boundary enforcement | Product tests are strong, but architecture-level guardrails need expansion; covered by `Architecture Boundary Guard Tests` spec candidate | Open |
|
||||||
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
|
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
|
||||||
| No formal release process | Manual deploys | Open |
|
| No formal release process | Manual deploys | Open |
|
||||||
| Dokploy config external to repo | Env drift | Open |
|
| Dokploy config external to repo | Env drift | Open |
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-04-24 (added Platform Hardening — OperationRun UX Consistency cluster with OperationRun Start UX Contract, Generic Active Run Surface, OperationRun Notification Lifecycle, and OperationRun Startsurface Migration; promoted Provider Boundary Hardening to Spec 237, clarified the remaining near-term sequencing after Canonical Control Catalog Foundation and Provider Boundary Hardening, and retained `Customer Review Workspace v1` as the customer-facing review consumption candidate that sharpens the R2 read-only/customer review lane)
|
> **Last reviewed**: 2026-04-25 (added Codebase Quality & Engineering Maturity cluster from full codebase audit with System Panel Least-Privilege Capability Model, Static Analysis Baseline, Architecture Boundary Guard Tests, Filament Hotspot Decomposition Foundation, and RestoreService Responsibility Split; retained OperationRun UX Consistency and Provider Boundary hardening sequences as current strategic hardening lanes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -78,6 +78,202 @@ ## Qualified
|
|||||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage.
|
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage.
|
||||||
|
|
||||||
|
|
||||||
|
> Codebase Quality & Engineering Maturity cluster: these candidates come from the full codebase quality audit on 2026-04-25. The audit classified the repo as **good / product-capable, not bad coding**, but identified a small set of structural risks that should be handled before larger feature expansion: coarse System Panel platform visibility, missing static-analysis gates, thin architecture-boundary enforcement, and several large Filament/service hotspots. This cluster is intentionally hardening-focused; it must not become a broad rewrite or cosmetic cleanup campaign.
|
||||||
|
|
||||||
|
### System Panel Least-Privilege Capability Model
|
||||||
|
- **Type**: security hardening / platform-plane RBAC
|
||||||
|
- **Source**: full codebase quality audit 2026-04-25 — tenant/workspace-plane isolation is strong, but System Panel directory visibility is intentionally global and currently gated by coarse platform capabilities
|
||||||
|
- **Problem**: The System Panel currently exposes global workspace and tenant directory views through broad platform capabilities. This is acceptable for trusted platform superadmins and break-glass operators, but too coarse for enterprise-grade least-privilege support roles, audit expectations, and future support delegation.
|
||||||
|
- **Why it matters**: TenantPilot has strong tenant/workspace isolation elsewhere. If the platform plane remains coarse, the product has an uneven security story: customer-facing tenant access is tight, while internal/operator metadata visibility can still be broader than necessary. Enterprise customers, MSP operators, and auditors will expect support roles to see only the minimum system metadata needed for their task.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- split broad System Panel directory visibility into more granular platform capabilities
|
||||||
|
- distinguish System Panel access, workspace directory visibility, tenant directory visibility, operations visibility, support diagnostics, and break-glass access
|
||||||
|
- keep platform superadmin and emergency break-glass behavior intact
|
||||||
|
- enforce the new boundaries server-side on System Panel pages, not only through navigation hiding
|
||||||
|
- add explicit tests for restricted platform users so unrelated workspace/tenant metadata cannot be enumerated accidentally
|
||||||
|
- **Candidate capabilities**:
|
||||||
|
- `platform.system.access`
|
||||||
|
- `platform.workspaces.view`
|
||||||
|
- `platform.tenants.view`
|
||||||
|
- `platform.operations.view`
|
||||||
|
- `platform.support_diagnostics.view`
|
||||||
|
- `platform.break_glass.use`
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: System Panel page access, platform capability split, server-side authorization checks, navigation visibility alignment, audit-friendly role behavior, and regression tests for non-superadmin platform users
|
||||||
|
- **Out of scope**: redesigning tenant/workspace membership RBAC, changing admin-panel tenant isolation semantics, removing break-glass, adding impersonation, or building a full support-role management UI unless explicitly needed for test fixtures
|
||||||
|
- **Acceptance points**:
|
||||||
|
- existing platform superadmin behavior remains intact
|
||||||
|
- a platform user with only workspace-directory visibility cannot view tenant-directory pages
|
||||||
|
- a platform user with only tenant-directory visibility cannot view workspace-directory pages unless explicitly granted
|
||||||
|
- operations visibility is separately controllable from directory visibility
|
||||||
|
- System Panel pages return forbidden or not-found consistently when capability is missing
|
||||||
|
- tests prove navigation hiding is not the only protection
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Over-fragmenting capabilities could make platform-user administration noisy before there is a polished role UI
|
||||||
|
- The product needs an explicit decision on whether support diagnostics can reveal tenant metadata without full tenant-directory access
|
||||||
|
- Break-glass behavior must remain simple, auditable, and unmistakably separate from normal support access
|
||||||
|
- **Dependencies**: `PlatformCapabilities`, System Panel providers/pages, platform-user model/policies, existing System Directory tests, existing tenant/workspace isolation tests
|
||||||
|
- **Related specs / candidates**: enterprise auth structure, platform superadmin / break-glass rules, RBAC hardening, System Directory residual surface tests
|
||||||
|
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Static Analysis Baseline for Platform Code
|
||||||
|
- **Type**: quality gate / developer experience hardening
|
||||||
|
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
|
||||||
|
- **Problem**: Runtime tests and feature tests are strong, but the codebase lacks a visible static-analysis baseline. In a growing Laravel / Filament / Livewire codebase with large services and resources, relying only on runtime tests leaves type drift, unsafe API usage, dead paths, and refactoring regressions too easy to introduce.
|
||||||
|
- **Why it matters**: TenantPilot is increasingly agent-assisted and spec-driven. Agents can move quickly, but without static analysis they can also reinforce invalid assumptions across dynamic Laravel boundaries. A pragmatic static-analysis gate gives both humans and agents a fast feedback loop before full suites run.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- add Larastan/PHPStan configuration for `apps/platform`
|
||||||
|
- start at a realistic level rather than attempting perfect strictness on day one
|
||||||
|
- generate an explicit baseline if existing findings are too broad for immediate cleanup
|
||||||
|
- make CI fail on new non-baselined findings
|
||||||
|
- document the local and CI workflow for developers and repo agents
|
||||||
|
- track baseline reduction as a future maintenance path rather than bundling all fixes into this spec
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: PHPStan/Larastan setup, baseline generation if needed, CI integration, developer documentation, and a small number of configuration fixes required to make analysis meaningful for Laravel/Filament patterns
|
||||||
|
- **Out of scope**: fixing all existing static-analysis findings, broad refactoring, Rector-driven code rewrites, changing app architecture, or blocking unrelated feature delivery on full strictness immediately
|
||||||
|
- **Acceptance points**:
|
||||||
|
- static analysis runs locally for `apps/platform`
|
||||||
|
- static analysis runs in CI or the active repository pipeline
|
||||||
|
- existing accepted findings are captured in a reviewed baseline
|
||||||
|
- new non-baselined findings fail the quality gate
|
||||||
|
- README, handover, or developer docs explain how to run and update the baseline
|
||||||
|
- configuration accounts for Laravel, Filament, Eloquent factories, and dynamic container usage where appropriate
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Starting too strict could create a large noisy cleanup spec instead of a useful guardrail
|
||||||
|
- Starting too loose could give false confidence without catching meaningful drift
|
||||||
|
- The repo must decide whether PHPStan/Larastan is enough initially or whether Rector belongs in a later separate modernization lane
|
||||||
|
- **Dependencies**: current Composer tooling, Pest lanes, Gitea workflows, `apps/platform/phpunit.xml`, developer documentation
|
||||||
|
- **Related specs / candidates**: Architecture Boundary Guard Tests, codebase quality hardening, CI/DX hardening
|
||||||
|
- **Strategic sequencing**: Second item in this cluster. It should land before broad hotspot refactors so those refactors have stronger safety rails.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Architecture Boundary Guard Tests
|
||||||
|
- **Type**: architecture hardening / regression guardrail
|
||||||
|
- **Source**: full codebase quality audit 2026-04-25 — product tests are strong, but architecture-level enforcement is still thin compared with the size and complexity of the codebase
|
||||||
|
- **Problem**: The repo has strong feature, RBAC, browser, and operation-flow tests, but only limited architecture-boundary enforcement. As the platform grows, Filament UI, services, jobs, provider code, models, support registries, and operation-run semantics can drift silently unless dependency and responsibility rules are executable.
|
||||||
|
- **Why it matters**: TenantPilot already has clear architectural intent: UI should not become provider-write logic, jobs should delegate business logic, platform and tenant capabilities should remain separate, and operation-run semantics should stay service-owned. Without guard tests, these principles remain review conventions and can be weakened by future agent-led changes.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce architecture tests that encode the most important dependency and responsibility boundaries
|
||||||
|
- start with high-signal rules rather than broad brittle pattern matching
|
||||||
|
- baseline or explicitly document accepted legacy violations
|
||||||
|
- connect new tests to the active quality-gate lane
|
||||||
|
- use the tests as a safety rail before decomposing large Filament/service hotspots
|
||||||
|
- **Candidate guardrails**:
|
||||||
|
- Filament Resources must not directly perform provider writes
|
||||||
|
- Filament Resources must not own large workflow orchestration
|
||||||
|
- Jobs should delegate business logic to services or handlers
|
||||||
|
- provider-specific code must not leak into neutral platform domains
|
||||||
|
- Models must not depend on Filament
|
||||||
|
- Services must not depend on Filament Resources
|
||||||
|
- Support registries must not depend on UI classes
|
||||||
|
- platform capabilities and tenant/workspace capabilities must remain separated
|
||||||
|
- OperationRun lifecycle and outcome semantics stay service-owned
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: executable architecture tests, pragmatic baselines/exceptions, dependency-direction checks, responsibility-boundary checks, and CI integration
|
||||||
|
- **Out of scope**: perfect clean-architecture purity, mass refactoring to satisfy idealized rules, changing Laravel/Filament conventions where the framework reasonably expects dynamic coupling, or enforcing line-count thresholds as the only quality metric
|
||||||
|
- **Acceptance points**:
|
||||||
|
- architecture tests run locally and in CI
|
||||||
|
- new violations for selected boundaries fail tests
|
||||||
|
- accepted existing violations are explicitly documented with exit paths or reasons
|
||||||
|
- tests protect at least UI/provider, model/UI, service/UI, platform-capability, and OperationRun ownership boundaries
|
||||||
|
- the rules are specific enough to guide future agent work without blocking legitimate Laravel/Filament usage
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Over-broad static rules may produce noise and encourage blanket exceptions
|
||||||
|
- Some legacy hotspots may need temporary exceptions until decomposition specs land
|
||||||
|
- The tests should complement, not duplicate, PHPStan/Larastan
|
||||||
|
- **Dependencies**: Static Analysis Baseline for Platform Code, current architecture test setup, existing Action Surface guard tests, platform capability registry, provider contracts
|
||||||
|
- **Related specs / candidates**: Static Analysis Baseline for Platform Code, Filament Hotspot Decomposition Foundation, RestoreService Responsibility Split, Provider Boundary Hardening
|
||||||
|
- **Strategic sequencing**: Third item in this cluster. It can begin alongside static analysis but should be in place before large decomposition work accelerates.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Filament Hotspot Decomposition Foundation
|
||||||
|
- **Type**: maintainability hardening / UI architecture
|
||||||
|
- **Source**: full codebase quality audit 2026-04-25 — several Filament Resources/Pages are large multi-responsibility hotspots despite an otherwise structured architecture
|
||||||
|
- **Problem**: Several Filament surfaces have grown into large classes that combine table/query construction, form or infolist schema, action definitions, presentation rules, state labels, authorization glue, notifications, and workflow orchestration. This does not make the codebase bad, but it increases review cost, bus-factor risk, regression risk, and future feature cost.
|
||||||
|
- **Known hotspots**:
|
||||||
|
- `ManagedTenantOnboardingWizard.php`
|
||||||
|
- `TenantResource.php`
|
||||||
|
- `FindingResource.php`
|
||||||
|
- `RestoreRunResource.php`
|
||||||
|
- **Why it matters**: Filament is the primary operator UI. If every major surface keeps accumulating local query, action, presenter, and workflow code, the admin experience becomes hard to evolve safely. This is especially risky in an agent-led workflow where large files encourage local patching rather than clean extraction.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- define a repeatable decomposition pattern for large Filament Resources and Pages
|
||||||
|
- extract complex query builders into dedicated query/read-model objects where useful
|
||||||
|
- extract action construction into action builder classes or surface-specific action objects
|
||||||
|
- extract badge, label, state, and helper-text rules into presenters
|
||||||
|
- extract complex form/infolist/table section schemas into reusable schema builders
|
||||||
|
- keep routes, resource names, permissions, and user-facing behavior unchanged during the foundation slice
|
||||||
|
- adopt the pattern on one representative Resource first before migrating all hotspots
|
||||||
|
- **First adoption target**: Prefer `FindingResource.php` or `TenantResource.php` as the first representative target because both expose dense operator-facing surfaces and repeated action/presentation/query patterns.
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: decomposition pattern, first representative Resource/Page adoption, tests proving behavior is unchanged, and one or more architecture guardrails that prevent immediate regression
|
||||||
|
- **Out of scope**: broad UI redesign, changing product behavior, permission-semantic changes, schema changes, visual redesign, or mass migration of every large Filament surface in one spec
|
||||||
|
- **Acceptance points**:
|
||||||
|
- selected Resource/Page loses meaningful line count without changing behavior
|
||||||
|
- extracted classes have clear responsibility names and are easier to test or review
|
||||||
|
- existing UI/feature tests pass unchanged or are updated only for intentional structure-aware guardrails
|
||||||
|
- new or updated architecture tests prevent action/query/presenter logic from growing back into the Resource in the same form
|
||||||
|
- the resulting pattern is documented so future specs and agents can reuse it
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Extracting too aggressively could create more indirection than clarity
|
||||||
|
- Extracting too little would reduce line count without actually improving responsibility boundaries
|
||||||
|
- Choosing the first adoption surface matters; a volatile feature surface may make behavior-preserving decomposition harder
|
||||||
|
- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, existing Filament resource tests, action-surface guard tests
|
||||||
|
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation (Spec 192), Monitoring Surface Action Hierarchy & Workbench Semantics (Spec 193), Governance Friction & Operator Vocabulary Hardening (Spec 194), RestoreService Responsibility Split
|
||||||
|
- **Strategic sequencing**: Fourth item in this cluster. It should follow static analysis and initial architecture guardrails so the extraction work is safer and easier to review.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### RestoreService Responsibility Split
|
||||||
|
- **Type**: maintainability hardening / safety-critical workflow architecture
|
||||||
|
- **Source**: full codebase quality audit 2026-04-25 — restore logic is safety-critical but currently concentrated in a large service hotspot
|
||||||
|
- **Problem**: `RestoreService.php` has grown into a large multi-responsibility class. Restore is one of TenantPilot's highest-risk workflows because it can affect customer tenant state. Concentrating preview, validation, payload mapping, provider writes, operation tracking, result normalization, and failure classification in one service increases regression risk and makes review harder.
|
||||||
|
- **Why it matters**: Restore is not just another CRUD operation. Operators need predictable preview/apply semantics, accurate failure handling, and auditable operation results. A large service can still work, but it becomes increasingly difficult to change safely, especially as provider-backed actions and restore semantics mature.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- keep `RestoreService` as a thin application-facing facade if preserving its public API is useful
|
||||||
|
- extract restore preview calculation into a focused collaborator
|
||||||
|
- extract restore payload mapping into provider-aware mappers
|
||||||
|
- extract restore validation / precondition checks into a dedicated validator or gate
|
||||||
|
- extract provider write execution into explicit execution handlers
|
||||||
|
- extract restore result normalization and failure classification into focused components
|
||||||
|
- preserve existing OperationRun and audit semantics
|
||||||
|
- **Target responsibility slices**:
|
||||||
|
- restore preview calculation
|
||||||
|
- restore payload mapping
|
||||||
|
- restore validation and preconditions
|
||||||
|
- provider write execution
|
||||||
|
- restore operation/run tracking
|
||||||
|
- restore result normalization
|
||||||
|
- restore failure classification
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: internal responsibility split, behavior-preserving tests, collaborator extraction, thin facade preservation where appropriate, and restore-specific architecture guardrails
|
||||||
|
- **Out of scope**: changing restore UI, changing provider behavior, changing restore operation semantics, adding new restore features, broad provider abstraction redesign, or rewriting the restore engine from scratch
|
||||||
|
- **Acceptance points**:
|
||||||
|
- `RestoreService.php` becomes materially smaller
|
||||||
|
- each extracted class has one clear responsibility
|
||||||
|
- existing restore tests pass
|
||||||
|
- new tests cover at least preview, validation/preconditions, provider write execution, and failure/result handling boundaries
|
||||||
|
- OperationRun lifecycle and audit behavior remain unchanged
|
||||||
|
- the public restore workflow remains behavior-compatible unless an explicit spec requirement says otherwise
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Restore has real execution risk; decomposition must be behavior-preserving and heavily tested
|
||||||
|
- Poor extraction could hide execution order or transactional semantics across too many classes
|
||||||
|
- Provider-boundary cleanup and restore decomposition must be coordinated so neither creates competing abstractions
|
||||||
|
- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, restore tests, OperationRun semantics, Provider Boundary Hardening
|
||||||
|
- **Related specs / candidates**: Restore Lifecycle Semantic Clarity, Provider-Backed Action Preflight and Dispatch Gate Unification (Spec 216), Provider Boundary Hardening (Spec 237), Filament Hotspot Decomposition Foundation
|
||||||
|
- **Strategic sequencing**: Fifth item in this cluster. It should follow or run shortly after the generic quality gates, but it can be promoted earlier if restore changes become frequent.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
> Recommended sequence for this cluster:
|
||||||
|
> 1. **System Panel Least-Privilege Capability Model**
|
||||||
|
> 2. **Static Analysis Baseline for Platform Code**
|
||||||
|
> 3. **Architecture Boundary Guard Tests**
|
||||||
|
> 4. **Filament Hotspot Decomposition Foundation**
|
||||||
|
> 5. **RestoreService Responsibility Split**
|
||||||
|
>
|
||||||
|
> Why this order: first close the enterprise security/least-privilege gap, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage risks.
|
||||||
|
|
||||||
|
|
||||||
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
||||||
|
|
||||||
### OperationRun Start UX Contract
|
### OperationRun Start UX Contract
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-24
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The spec stays bounded to provider connection identity and target-scope semantics on existing shared surfaces.
|
||||||
|
- Broader governed-subject and compare-boundary work remains an explicit follow-up, not hidden scope inside this draft.
|
||||||
@ -0,0 +1,361 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Provider Identity & Target Scope Neutrality Logical Contract
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Logical internal contract for the provider connection target-scope neutrality
|
||||||
|
slice. It describes the normalized shared target-scope descriptor, the
|
||||||
|
operator-facing surface summary, and the bounded neutrality guard result.
|
||||||
|
It is not a commitment to expose public HTTP routes.
|
||||||
|
Review stop: shared provider connection surfaces must use neutral target
|
||||||
|
scope truth by default, carry provider-owned Microsoft identity only as
|
||||||
|
contextual metadata, and resolve remaining provider-boundary drift through
|
||||||
|
document-in-feature or follow-up-spec disposition instead of silent shared
|
||||||
|
platform truth.
|
||||||
|
paths:
|
||||||
|
/logical/provider-connections/{connectionId}/target-scope:
|
||||||
|
get:
|
||||||
|
summary: Read the normalized target-scope descriptor for an existing provider connection
|
||||||
|
operationId: getProviderConnectionTargetScope
|
||||||
|
parameters:
|
||||||
|
- name: connectionId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Normalized target-scope descriptor
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
|
||||||
|
/logical/provider-connections/target-scope/normalize:
|
||||||
|
post:
|
||||||
|
summary: Normalize create or edit input into shared target-scope truth plus contextual provider metadata
|
||||||
|
operationId: normalizeProviderConnectionTargetScope
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionTargetScopeInput'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Normalized descriptor and optional shared-surface preview
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationSuccess'
|
||||||
|
'422':
|
||||||
|
description: Unsupported provider-scope combination or missing provider context
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationFailure'
|
||||||
|
/logical/provider-connections/{connectionId}/surface-summary:
|
||||||
|
get:
|
||||||
|
summary: Read the default-visible operator-facing summary for a shared provider connection surface
|
||||||
|
operationId: getProviderConnectionSurfaceSummary
|
||||||
|
parameters:
|
||||||
|
- name: connectionId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Shared-surface summary
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSurfaceSummary'
|
||||||
|
/logical/provider-connections/neutrality/evaluate:
|
||||||
|
post:
|
||||||
|
summary: Evaluate whether a touched shared provider-connection path preserves neutral target-scope truth
|
||||||
|
operationId: evaluateProviderConnectionNeutrality
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionNeutralityEvaluationRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Neutrality evaluation result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionNeutralityCheckResult'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ProviderConnectionSupportedScopeKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- tenant
|
||||||
|
ProviderIdentityContextVisibility:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- contextual_only
|
||||||
|
- audit_only
|
||||||
|
- troubleshooting_only
|
||||||
|
ProviderIdentityContextMetadata:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
detail_key:
|
||||||
|
type: string
|
||||||
|
detail_label:
|
||||||
|
type: string
|
||||||
|
detail_value:
|
||||||
|
type: string
|
||||||
|
visibility:
|
||||||
|
$ref: '#/components/schemas/ProviderIdentityContextVisibility'
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- detail_key
|
||||||
|
- detail_label
|
||||||
|
- detail_value
|
||||||
|
- visibility
|
||||||
|
ProviderConnectionTargetScopeDescriptor:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
scope_kind:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||||
|
scope_identifier:
|
||||||
|
type: string
|
||||||
|
scope_display_name:
|
||||||
|
type: string
|
||||||
|
shared_label:
|
||||||
|
type: string
|
||||||
|
shared_help_text:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- scope_kind
|
||||||
|
- scope_identifier
|
||||||
|
- scope_display_name
|
||||||
|
- shared_label
|
||||||
|
- shared_help_text
|
||||||
|
ProviderConnectionTargetScopeInput:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
scope_kind:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||||
|
scope_identifier:
|
||||||
|
type: string
|
||||||
|
scope_display_name:
|
||||||
|
type: string
|
||||||
|
provider_specific_identity:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- scope_kind
|
||||||
|
- scope_identifier
|
||||||
|
- scope_display_name
|
||||||
|
ProviderConnectionTargetScopeNormalizationSuccess:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- normalized
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
scope_kind:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||||
|
target_scope:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
|
||||||
|
contextual_identity_details:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProviderIdentityContextMetadata'
|
||||||
|
failure_code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
preview_summary:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ProviderConnectionSurfaceSummary'
|
||||||
|
- type: 'null'
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- provider
|
||||||
|
- scope_kind
|
||||||
|
- target_scope
|
||||||
|
- contextual_identity_details
|
||||||
|
- failure_code
|
||||||
|
- message
|
||||||
|
- preview_summary
|
||||||
|
ProviderConnectionTargetScopeNormalizationFailure:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- blocked
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
scope_kind:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||||
|
failure_code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- unsupported_provider_scope_combination
|
||||||
|
- missing_provider_context
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- provider
|
||||||
|
- scope_kind
|
||||||
|
- failure_code
|
||||||
|
- message
|
||||||
|
ProviderConnectionSurfaceSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
target_scope:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
|
||||||
|
consent_state:
|
||||||
|
type: string
|
||||||
|
verification_state:
|
||||||
|
type: string
|
||||||
|
readiness_summary:
|
||||||
|
type: string
|
||||||
|
contextual_identity_details:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProviderIdentityContextMetadata'
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- target_scope
|
||||||
|
- consent_state
|
||||||
|
- verification_state
|
||||||
|
- readiness_summary
|
||||||
|
- contextual_identity_details
|
||||||
|
ProviderConnectionNeutralityEvaluationRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
surface_key:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
surface_ownership:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- platform_core
|
||||||
|
- provider_owned
|
||||||
|
default_labels:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required_fields:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
filter_labels:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
validation_messages:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
helper_copy:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
audit_prose:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
allowed_exception_classes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
provider_owned_context:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- surface_key
|
||||||
|
- path
|
||||||
|
- surface_ownership
|
||||||
|
- default_labels
|
||||||
|
- required_fields
|
||||||
|
- filter_labels
|
||||||
|
- validation_messages
|
||||||
|
- helper_copy
|
||||||
|
- audit_prose
|
||||||
|
- allowed_exception_classes
|
||||||
|
- provider
|
||||||
|
- provider_owned_context
|
||||||
|
ProviderConnectionNeutralityCheckResult:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Guard result for touched shared provider connection surfaces. A blocked
|
||||||
|
or review_required result must name whether the issue is a default
|
||||||
|
label, filter, required field, validation message, helper copy, audit
|
||||||
|
prose, or missing target-scope descriptor, and must route the close-out
|
||||||
|
through document-in-feature or follow-up-spec.
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- allowed
|
||||||
|
- review_required
|
||||||
|
- blocked
|
||||||
|
surface_key:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
surface_ownership:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- platform_core
|
||||||
|
- provider_owned
|
||||||
|
allowed_exception_classes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
violation_code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- provider_specific_default_label
|
||||||
|
- provider_specific_default_filter
|
||||||
|
- provider_specific_required_field
|
||||||
|
- provider_specific_validation_message
|
||||||
|
- provider_specific_default_helper_copy
|
||||||
|
- provider_specific_default_audit_prose
|
||||||
|
- missing_target_scope_descriptor
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
suggested_follow_up:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- document-in-feature
|
||||||
|
- follow-up-spec
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- surface_key
|
||||||
|
- path
|
||||||
|
- surface_ownership
|
||||||
|
- allowed_exception_classes
|
||||||
|
- violation_code
|
||||||
|
- message
|
||||||
|
- suggested_follow_up
|
||||||
140
specs/238-provider-identity-target-scope/data-model.md
Normal file
140
specs/238-provider-identity-target-scope/data-model.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# Data Model: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This slice introduces one shared target-scope descriptor, one provider-owned contextual identity metadata shape, one operator-facing connection summary, and one narrow neutrality guard result. No new database persistence is introduced.
|
||||||
|
|
||||||
|
## Entity: ProviderConnectionTargetScopeDescriptor
|
||||||
|
|
||||||
|
- **Purpose**: Express the neutral platform meaning of what a provider connection points to without carrying provider-specific identity as part of the descriptor itself.
|
||||||
|
- **Identity**:
|
||||||
|
- `provider_connection_id` for existing records
|
||||||
|
- draft or create-flow input tuple `(provider, scope_kind, scope_identifier, scope_display_name)` before persistence
|
||||||
|
- **Core fields**:
|
||||||
|
- `provider`
|
||||||
|
- `scope_kind`
|
||||||
|
- `scope_identifier`
|
||||||
|
- `scope_display_name`
|
||||||
|
- `shared_label`
|
||||||
|
- `shared_help_text`
|
||||||
|
- **Validation rules**:
|
||||||
|
- The descriptor must remain renderable without provider-specific labels.
|
||||||
|
- In this current-release slice, `scope_kind` is tenant-only even though the neutral field remains generic for future provider-boundary-safe extension.
|
||||||
|
- `scope_kind`, `scope_identifier`, and `scope_display_name` must be sufficient to describe the neutral target-scope meaning on shared surfaces.
|
||||||
|
- `scope_identifier` and `scope_display_name` must be usable on shared surfaces without relying on Microsoft directory vocabulary.
|
||||||
|
- Provider-owned identity details must live beside the descriptor in contextual metadata or summary shapes, not inside the descriptor itself.
|
||||||
|
|
||||||
|
## Entity: ProviderConnectionTargetScopeNormalizationResult
|
||||||
|
|
||||||
|
- **Purpose**: Represent the deterministic result of normalizing create or edit input before persistence, including explicit blocked outcomes for unsupported combinations or missing provider context.
|
||||||
|
- **Fields**:
|
||||||
|
- `status`
|
||||||
|
- `provider`
|
||||||
|
- `scope_kind`
|
||||||
|
- `target_scope` when `status = normalized`
|
||||||
|
- `contextual_identity_details[]` when `status = normalized`
|
||||||
|
- `preview_summary` when a shared-surface preview can be derived without assuming persisted runtime state
|
||||||
|
- `failure_code`
|
||||||
|
- `message`
|
||||||
|
- **Status values**:
|
||||||
|
- `normalized`
|
||||||
|
- `blocked`
|
||||||
|
- **Failure code values**:
|
||||||
|
- `none`
|
||||||
|
- `unsupported_provider_scope_combination`
|
||||||
|
- `missing_provider_context`
|
||||||
|
- **Validation rules**:
|
||||||
|
- `normalized` results must carry `provider`, `scope_kind`, `target_scope`, `contextual_identity_details[]`, `failure_code = none`, and a human-readable `message`; they may include `preview_summary` only when consent and verification state can be derived without assuming persisted runtime state.
|
||||||
|
- `blocked` results must carry `provider`, `scope_kind`, `failure_code`, and `message` and must not pretend readiness or persisted summary truth exists.
|
||||||
|
- The normalization result must preserve the distinction between neutral target-scope truth and provider-owned contextual identity details.
|
||||||
|
|
||||||
|
## Entity: ProviderIdentityContextMetadata
|
||||||
|
|
||||||
|
- **Purpose**: Carry provider-owned identity details that remain necessary for Microsoft consent, verification, troubleshooting, or audit drill-in.
|
||||||
|
- **Fields**:
|
||||||
|
- `provider`
|
||||||
|
- `detail_key`
|
||||||
|
- `detail_label`
|
||||||
|
- `detail_value`
|
||||||
|
- `visibility`
|
||||||
|
- **Visibility values**:
|
||||||
|
- `contextual_only`
|
||||||
|
- `audit_only`
|
||||||
|
- `troubleshooting_only`
|
||||||
|
- **Validation rules**:
|
||||||
|
- Context metadata must never replace the shared target-scope descriptor on generic provider surfaces.
|
||||||
|
- Microsoft-only labels such as `Entra tenant ID` remain allowed only when `provider = microsoft` and visibility is contextual.
|
||||||
|
|
||||||
|
## Entity: ProviderConnectionSurfaceSummary
|
||||||
|
|
||||||
|
- **Purpose**: Define the default-visible operator-facing summary for shared provider connection surfaces.
|
||||||
|
- **Fields**:
|
||||||
|
- `provider`
|
||||||
|
- `target_scope`
|
||||||
|
- `consent_state`
|
||||||
|
- `verification_state`
|
||||||
|
- `readiness_summary`
|
||||||
|
- `contextual_identity_details[]`
|
||||||
|
- **Validation rules**:
|
||||||
|
- `provider`, `target_scope`, `consent_state`, and `verification_state` must all be visible without opening diagnostics.
|
||||||
|
- `contextual_identity_details[]` must remain secondary to the target-scope summary.
|
||||||
|
- Shared surface summaries must not collapse consent and verification into one ambiguous state.
|
||||||
|
|
||||||
|
## Entity: ProviderConnectionNeutralityCheckResult
|
||||||
|
|
||||||
|
- **Purpose**: Deterministic result shape used by guard tests and review checks.
|
||||||
|
- **Fields**:
|
||||||
|
- `status`
|
||||||
|
- `surface_key`
|
||||||
|
- `path`
|
||||||
|
- `surface_ownership`
|
||||||
|
- `allowed_exception_classes[]`
|
||||||
|
- `violation_code`
|
||||||
|
- `message`
|
||||||
|
- `suggested_follow_up`
|
||||||
|
- **Status values**:
|
||||||
|
- `allowed`
|
||||||
|
- `review_required`
|
||||||
|
- `blocked`
|
||||||
|
- **Violation code examples**:
|
||||||
|
- `provider_specific_default_label`
|
||||||
|
- `provider_specific_default_filter`
|
||||||
|
- `provider_specific_required_field`
|
||||||
|
- `provider_specific_validation_message`
|
||||||
|
- `provider_specific_default_helper_copy`
|
||||||
|
- `provider_specific_default_audit_prose`
|
||||||
|
- `missing_target_scope_descriptor`
|
||||||
|
- **Surface ownership values**:
|
||||||
|
- `platform_core`
|
||||||
|
- `provider_owned`
|
||||||
|
- **Validation rules**:
|
||||||
|
- `allowed` means the shared surface uses neutral target-scope truth by default.
|
||||||
|
- `review_required` means the path contains documented provider-owned contextual detail or an allowed exception class.
|
||||||
|
- `blocked` means a shared surface reintroduced provider-specific default truth.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `ProviderConnectionSurfaceSummary` consumes exactly one `ProviderConnectionTargetScopeDescriptor` and may carry zero or more `ProviderIdentityContextMetadata` entries beside it.
|
||||||
|
- One `ProviderConnectionTargetScopeNormalizationResult` may carry zero or more `ProviderIdentityContextMetadata` entries beside exactly one normalized target-scope descriptor.
|
||||||
|
- One `ProviderConnectionNeutralityCheckResult` references exactly one touched surface or helper path.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
### Target-scope descriptor lifecycle
|
||||||
|
|
||||||
|
- `draft_input`: create or edit flow has neutral shared scope data but is not yet persisted.
|
||||||
|
- `persisted_shared_truth`: existing `provider_connections` row has a neutral target-scope descriptor available for shared surfaces.
|
||||||
|
- `context_enriched`: provider-owned contextual details are attached for Microsoft consent, verification, or audit drill-in.
|
||||||
|
|
||||||
|
### Neutrality check lifecycle
|
||||||
|
|
||||||
|
- `allowed`: shared surface is neutral by default.
|
||||||
|
- `review_required`: shared surface stays neutral but exposes documented provider-owned contextual detail.
|
||||||
|
- `blocked`: shared surface or helper reintroduced provider-specific default truth.
|
||||||
|
|
||||||
|
## Rollout Model
|
||||||
|
|
||||||
|
- No new database table or column is planned for this slice.
|
||||||
|
- The descriptor layer is derived from existing provider connection truth and existing provider-owned identity details.
|
||||||
|
- Provider connection list, detail, create, edit, onboarding provider setup, and audit wording adopt the new descriptor first.
|
||||||
|
- Broader provider identity migration and compare-boundary work remain out of scope for this feature.
|
||||||
264
specs/238-provider-identity-target-scope/plan.md
Normal file
264
specs/238-provider-identity-target-scope/plan.md
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
# Implementation Plan: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
**Branch**: `238-provider-identity-target-scope` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/238-provider-identity-target-scope/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the slice intentionally narrow. It introduces one small shared target-scope descriptor layer over the existing provider connection and identity-resolution path, rewrites Microsoft-shaped default labels and summaries only on the in-scope shared surfaces, preserves Entra-specific identity details as contextual provider-owned metadata, and adds focused guardrails so shared provider/platform seams do not regress into Microsoft-first default truth.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a config-free, in-process target-scope descriptor layer that sits between existing provider connection truth and operator-facing shared surfaces. The implementation will harden two concrete hotspots already visible in code: first, the shared resolution objects (`ProviderIdentityResolution` and adjacent provider-connection resolution paths) still expose Microsoft-shaped semantics such as `tenantContext`, `authorityTenant`, and `entra_tenant_id` as if they were default shared truth; second, `ProviderConnectionResource` uses `Entra tenant ID` as a required shared form field and as the default list/detail summary. The plan narrows the shared contract to neutral provider and target-scope concepts, keeps Microsoft tenant and directory identity available only as provider-owned contextual metadata, extends the same neutral semantics into onboarding-adjacent setup, aligns shared audit wording, and proves the result with focused unit, feature, Filament, onboarding, and guard coverage without adding a new provider runtime, new persistence, or a broader schema rewrite.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||||
|
**Storage**: Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned
|
||||||
|
**Testing**: Pest v4 unit and focused feature tests through Laravel Sail
|
||||||
|
**Validation Lanes**: `fast-feedback`, `confidence`
|
||||||
|
**Target Platform**: Laravel admin web application rendered through Filament on the existing workspace and tenant admin surfaces
|
||||||
|
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
|
||||||
|
**Performance Goals**: Keep target-scope resolution deterministic and in-process, add no new outbound call before existing consent or verification paths, and preserve current Microsoft-backed runtime performance on supported flows
|
||||||
|
**Constraints**: No new provider runtime, no provider marketplace, no new persistence, no broad credential-model redesign, no full rewrite of Spec 137 scope, no new operator-facing shell, and no new Microsoft-shaped default labels on shared surfaces
|
||||||
|
**Scale/Scope**: One shared target-scope descriptor layer, one bounded contextual-metadata path, three operator-facing surfaces, and focused unit plus feature guard coverage
|
||||||
|
|
||||||
|
## Filament v5 Implementation Contract
|
||||||
|
|
||||||
|
- **Livewire v4.0+ compliance**: Preserved. The feature changes existing Filament resources, shared support code, and tests only, with no legacy Livewire APIs.
|
||||||
|
- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||||
|
- **Global search coverage**: No new resource or page is added and no existing global-search posture is widened. Provider connection global-search behavior remains unchanged in this slice.
|
||||||
|
- **Destructive actions**: No new destructive action is added. Existing security-sensitive provider connection mutations continue to require confirmation and authorization on their current surfaces.
|
||||||
|
- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when registered Filament assets change.
|
||||||
|
- **Testing plan**: Prove the slice with one shared descriptor unit seam, focused provider connection and onboarding feature coverage, existing audit and UI enforcement coverage extensions, and one bounded neutrality guard test.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces across provider connection list and detail, provider connection create and edit, and the tenant onboarding provider setup step
|
||||||
|
- **Native vs custom classification summary**: native Filament plus shared support helpers
|
||||||
|
- **Shared-family relevance**: shared provider connection family, onboarding provider setup family, shared audit wording
|
||||||
|
- **State layers in scope**: `page`, `detail`, `wizard-step`
|
||||||
|
- **Handling modes by drift class or surface**: `review-mandatory`
|
||||||
|
- **Repository-signal treatment**: `review-mandatory`
|
||||||
|
- **Special surface test profiles**: `standard-native-filament`
|
||||||
|
- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke`
|
||||||
|
- **Exception path and spread control**: one named Microsoft contextual-identity boundary for tenant or directory identifiers, consent wording, and verification detail that remain provider-owned instead of shared default truth
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, provider connection resolution and mutation services, provider identity resolution, badge and summary rendering, and provider-connection audit wording
|
||||||
|
- **Shared abstractions reused**: existing provider connection resource and detail path, existing identity and connection resolution services, existing badge renderer domains, existing provider connection audit flows
|
||||||
|
- **New abstraction introduced? why?**: yes. One small target-scope descriptor and one small surface-summary mapping layer are needed because multiple real surfaces currently duplicate or embed Microsoft-shaped default meaning.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: the current shared path is sufficient for authorization, persistence, consent, and verification routing, but it is insufficient because the same path uses Microsoft-specific field names and summary language as the default shared contract.
|
||||||
|
- **Bounded deviation / spread control**: Microsoft tenant, directory, and consent-specific details remain allowed only inside explicitly provider-owned contextual sections, helper copy, and audit detail for the Microsoft provider
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Central contract reused**: `N/A`
|
||||||
|
- **Delegated UX behaviors**: `N/A`
|
||||||
|
- **Surface-owned behavior kept local**: `N/A`
|
||||||
|
- **Queued DB-notification policy**: `N/A`
|
||||||
|
- **Terminal notification path**: `N/A`
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Provider-owned seams**: Microsoft-specific consent and verification descriptors, contextual Entra tenant or directory identifiers, platform-app and authority details resolved by existing Microsoft identity services
|
||||||
|
- **Platform-core seams**: shared provider connection form labels, list and detail summaries, target-scope descriptor layer, shared validation shape, onboarding provider setup summary, audit wording for shared provider connection mutations
|
||||||
|
- **Neutral platform terms / contracts preserved**: provider, provider connection, target scope, scope identifier, scope display name, consent state, verification state, readiness summary
|
||||||
|
- **Retained provider-specific semantics and why**: `entra_tenant_id`, `authorityTenant`, `redirectUri`, and Microsoft-specific consent or verification wording remain because current-release truth is still Microsoft-first and operators still need these values on Microsoft-only paths
|
||||||
|
- **Bounded extraction or follow-up path**: broader governed-subject and compare-boundary work remains `follow-up-spec`; this feature resolves the provider connection identity and target-scope hotspot itself
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with one small descriptor layer, no new persistence, and no new provider runtime.*
|
||||||
|
|
||||||
|
| Gate | Status | Plan Notes |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Inventory-first / read-write separation | PASS | The slice changes shared provider connection semantics and existing write-surface wording only. No new snapshot or operational write class is introduced. |
|
||||||
|
| Single Graph contract path / no inline remote work | PASS | Existing consent and verification flows keep their current provider-owned Graph path. The feature adds no new Graph call and no inline remote work on shared surfaces. |
|
||||||
|
| RBAC, workspace isolation, tenant isolation | PASS | Existing provider connection and onboarding surfaces keep current workspace and tenant guards. Non-members remain 404 and members missing capability remain 403. |
|
||||||
|
| Run observability / Ops-UX lifecycle | PASS | No new `OperationRun` type or UX behavior is introduced. Existing health-check or verification runs keep their current service-owned lifecycle. |
|
||||||
|
| Shared pattern first | PASS | The implementation reuses existing provider connection surfaces and resolution services instead of creating a new provider management stack. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | One small target-scope descriptor layer is justified by multiple real surfaces and shared audits already depending on the same semantics. No marketplace or second-provider framework is introduced. |
|
||||||
|
| Persisted truth / behavioral state | PASS | No new table, persisted entity, or lifecycle family is added. Consent and verification remain the existing state dimensions. |
|
||||||
|
| Provider boundary | PASS | Shared target-scope truth is kept platform-core while Microsoft-specific identity remains provider-owned contextual metadata. |
|
||||||
|
| Filament v5 / Livewire v4 contract | PASS | The slice uses native Filament forms, tables, infolists, and existing shared primitives only. |
|
||||||
|
| Test governance | PASS | Coverage stays in focused unit and feature lanes with no browser or heavy-governance expansion. |
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Unit` for the descriptor and neutral-summary mapping seam; `Feature` for provider connection list/detail/create/edit, onboarding provider step, audit wording, and neutrality guard coverage; `Heavy-Governance`: none; `Browser`: none
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The risk is shared semantic drift and operator-facing meaning on existing admin surfaces, not browser interaction or long-running runtime behavior. Unit tests prove the neutral contract, while feature tests prove the same meaning across the real surfaces and authorization contexts.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing `ProviderConnectionFactory`, workspace and tenant membership fixtures, and current provider connection audit helpers. Do not add a new default provider-world helper.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: standard native Filament relief for the provider connection resource plus one onboarding wizard-step extension
|
||||||
|
- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test commands above. Reviewers should verify that shared labels and required fields are neutral by default, Microsoft contextual details still appear where genuinely needed, audit prose stays aligned, and no generic provider surface regained `Entra tenant ID` as default shared truth.
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected
|
||||||
|
- **Review-stop questions**: Did the slice drift into broad identity migration or credential-model redesign? Did any test or helper make Microsoft context implicit by default? Did any shared surface keep Microsoft-shaped default labels or required fields? Did audit or validation copy remain provider-shaped on shared paths?
|
||||||
|
- **Escalation path**: `document-in-feature`
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
- **Why no dedicated follow-up spec is needed**: This feature resolves the concrete provider-connection target-scope hotspot directly. Later governed-subject and compare-boundary work is already explicitly tracked separately.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/238-provider-identity-target-scope/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── provider-identity-target-scope.logical.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
│ │ └── Resources/ProviderConnectionResource.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── ProviderConnection.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ └── Providers/
|
||||||
|
│ │ ├── PlatformProviderIdentityResolver.php
|
||||||
|
│ │ ├── ProviderConnectionMutationService.php
|
||||||
|
│ │ ├── ProviderConnectionResolution.php
|
||||||
|
│ │ ├── ProviderConnectionResolver.php
|
||||||
|
│ │ ├── ProviderConnectionStateProjector.php
|
||||||
|
│ │ ├── ProviderIdentityResolution.php
|
||||||
|
│ │ └── ProviderIdentityResolver.php
|
||||||
|
│ └── Support/
|
||||||
|
│ └── Providers/
|
||||||
|
│ └── TargetScope/
|
||||||
|
└── tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Audit/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ ├── Guards/
|
||||||
|
│ ├── ManagedTenantOnboardingWizardTest.php
|
||||||
|
│ └── ProviderConnections/
|
||||||
|
└── Unit/
|
||||||
|
└── Providers/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the entire slice inside the existing Laravel runtime in `apps/platform`. The only new code shape planned is a small `Support/Providers/TargetScope` helper namespace or equivalent small support layer. Runtime changes stay inside existing provider connection resources and provider identity or connection services.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitutional violation is planned. One bounded complexity addition is tracked because the feature introduces a new shared descriptor layer.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| BLOAT-001 bounded target-scope descriptor | Multiple real surfaces and shared audit copy already need the same neutral provider-connection truth, and the current shared path still exposes Microsoft-shaped default semantics | Page-local label rewrites would not fix the shared contract or stop future regressions on the next surface |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Shared provider connection setup and inspection still teach Microsoft-specific identity as if it were the platform's default connection truth, which causes wrong operator assumptions and future provider-boundary drift.
|
||||||
|
- **Existing structure is insufficient because**: current resolution objects and resource schemas mix neutral provider-connection meaning with Microsoft-only fields such as `entra_tenant_id`, `tenantContext`, and `authorityTenant`.
|
||||||
|
- **Narrowest correct implementation**: add one shared target-scope descriptor and summary mapping layer, route shared labels and validation through it, and keep Microsoft-specific detail contextual instead of rewriting the full provider architecture.
|
||||||
|
- **Ownership cost created**: one small support namespace, one focused unit seam, a handful of extended feature and audit tests, and stricter review expectations on shared provider connection surfaces.
|
||||||
|
- **Alternative intentionally rejected**: reusing the broader migration and credential redesign scope from Spec 137. It would widen the slice beyond the current hotspot and obscure the real shared-contract goal.
|
||||||
|
- **Release truth**: current-release truth with bounded anti-drift hardening
|
||||||
|
|
||||||
|
## Phase 0 Research Summary
|
||||||
|
|
||||||
|
- The slice should reuse existing provider connection surfaces and services, not create a new provider-management framework.
|
||||||
|
- `ProviderConnectionResource` is a concrete hotspot because `entra_tenant_id` is still the required shared form field and a default list or detail summary.
|
||||||
|
- `ProviderIdentityResolution` is a second hotspot because shared identity output still uses Microsoft-shaped terms such as `tenantContext` and `authorityTenant` as if they were default shared semantics.
|
||||||
|
- Neutral target-scope truth should live in a small shared descriptor or summary layer used by forms, infolists, tables, onboarding, validation, and audit wording.
|
||||||
|
- Microsoft tenant, directory, consent, and authority details should remain contextual provider-owned metadata instead of disappearing or becoming the shared default contract.
|
||||||
|
- Focused unit and feature tests are sufficient; browser or heavy-governance coverage would add cost without proving unique behavior.
|
||||||
|
|
||||||
|
## Phase 1 Design Summary
|
||||||
|
|
||||||
|
- `research.md` captures the bounded contract and vocabulary decisions that keep the slice narrow.
|
||||||
|
- `data-model.md` defines the target-scope descriptor, provider-owned identity metadata, operator-facing surface summary, and guard result shape.
|
||||||
|
- `contracts/provider-identity-target-scope.logical.openapi.yaml` defines the internal logical contract for reading normalized target-scope summaries and evaluating neutrality drift.
|
||||||
|
- `quickstart.md` records the narrow implementation order and the validation sequence.
|
||||||
|
- `tasks.md` should sequence the work from target-scope descriptor foundation through shared surface adoption, audit wording alignment, and guard coverage.
|
||||||
|
|
||||||
|
## Implementation Close-Out Notes
|
||||||
|
|
||||||
|
- The implemented slice adds `App\Support\Providers\TargetScope` as the bounded target-scope support layer for descriptors, normalization, provider-owned contextual identity metadata, and surface summaries.
|
||||||
|
- Provider connection create, edit, list, detail, onboarding, identity-resolution, verification, health-check, and shared audit paths now carry neutral `target_scope` truth beside `provider_identity_context` metadata.
|
||||||
|
- Existing Microsoft runtime truth remains intentionally bounded: `entra_tenant_id` is still the persisted/runtime provider column, while shared operator surfaces use `Target scope` or `Target scope ID` by default and show `Microsoft tenant ID` only as contextual provider-owned detail.
|
||||||
|
- Unsupported provider and target-scope combinations now fail explicitly through the normalizer and provider connection resolver instead of falling through to Microsoft defaults.
|
||||||
|
- Existing security-sensitive provider connection actions remain confirmation-gated and capability-gated; this slice adds guard coverage rather than changing the action model.
|
||||||
|
- Close-out disposition remains `document-in-feature`. Broader governed-subject, compare-boundary, second-provider runtime, and credential-model redesign work stays deferred to follow-up specs.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
Run after artifact generation:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Add the shared target-scope descriptor
|
||||||
|
|
||||||
|
**Goal**: Introduce one neutral shared description of what a provider connection points to.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `apps/platform/app/Support/Providers/TargetScope/*` | Add a small target-scope descriptor and summary helper layer that can express `provider`, `scope_kind`, `scope_identifier`, and `scope_display_name` as neutral truth while carrying provider-owned contextual details only in companion metadata or summary shapes. |
|
||||||
|
| A.2 | `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `ProviderIdentityResolution.php` | Stop treating Microsoft-shaped identity output as the default shared semantic contract and expose neutral target-scope data for shared surfaces. |
|
||||||
|
| A.3 | `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php` | Prove the descriptor stays neutral by default and still allows bounded Microsoft contextual metadata. |
|
||||||
|
|
||||||
|
### Phase B — Adopt the descriptor on shared provider connection surfaces
|
||||||
|
|
||||||
|
**Goal**: Replace Microsoft-shaped default labels and summaries on the existing shared resource surfaces.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` | Replace `Entra tenant ID` as the default shared field or summary label with neutral target-scope wording on form, infolist, and table surfaces while keeping Microsoft-specific detail contextual. |
|
||||||
|
| B.2 | `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` or adjacent summary helpers | Align default-visible connection summaries with the same neutral descriptor used by the resource. |
|
||||||
|
| B.3 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` and existing Filament UI enforcement coverage | Prove list, detail, create, and edit surfaces show neutral target-scope truth first and preserve Microsoft-specific detail only contextually. |
|
||||||
|
|
||||||
|
### Phase C — Extend onboarding and mutation or audit wording
|
||||||
|
|
||||||
|
**Goal**: Keep setup, validation, and audit semantics aligned with the same shared contract.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Reuse the same target-scope descriptor in the onboarding provider setup step so the operator sees the same neutral meaning before continuing. |
|
||||||
|
| C.2 | `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` and related audit writers | Align create or update validation messages and audit wording with neutral provider and target-scope vocabulary while preserving provider context. |
|
||||||
|
| C.3 | `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` | Prove audit entries and onboarding copy use the same neutral target-scope contract. |
|
||||||
|
|
||||||
|
### Phase D — Add guardrails and finish bounded drift protection
|
||||||
|
|
||||||
|
**Goal**: Keep the hotspot closed without widening into a broader provider rewrite.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php` | Prove shared identity-resolution output no longer requires Microsoft-shaped default terms. |
|
||||||
|
| D.2 | `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` | Block new Microsoft-specific default labels, filters, validation messages, helper copy, or audit prose on shared provider connection surfaces unless the path is explicitly provider-owned. |
|
||||||
|
| D.3 | `specs/238-provider-identity-target-scope/quickstart.md` and later `tasks.md` | Keep the neutral-target-scope contract, bounded Microsoft contextual metadata, and no-marketplace/no-second-provider guardrail explicit. |
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- **Scope creep into Spec 137**: Connection-type and credential redesign could get pulled back into this slice. Mitigation: keep the implementation centered on shared target-scope semantics only and leave connection-type migration out of scope.
|
||||||
|
- **Over-abstracting the descriptor**: A broad multi-provider descriptor framework could appear. Mitigation: keep the new support layer small and only as rich as the currently touched provider connection surfaces require.
|
||||||
|
- **Audit or validation drift**: Shared UI labels may become neutral while audit prose or validation messages stay Microsoft-shaped. Mitigation: extend existing audit and mutation coverage in the same slice.
|
||||||
|
- **Hidden Microsoft default leakage**: A later surface might reintroduce `Entra tenant ID` as the default shared label. Mitigation: add one focused guard test for shared provider connection surfaces.
|
||||||
|
- **Onboarding mismatch**: Provider connection surfaces may become neutral while onboarding still shows Microsoft-first setup meaning. Mitigation: include the onboarding provider step in the first implementation slice and in validation coverage.
|
||||||
|
|
||||||
|
## Post-Design Re-check
|
||||||
|
|
||||||
|
Phase 0 and Phase 1 outputs keep the feature constitution-compliant, Filament v5 and Livewire v4 compliant, and intentionally narrow. The plan introduces no new persistence, no second provider runtime, no provider marketplace workflow, and no broad identity-migration framework. It is ready for `/speckit.tasks`.
|
||||||
82
specs/238-provider-identity-target-scope/quickstart.md
Normal file
82
specs/238-provider-identity-target-scope/quickstart.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Quickstart: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the shared provider connection target-scope contract so generic provider surfaces stop treating Microsoft identity as the default meaning of a connection.
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Add the small shared target-scope descriptor and summary helper layer.
|
||||||
|
2. Refactor shared provider connection and identity-resolution outputs so neutral target-scope truth is available without Microsoft-shaped default labels.
|
||||||
|
3. Update provider connection list, detail, create, and edit surfaces to use neutral target-scope language by default.
|
||||||
|
4. Update the onboarding provider setup step and shared audit and validation wording to reuse the same neutral contract.
|
||||||
|
5. Add focused guardrails that block Microsoft-specific default labels, filters, required fields, validation messages, helper copy, and audit prose from reappearing on shared provider connection surfaces.
|
||||||
|
|
||||||
|
## Suggested Code Areas
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/app/Filament/Resources/ProviderConnectionResource.php
|
||||||
|
apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
apps/platform/app/Services/Providers/
|
||||||
|
apps/platform/app/Support/Providers/TargetScope/
|
||||||
|
apps/platform/tests/Feature/Audit/
|
||||||
|
apps/platform/tests/Feature/Filament/
|
||||||
|
apps/platform/tests/Feature/ProviderConnections/
|
||||||
|
apps/platform/tests/Feature/Guards/
|
||||||
|
apps/platform/tests/Unit/Providers/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
Run the narrowest shared-contract proof first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the shared-surface and onboarding proof:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the audit and guardrail proof:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If PHP files changed, finish with formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- Confirm shared provider connection forms, tables, and infolists no longer use `Entra tenant ID` as the default shared label or required field.
|
||||||
|
- Confirm the shared target-scope descriptor remains understandable without provider-specific vocabulary.
|
||||||
|
- Confirm unsupported provider or target-scope combinations and missing-context paths fail explicitly instead of inheriting Microsoft defaults.
|
||||||
|
- Confirm Microsoft tenant, directory, and consent details remain available only as contextual provider-owned metadata.
|
||||||
|
- Confirm unchanged `404` versus `403` behavior and confirmation-gated sensitive actions are preserved on the touched shared surfaces.
|
||||||
|
- Confirm onboarding uses the same target-scope meaning as the provider connection resource.
|
||||||
|
- Confirm audit and validation wording follow the same provider and target-scope vocabulary.
|
||||||
|
- Confirm no broader credential-model, second-provider, or marketplace scope slipped into the slice.
|
||||||
|
|
||||||
|
## Guardrail Close-Out
|
||||||
|
|
||||||
|
- Validation to complete before final handoff:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- Guardrails checked:
|
||||||
|
- No new provider runtime or provider marketplace abstraction.
|
||||||
|
- No new persistence or schema rewrite.
|
||||||
|
- No Microsoft-specific default labels, filters, required fields, validation messages, helper copy, or audit prose on shared provider connection surfaces.
|
||||||
|
- Unchanged `404` versus `403` behavior and confirmation-gated sensitive actions remain intact on the touched shared surfaces.
|
||||||
|
- Microsoft contextual identity remains available where current-release workflows genuinely need it.
|
||||||
|
- Implemented close-out:
|
||||||
|
- Shared provider connection surfaces now use `Target scope` vocabulary by default.
|
||||||
|
- Provider-owned Microsoft details are carried in `provider_identity_context` and diagnostic labels such as `Microsoft tenant ID`.
|
||||||
|
- Create, update, verification, health-check, and onboarding audit metadata carries `target_scope` plus provider context instead of promoting a raw Microsoft tenant field as shared truth.
|
||||||
|
- Existing Filament table contracts for provider connections were updated to reflect provider and target scope as default-visible summary columns.
|
||||||
|
- Close-out decision: `document-in-feature`. The shared provider connection target-scope hotspot is closed here; broader cross-domain provider-boundary work remains separately tracked.
|
||||||
41
specs/238-provider-identity-target-scope/research.md
Normal file
41
specs/238-provider-identity-target-scope/research.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Research: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
## Decision 1: Use one small shared target-scope descriptor instead of a broad provider identity framework
|
||||||
|
|
||||||
|
- **Decision**: Introduce one small shared descriptor for provider connection target scope and reuse it across the provider connection resource, onboarding, validation, and audit wording.
|
||||||
|
- **Rationale**: The current release needs one neutral shared truth for multiple real surfaces, not a generalized provider marketplace or identity framework. A small descriptor layer is enough to keep shared language neutral while still letting Microsoft-specific detail remain contextual.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Page-local label cleanup only: rejected because it would leave the shared contract Microsoft-shaped underneath.
|
||||||
|
- Broad provider identity abstraction: rejected because there is still only one shipped provider runtime and the current hotspot is narrower than that.
|
||||||
|
|
||||||
|
## Decision 2: Keep Microsoft tenant and directory details as provider-owned contextual metadata
|
||||||
|
|
||||||
|
- **Decision**: Retain `entra_tenant_id`, authority-tenant details, consent wording, and Microsoft verification details only as contextual provider-owned metadata on Microsoft paths.
|
||||||
|
- **Rationale**: Operators still need Microsoft-specific identifiers for consent and troubleshooting, but those identifiers should not define the default meaning of a provider connection on generic shared surfaces.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Remove Microsoft-specific details from the UI entirely: rejected because the current product still needs them on Microsoft-only workflows.
|
||||||
|
- Keep them as the default connection summary: rejected because that preserves the current provider-boundary drift.
|
||||||
|
|
||||||
|
## Decision 3: Neutralize shared Filament surfaces first, not every provider term in the repo
|
||||||
|
|
||||||
|
- **Decision**: Limit the first slice to provider connection list, detail, create, edit, onboarding provider setup, and shared audit or validation wording directly tied to those surfaces.
|
||||||
|
- **Rationale**: These are the concrete operator-facing hotspots already named in the spec. A repo-wide terminology sweep would widen scope without improving the core shared contract any faster.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Rename every provider-related term immediately: rejected because it would turn one bounded hotspot into a broad copy and architecture sweep.
|
||||||
|
- Leave onboarding for later: rejected because it would preserve two competing interpretations of the same connection truth.
|
||||||
|
|
||||||
|
## Decision 4: Anchor neutrality in shared resolution and mutation paths, not only in UI labels
|
||||||
|
|
||||||
|
- **Decision**: Update the existing provider connection and identity-resolution outputs plus mutation and audit wording so shared surfaces all consume the same neutral target-scope semantics.
|
||||||
|
- **Rationale**: UI-only changes would be fragile because validation, audit prose, and future surfaces would still source their meaning from Microsoft-shaped service outputs.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Keep service outputs unchanged and translate everything in Filament only: rejected because future surfaces would likely repeat the same drift.
|
||||||
|
- Replace the entire provider identity stack: rejected because the current hotspot is limited to shared target-scope meaning.
|
||||||
|
|
||||||
|
## Decision 5: Enforce the contract with focused guardrails, not browser coverage
|
||||||
|
|
||||||
|
- **Decision**: Add focused unit and feature guard coverage for neutral target-scope descriptors, shared surface labels, onboarding reuse, and audit wording.
|
||||||
|
- **Rationale**: The risk is semantic drift in shared provider connection truth, not browser-only interaction. Narrow unit and feature coverage are the cheapest proof.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Browser tests: rejected because they add cost without proving unique behavior for this slice.
|
||||||
|
- Manual review only: rejected because the feature exists to stop the same hotspot from reopening quietly.
|
||||||
310
specs/238-provider-identity-target-scope/spec.md
Normal file
310
specs/238-provider-identity-target-scope/spec.md
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
# Feature Specification: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
**Feature Branch**: `238-provider-identity-target-scope`
|
||||||
|
**Created**: 2026-04-24
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Promote the next roadmap-fit spec candidate: Provider Identity & Target Scope Neutrality"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Shared provider connection semantics still mix neutral platform language with Microsoft-specific tenant identity assumptions, so operators and maintainers cannot tell where platform truth ends and provider detail begins.
|
||||||
|
- **Today's failure**: Generic-looking provider surfaces and validation paths still imply that a provider connection is fundamentally an Entra tenant binding, which teaches the wrong mental model to operators and quietly deepens provider coupling in shared code.
|
||||||
|
- **User-visible improvement**: Provider connection create, edit, list, detail, and onboarding-adjacent views describe the connection and its target scope consistently, while Microsoft-specific identity details remain available only when that context is actually needed.
|
||||||
|
- **Smallest enterprise-capable version**: Neutralize the in-scope provider connection and target-scope contract plus its default UI vocabulary, while preserving current Microsoft onboarding, consent, and verification behavior as bounded provider-specific detail.
|
||||||
|
- **Architecture center**: The primary deliverable is the shared provider connection target-scope contract. UI vocabulary changes are acceptance evidence for that contract, not the architectural center of the feature.
|
||||||
|
- **Explicit non-goals**: No second-provider runtime, no provider marketplace, no broad credential model redesign, no replay of the much wider Spec 137 migration scope, no compare-engine changes, and no generic multi-cloud onboarding framework.
|
||||||
|
- **Permanent complexity imported**: One narrower neutral target-scope contract for shared provider connection truth, one explicit distinction between shared target-scope semantics and provider-owned identity metadata, and focused regression coverage for shared labels, validation, and unsupported-path behavior.
|
||||||
|
- **Why now**: The roadmap and spec-candidates sequence place this as the next anti-drift hotspot immediately after Spec 237, because leaving provider identity and target scope Microsoft-shaped would undermine the newly hardened boundary before more provider-backed work lands.
|
||||||
|
- **Why not local**: The problem spans shared persistence semantics, list and detail vocabulary, create and edit flows, onboarding-adjacent copy, and validation behavior. A one-surface copy cleanup would leave the underlying shared contract and other surfaces free to drift again.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: Foundation-sounding scope and new boundary vocabulary. Defense: the slice stays bounded to provider connection identity and target-scope semantics, preserves Microsoft-first current product truth, and avoids speculative runtime or marketplace expansion.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace, tenant
|
||||||
|
- **Primary Routes**:
|
||||||
|
- Existing provider connection collection, create, edit, and detail surfaces under `/admin/provider-connections`
|
||||||
|
- Existing tenant-scoped onboarding or connection-setup flows under `/admin/t/{tenant}/...` where provider identity and target scope are shown or chosen
|
||||||
|
- Existing consent, verification, and provider-readiness surfaces that display shared connection identity or target-scope summaries
|
||||||
|
- **Data Ownership**:
|
||||||
|
- `provider_connections` remain tenant-owned operational records
|
||||||
|
- No new tenant-owned or workspace-owned business entity is introduced
|
||||||
|
- Provider-specific identity metadata remains provider-owned detail attached to existing connection truth rather than a new shared platform object
|
||||||
|
- **RBAC**:
|
||||||
|
- Existing workspace membership remains required for all provider connection surfaces
|
||||||
|
- Existing tenant entitlement remains required on tenant-context surfaces
|
||||||
|
- Existing provider-connection management capabilities continue to authorize create, edit, verification, and consent-adjacent mutations
|
||||||
|
- This spec does not introduce a new top-level capability or relax current 404 versus 403 semantics
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical-view surface
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: N/A - in-scope work stays on existing workspace-admin provider-connection surfaces plus existing tenant-scoped onboarding and provider-setup surfaces
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: shared provider connection vocabulary, detail summaries, form labels, status messaging, and audit prose
|
||||||
|
- **Systems touched**: provider connection list, detail, create, edit, onboarding-adjacent setup steps, consent and verification summaries, and any shared filters or helper copy that describe connection target scope
|
||||||
|
- **Existing pattern(s) to extend**: Spec 237 boundary ownership classification, existing provider connection resource and detail surfaces, and the current shared provider connection resolution path
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: the existing provider connection management surfaces and the current shared provider connection resolution and summary path remain the single shared path; this spec narrows their vocabulary and target-scope semantics rather than introducing a parallel presenter stack
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: the current shared path is sufficient for routing, authorization, and current provider-backed behavior, but it is insufficient because it still treats Microsoft-shaped identity and target-scope semantics as the implied default on generic provider surfaces
|
||||||
|
- **Allowed deviation and why**: bounded provider-specific descriptors are allowed when the selected provider is Microsoft and the operator genuinely needs tenant- or directory-specific context for consent, troubleshooting, or verification
|
||||||
|
- **Consistency impact**: provider, provider connection, target scope, consent state, and verification state must use one shared vocabulary across form labels, table columns, detail summaries, validation feedback, and audit prose
|
||||||
|
- **Review focus**: reviewers must block any generic provider connection surface or shared validation path that reintroduces Microsoft-specific field names, required labels, or defaults without explicit provider-owned justification
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: N/A
|
||||||
|
- **Delegated start/completion UX behaviors**: N/A
|
||||||
|
- **Local surface-owned behavior that remains**: N/A
|
||||||
|
- **Queued DB-notification policy**: N/A
|
||||||
|
- **Terminal notification path**: N/A
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Boundary classification**: mixed
|
||||||
|
- **Seams affected**: shared provider connection semantics, target-scope descriptors, create and edit field vocabulary, list and detail summaries, validation rules, filter labels, consent and verification summaries, and audit wording
|
||||||
|
- **Neutral platform terms preserved or introduced**: provider, provider connection, target scope, scope identifier, scope display name, consent state, verification state, readiness summary
|
||||||
|
- **Provider-specific semantics retained and why**: Microsoft tenant ID, directory ID, admin-consent wording, and Microsoft-specific verification details remain contextual provider-owned detail because current-release truth is still Microsoft-first and operators still need those descriptors on Microsoft paths
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: the shared platform contract is reduced to neutral connection and target-scope truth, while Microsoft-specific identifiers remain contextual metadata instead of required shared platform fields or default operator vocabulary
|
||||||
|
- **Follow-up path**: follow-up-spec for broader governed-subject and compare-boundary work; the provider connection identity and target-scope hotspot is resolved in-feature
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Provider connection list and detail | yes | Native Filament resource and infolist primitives | shared provider connection family | table, detail | no | Default language becomes target-scope neutral; Microsoft descriptors stay contextual |
|
||||||
|
| Provider connection create and edit | yes | Native Filament forms and sections | shared provider connection family | form, section | no | Existing create and edit flow remains; only shared contract and vocabulary are narrowed |
|
||||||
|
| Tenant onboarding provider step | yes | Native wizard and action surfaces | shared onboarding and provider family | wizard step | no | Existing onboarding path remains; scope and identity language become neutral by default |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Provider connection list and detail | Primary Decision Surface | Decide whether the connection points at the right target scope and what follow-up action is needed | Provider, target scope, consent state, verification state, and readiness summary | Raw provider-specific identifiers, consent diagnostics, low-level verification detail | Primary because this is the operational management surface where operators decide whether the connection is correctly scoped and usable | Follows provider setup and troubleshooting workflow rather than storage internals | Removes the need to infer meaning from raw tenant identifiers or mixed status labels |
|
||||||
|
| Provider connection create and edit | Primary Decision Surface | Choose the connection target and save it with the correct scope semantics | Provider choice, target-scope summary, required neutral fields, and provider-specific fields only when relevant | Secondary provider-specific guidance and troubleshooting detail | Primary because the operator is defining connection truth here, not just inspecting it later | Follows setup and correction workflow directly | Prevents incorrect scope choices caused by Microsoft-first default wording |
|
||||||
|
| Tenant onboarding provider step | Primary Decision Surface | Confirm that onboarding is connecting the intended target scope before continuing | Provider, target-scope summary, and the next action needed to continue onboarding | Provider-specific context such as Microsoft tenant identity when selected | Primary because onboarding must answer what is being connected before the operator commits to the next step | Keeps onboarding focused on the current tenant workflow | Reduces ambiguity between platform-neutral setup and Microsoft-specific details |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Provider connection list and detail | List / Table / Bulk | CRUD / List-first Resource | Inspect or edit the connection target scope | Full-row click to detail | allowed | One inline safe shortcut plus More | More or detail header only | /admin/provider-connections | existing provider connection detail route | Workspace, provider, target scope | Provider connections / Provider connection | What scope the connection represents and whether it is consented and verified | none |
|
||||||
|
| Provider connection create and edit | Record / Detail / Edit | Create/Edit Form | Save the connection with the correct target scope | Explicit create or edit page only | forbidden | Secondary guidance lives in section help text, not competing actions | Existing dangerous lifecycle actions remain off the create page and grouped on detail or edit where already supported | /admin/provider-connections | existing provider connection detail route after save | Provider and target scope | Provider connection | Which target scope will be connected and which fields are provider-neutral versus provider-specific | none |
|
||||||
|
| Tenant onboarding provider step | Workflow / Wizard / Launch | Wizard / Step-driven Flow | Continue onboarding with the confirmed provider target scope | Existing onboarding step | forbidden | Secondary navigation remains contextual only | Destructive actions are out of scope | existing tenant onboarding route | existing onboarding step route | Workspace, tenant, provider, target scope | Onboarding / Provider setup step | Which scope is being connected before the operator continues | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Provider connection list and detail | Workspace owner, tenant manager, support operator | Decide whether a provider connection is correctly scoped and ready for use | List and detail | What does this connection point to, and is it ready? | Provider, target scope, consent state, verification state, readiness summary | Raw tenant or directory identifiers, provider-specific verification detail | consent, verification, readiness | TenantPilot only for inspection; Microsoft tenant only when operator triggers existing consent or verification actions | View connection, Edit connection, Retry consent, Run verification | Existing destructive lifecycle actions only where already supported |
|
||||||
|
| Provider connection create and edit | Workspace owner or tenant manager | Create or correct a provider connection without encoding the wrong target-scope meaning | Create/Edit form | Am I connecting the correct scope, and are these the right fields for this provider? | Provider selection, target-scope summary, neutral required fields, contextual provider-specific fields | Provider-specific troubleshooting guidance | readiness prerequisites only | TenantPilot only until existing provider-facing consent or verification steps are invoked | Save connection, Cancel | none added |
|
||||||
|
| Tenant onboarding provider step | Tenant manager or onboarding operator | Continue onboarding with the intended provider target scope | Wizard step | What target scope am I connecting right now? | Provider, target-scope summary, current onboarding next step | Provider-specific detail needed for consent or troubleshooting | onboarding progress, consent readiness, verification readiness | TenantPilot onboarding flow and existing provider-facing consent actions | Continue onboarding, Open connection setup where already present | none added |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: yes
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Operators and maintainers must currently infer shared provider connection meaning from Microsoft-specific labels and identifiers, which creates wrong setup decisions and teaches the wrong platform truth.
|
||||||
|
- **Existing structure is insufficient because**: Spec 237 classifies seam ownership but does not yet narrow the concrete connection and target-scope contract where Microsoft-shaped semantics are still visible by default.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing provider connection surfaces and resolution path, replace only the shared identity and target-scope descriptor/contract semantics, and keep provider-specific detail contextual instead of building a new provider framework.
|
||||||
|
- **Ownership cost**: The codebase must keep one neutral vocabulary for shared provider connection truth, maintain focused tests that guard default labels and validation behavior, and review provider-specific additions more carefully on shared surfaces.
|
||||||
|
- **Alternative intentionally rejected**: Reusing the broader old Spec 137 scope was rejected because it mixes identity migration, dedicated credential design, and larger onboarding changes. Pure copy cleanup was rejected because it would leave the shared contract Microsoft-shaped underneath.
|
||||||
|
- **Release truth**: current-release truth with bounded anti-drift hardening
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit, Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: the change is proven by shared contract behavior plus operator-visible list, detail, create, edit, and onboarding semantics. Narrow unit coverage proves neutral target-scope mapping and validation, while focused feature coverage proves the operator-visible surfaces and authorization behavior.
|
||||||
|
- **New or expanded test families**: focused provider connection neutrality guard tests only
|
||||||
|
- **Fixture / helper cost impact**: low to moderate; tests need existing workspace, tenant, membership, provider connection, and provider-specific metadata fixtures, but no new heavy provider harness or browser stack
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament
|
||||||
|
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient; no browser or heavy-governance lane is justified for this slice
|
||||||
|
- **Reviewer handoff**: reviewers must confirm that tests prove default neutral target-scope semantics, contextual Microsoft detail retention, unchanged 404 versus 403 behavior, and explicit unsupported-path handling rather than only asserting copy fragments
|
||||||
|
- **Budget / baseline / trend impact**: none expected beyond a small increase in provider connection feature assertions
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Choose the Right Target Scope (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace or tenant admin, I want the provider connection setup flow to describe the target scope in neutral platform language so I can tell what I am connecting before I save it.
|
||||||
|
|
||||||
|
**Why this priority**: This is the main operator-facing trust problem. If setup still implies that every connection is fundamentally an Entra tenant binding, the shared platform contract remains misleading.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening the create or edit flow, choosing an in-scope provider, and confirming that shared fields and summaries use target-scope language while provider-specific fields appear only when the selected provider needs them.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an operator opens the provider connection create flow, **When** they review the default shared fields, **Then** the form describes provider and target scope in neutral platform language rather than Microsoft-specific defaults.
|
||||||
|
2. **Given** the selected provider is Microsoft, **When** the operator reaches provider-specific consent or verification context, **Then** the UI may show tenant or directory-specific detail without redefining the shared target-scope meaning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Inspect Connection Meaning Without Guesswork (Priority: P1)
|
||||||
|
|
||||||
|
As an operator inspecting existing provider connections, I want list and detail surfaces to show provider, target scope, consent state, and verification state separately so I can understand what the connection represents and what follow-up is needed.
|
||||||
|
|
||||||
|
**Why this priority**: The day-to-day management surface must become trustworthy. If operators still need to infer meaning from raw IDs or mixed status fields, the spec has not delivered its main value.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by loading the list and detail surfaces for representative provider connections and confirming that the shared summary stays neutral while provider-specific identifiers remain secondary detail.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a provider connection is listed on the shared resource page, **When** the operator scans the row, **Then** they can see the provider, target scope, consent state, and verification state without reading raw provider-specific IDs.
|
||||||
|
2. **Given** the operator opens connection detail, **When** provider-specific Microsoft information exists, **Then** it appears as contextual detail rather than the primary shared identity of the connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Extend Shared Provider Surfaces Safely (Priority: P2)
|
||||||
|
|
||||||
|
As a maintainer or reviewer, I want shared provider connection semantics to stay neutral by default so future provider-related changes do not silently reintroduce Microsoft-first platform truth.
|
||||||
|
|
||||||
|
**Why this priority**: The slice is justified not only by current UI clarity but by preventing the same hotspot from reopening as more provider-backed work lands.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by exercising the focused regression coverage and confirming that a generic provider surface cannot require Microsoft-specific shared fields or default labels without explicit provider-owned justification.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a generic provider connection surface or shared validation path is extended, **When** Microsoft-specific identity labels are introduced as the default shared contract, **Then** focused regression coverage fails visibly.
|
||||||
|
2. **Given** a Microsoft-specific surface still needs tenant or directory detail, **When** the same coverage runs, **Then** the contextual provider-owned detail remains allowed without forcing those labels into the generic contract.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A provider connection may already have valid target-scope summary data but be missing provider-specific Microsoft identity detail, and the shared surface must stay interpretable without pretending the connection is fully ready.
|
||||||
|
- A Microsoft connection may legitimately need tenant or directory identifiers for consent or troubleshooting, but those identifiers must not become the default label set on generic provider surfaces.
|
||||||
|
- Existing rows may still contain legacy Microsoft-shaped fields; in-scope surfaces must present neutral target-scope truth without silently promoting legacy fields back into shared platform defaults.
|
||||||
|
- Unauthorized users must not learn target-scope or provider identity details through list filters, detail summaries, or onboarding-adjacent helper copy.
|
||||||
|
- Changing the selected provider in a create or edit flow must update required fields and helper copy without leaving stale Microsoft-specific wording behind.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph endpoints, new long-running operations, or new persisted business entities. It narrows the shared contract and UI semantics for existing provider connection create, edit, view, consent, and verification flows. Any in-scope create or update mutation remains server-authorized and auditable.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds one narrower shared abstraction for target-scope semantics because the existing shared connection contract still encodes provider-specific truth by default. It does not add new persistence, new state families, or new cross-domain UI taxonomy.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** This feature is cross-cutting across provider connection forms, lists, details, onboarding-adjacent setup, validation feedback, and audit prose. It extends the existing shared provider connection path rather than introducing a second presenter or page-local vocabulary layer.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** Shared provider connection and target-scope truth remain platform-core. Provider-specific identity descriptors, consent wording, and Microsoft verification detail remain provider-owned. The spec keeps provider-specific semantics out of the default shared contract and records broader governed-subject and compare semantics as later follow-up work.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in narrow unit and feature lanes. No browser or heavy-governance family is added. Any helper introduced for provider connection neutrality coverage must keep workspace, tenant, membership, and provider context explicit rather than default.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** N/A - this slice does not create, start, or redesign an `OperationRun`.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX-START-001):** N/A - no `OperationRun` start or link semantics are changed.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** In-scope surfaces remain on the tenant and workspace admin plane. Non-members or actors lacking tenant entitlement remain 404. Members lacking the relevant capability remain 403. Authorization stays server-side for create, edit, consent-adjacent, and verification mutations. Existing security-sensitive actions remain confirmation-gated and audited.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Consent and verification labels remain centralized; this spec does not introduce a new badge family or a second mapping layer.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** UI-FIL-001 is satisfied. In-scope operator-facing changes stay on native Filament forms, tables, infolists, sections, and existing shared UI primitives. No local replacement markup or page-local status language is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target object is the provider connection and its target scope. Operator verbs remain existing verbs such as connect, edit, retry consent, and run verification. The same provider and target-scope vocabulary must be preserved across buttons, headings, helper copy, validation feedback, and audit prose.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** The affected provider connection and onboarding surfaces are primary decision surfaces because they answer what is being connected and whether the connection is correctly scoped. Provider-specific diagnostics remain secondary detail.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The chosen action-surface classes, inspect models, grouped action placement, scope signals, canonical nouns, and default-visible truth are captured in the surface tables above. No exception is required.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** This spec does not add new action families. Existing navigation and mutation placement remain unchanged, and no mixed catch-all action structure is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** The default-visible content stays operator-first by surfacing provider, target scope, consent state, and verification state before raw provider-specific identifiers. Diagnostics stay secondary and contextual.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces only the minimum shared semantic tightening required to keep provider-specific identity detail from masquerading as platform-core truth. Tests focus on operator-visible meaning and boundary consequences rather than thin presentation indirection alone.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied. Each affected surface keeps one primary inspect or open model, redundant View actions remain absent, empty action groups remain absent, and destructive actions stay in their existing safe placements. The UI Action Matrix below records the in-scope surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope create and edit forms continue to use the existing sectioned Filament layout, view pages continue to use the existing detail or infolist presentation, empty states keep one primary CTA, and tables continue to expose core searchable and filterable dimensions such as provider and target scope. No UX-001 exemption is required.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-238-001**: The shared provider connection contract MUST distinguish neutral provider-connection truth from provider-specific identity metadata.
|
||||||
|
- **FR-238-002**: In-scope shared create, edit, list, and detail surfaces MUST use neutral default vocabulary for provider connection and target scope.
|
||||||
|
- **FR-238-003**: The shared provider connection target-scope descriptor/contract MUST remain understandable through neutral fields such as scope kind, scope identifier, and scope display name without assuming Microsoft directory semantics as the default meaning.
|
||||||
|
- **FR-238-004**: Microsoft-specific tenant, directory, consent, and verification descriptors MAY appear only as contextual provider-owned detail when the selected provider is Microsoft.
|
||||||
|
- **FR-238-005**: Shared validation and persistence paths MUST NOT require Microsoft-specific fields unless the selected provider explicitly needs them through provider-owned rules.
|
||||||
|
- **FR-238-006**: Provider connection list and detail surfaces MUST show provider, target scope, consent state, and verification state as separate default-visible dimensions.
|
||||||
|
- **FR-238-007**: Shared filters, columns, section headings, and detail summaries for provider connections MUST NOT use Microsoft-specific nouns as the default labels.
|
||||||
|
- **FR-238-008**: Existing Microsoft onboarding, consent, and verification flows MUST preserve the provider-specific detail operators need without redefining the shared target-scope contract.
|
||||||
|
- **FR-238-009**: Unsupported provider or target-scope combinations encountered on shared paths MUST fail explicitly rather than inheriting Microsoft defaults.
|
||||||
|
- **FR-238-010**: Existing authorization behavior for provider connection surfaces MUST remain unchanged, including 404 versus 403 semantics.
|
||||||
|
- **FR-238-011**: Existing security-sensitive provider connection mutations MUST remain confirmation-gated and auditable.
|
||||||
|
- **FR-238-012**: The first implementation slice MUST cover provider connection list, detail, create, edit, and tenant onboarding-adjacent setup surfaces.
|
||||||
|
- **FR-238-013**: The feature MUST NOT introduce a second-provider runtime, provider marketplace, or broad credential model redesign.
|
||||||
|
- **FR-238-014**: Audit events for in-scope provider connection create or update flows MUST describe target scope in neutral vocabulary while still recording provider context.
|
||||||
|
- **FR-238-015**: Regression coverage MUST fail if a generic provider connection surface reintroduces Microsoft-specific default labels or required shared fields without explicit provider-owned justification.
|
||||||
|
- **FR-238-016**: Shared provider/platform seams MUST NOT introduce new Microsoft-specific default labels, filter labels, required fields, validation messages, audit prose, or helper copy unless the path is explicitly provider-owned and scoped to the Microsoft provider.
|
||||||
|
- **FR-238-017**: Any new target-scope helper, descriptor, or normalization path MUST preserve the distinction between neutral platform scope truth and provider-owned identity metadata.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Reopening the broader connection-type and credential migration work from Spec 137
|
||||||
|
- Adding a second provider or a provider marketplace workflow
|
||||||
|
- Redesigning provider verification or consent execution logic end to end
|
||||||
|
- Renaming every provider-related term in the product outside the in-scope shared connection and target-scope hotspot
|
||||||
|
- Introducing a new governed-subject taxonomy or compare orchestration layer
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- Current-release product truth remains Microsoft-first, but shared provider connection semantics must no longer imply that Microsoft identity is the default platform contract.
|
||||||
|
- Existing provider connection resources, onboarding steps, consent flows, and verification flows remain in place and are narrowed rather than rebuilt.
|
||||||
|
- Existing operator roles and capabilities already cover the in-scope provider connection surfaces.
|
||||||
|
- Legacy Microsoft-shaped fields may still exist on some rows, but this spec prefers canonical replacement on in-scope shared surfaces instead of compatibility shims.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Boundary ownership classification from `specs/237-provider-boundary-hardening/spec.md`
|
||||||
|
- Existing provider connection resource and detail surfaces
|
||||||
|
- Existing onboarding, consent, and verification flows that display provider connection identity or scope
|
||||||
|
- Existing audit logging and authorization infrastructure for provider connections
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Provider connection list | existing provider connection Filament resource | `New connection` | `recordUrl()` to detail | `Edit`, `More` | Existing grouped bulk actions only where already supported | `Connect provider` | `Edit`, `Retry consent`, `Run verification`, `More` | `Save` and `Cancel` on create and edit pages | yes | Default labels become provider and target-scope neutral; no exemption |
|
||||||
|
| Provider connection detail | existing provider connection detail surface | `Edit`, `Retry consent`, `Run verification` | detail page is the only primary inspect model | none added | none | n/a | `Edit`, `Retry consent`, `Run verification`, `More` | `Save` and `Cancel` on edit page | yes | Provider-specific Microsoft identifiers remain contextual secondary detail |
|
||||||
|
| Tenant onboarding provider step | existing tenant onboarding provider setup step | none added | existing wizard step only | none | none | existing provider setup CTA remains single primary CTA | contextual open-setup action only where already supported | existing continue and cancel flow remains | yes | Step remains native; only shared target-scope semantics are narrowed |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Provider Connection**: The tenant-owned record that represents a provider-backed connection and its readiness for use.
|
||||||
|
- **Target Scope**: The neutral platform description of what the connection points to, expressed through shared fields such as scope kind, scope identifier, and scope display name.
|
||||||
|
- **Provider-Specific Identity Metadata**: Contextual provider-owned detail such as Microsoft tenant or directory identifiers that operators may need for consent, verification, or troubleshooting.
|
||||||
|
- **Connection Readiness Summary**: The operator-facing combination of consent state and verification state used to explain whether the connection is usable.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-238-001**: In covered provider connection list, detail, create, edit, and onboarding surfaces, 100% of default shared labels use neutral provider and target-scope language rather than Microsoft-specific defaults.
|
||||||
|
- **SC-238-002**: In covered Microsoft scenarios, 100% of required tenant or directory detail remains available contextually without becoming the default shared identity language of the connection.
|
||||||
|
- **SC-238-003**: In focused operator-visible coverage, users can distinguish provider, target scope, consent state, and verification state without relying on raw provider-specific identifiers to infer meaning.
|
||||||
|
- **SC-238-004**: In all covered unsupported or missing-context scenarios, shared validation paths fail explicitly rather than inheriting Microsoft-first fallback behavior.
|
||||||
|
- **SC-238-005**: In all covered create, update, and security-sensitive mutation scenarios, the audit trail records workspace, tenant, provider, and target-scope context.
|
||||||
|
- **SC-238-006**: Focused guard or regression coverage fails when shared provider/platform seams introduce Microsoft-specific default labels, filters, validation messages, audit prose, or helper copy outside explicitly provider-owned Microsoft paths.
|
||||||
258
specs/238-provider-identity-target-scope/tasks.md
Normal file
258
specs/238-provider-identity-target-scope/tasks.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Provider Identity & Target Scope Neutrality"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Provider Identity & Target Scope Neutrality
|
||||||
|
|
||||||
|
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/`
|
||||||
|
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/contracts/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) for runtime behavior changes; keep proof in the narrow `Unit` and `Feature` lanes named in the plan
|
||||||
|
**Operations**: No new `OperationRun` type or Monitoring surface is introduced; preserve current health-check and verification run behavior while neutralizing shared target-scope semantics
|
||||||
|
**RBAC**: Preserve existing workspace and tenant authorization semantics on touched provider connection and onboarding surfaces, including `404` for non-members and `403` for members missing capability
|
||||||
|
**Provider Boundary**: Shared target-scope truth remains platform-core; Microsoft tenant, directory, consent, and authority details remain explicitly bounded provider-owned contextual metadata
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so the shared target-scope contract, shared-surface adoption, and guardrail close-out can be implemented and validated incrementally.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [X] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [X] New or changed tests stay in existing provider connection, Filament, audit, onboarding, and guard families; no browser or heavy-governance lane is added.
|
||||||
|
- [X] Shared helpers, factories, fixtures, and provider context defaults stay cheap by default; do not introduce a default provider-world bootstrap.
|
||||||
|
- [X] Planned validation commands cover descriptor normalization, explicit unsupported-path handling, shared-surface neutrality, unchanged auth and confirmation behavior, onboarding reuse, audit wording, and guardrails without widening scope.
|
||||||
|
- [X] Surface test profile remains `standard-native-filament` with one onboarding wizard-step extension.
|
||||||
|
- [X] Any remaining provider-specific hotspot resolves as `document-in-feature` or `follow-up-spec`, not as silent platform-core truth.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Context)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the current hotspots, owning files, and existing proof lanes before introducing the shared target-scope contract.
|
||||||
|
|
||||||
|
- [X] T001 Review the current Microsoft-shaped shared-surface hotspots in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T002 [P] Review existing provider connection audit and UI enforcement coverage in `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
|
||||||
|
- [X] T003 [P] Review existing provider-owned exception seams in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Add the shared target-scope primitives that every user story depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 Create the shared descriptor primitives in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`
|
||||||
|
- [X] T005 [P] Create the shared normalization and surface-summary helpers in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
|
||||||
|
- [X] T006 Update `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `apps/platform/app/Services/Providers/ProviderIdentityResolution.php` to expose neutral target-scope data plus provider-owned contextual metadata
|
||||||
|
- [X] T007 [P] Align resolver consumption of the new neutral descriptor in `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`
|
||||||
|
- [X] T008 [P] Sync the shared descriptor, normalization, surface-summary, and neutrality-evaluation shapes with `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml`
|
||||||
|
|
||||||
|
**Checkpoint**: Shared target-scope primitives exist; user story work can now build on one explicit neutral contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Choose the Right Target Scope (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make the create and edit flow describe provider connection scope in neutral platform language before the operator saves it.
|
||||||
|
|
||||||
|
**Independent Test**: Open the provider connection create or edit flow, choose an in-scope provider, and verify the shared fields and helper copy use neutral target-scope language while Microsoft-specific identity only appears contextually when the selected provider is Microsoft.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [P] [US1] Add descriptor normalization coverage, including explicit unsupported provider or target-scope combinations and missing-context failures, in `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`
|
||||||
|
- [X] T010 [P] [US1] Add create and edit neutral target-scope flow coverage, including explicit unsupported combination and missing-context failures, in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`
|
||||||
|
- [X] T011 [P] [US1] Extend shared authorization and UI enforcement coverage for provider connection create, list, and detail target-scope surfaces, including unchanged `404` versus `403` behavior, in `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T012 [US1] Replace shared create and edit form fields plus helper text with neutral target-scope wording in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T013 [US1] Route create and edit normalization plus mutation messaging through the shared target-scope helper in `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`
|
||||||
|
- [X] T014 [US1] Keep Microsoft-specific contextual identity available only on Microsoft create and edit paths in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php` and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`
|
||||||
|
- [X] T015 [US1] Run the US1 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently deliverable as the core setup-flow neutrality slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Inspect Connection Meaning Without Guesswork (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make list and detail surfaces show provider, target scope, consent state, and verification state separately without forcing operators to infer meaning from raw Microsoft identifiers.
|
||||||
|
|
||||||
|
**Independent Test**: Load provider connection list and detail surfaces for representative connections and verify the default-visible summary stays neutral while provider-specific identity remains secondary diagnostic detail.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [P] [US2] Add list and detail neutrality coverage plus default-visible status separation in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`
|
||||||
|
- [X] T017 [P] [US2] Add shared identity-resolution neutrality coverage in `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`
|
||||||
|
- [X] T018 [P] [US2] Extend badge and summary assertions that keep consent and verification distinct in `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php` and `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T019 [US2] Replace default list, table, infolist, and detail target-scope labels plus summaries in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T020 [P] [US2] Align derived connection summaries with the shared target-scope descriptor in `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
|
||||||
|
- [X] T021 [US2] Keep contextual Microsoft identity and diagnostics secondary to neutral target-scope truth in `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T022 [US2] Run the US2 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, and `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Stories 1 and 2 both work independently, with shared setup and inspection surfaces aligned to one neutral target-scope contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Extend Shared Provider Surfaces Safely (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Reuse the same neutral target-scope contract in onboarding, mutation copy, and audit wording, and block future regressions on shared provider surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Exercise onboarding, audit, UI enforcement, and guard coverage to verify shared provider surfaces cannot reintroduce Microsoft-specific default labels, filters, required fields, validation messages, helper copy, or audit prose without explicit provider-owned justification.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T023 [P] [US3] Add onboarding provider-setup neutrality coverage plus unchanged `404` versus `403` leakage protection in `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`
|
||||||
|
- [X] T024 [P] [US3] Extend shared audit wording coverage and sensitive-action confirmation-gate coverage in `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
|
||||||
|
- [X] T025 [P] [US3] Add shared-surface guard coverage for Microsoft-specific default labels, filters, required fields, validation messages, helper copy, and audit prose in `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T026 [US3] Reuse the shared target-scope descriptor in the onboarding provider setup step in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T027 [US3] Align provider connection action labels, filter labels, section headings, helper copy, validation messages, mutation messaging, and audit wording with neutral provider and target-scope vocabulary in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php`
|
||||||
|
- [X] T028 [US3] Codify the provider-owned contextual exception boundary from `specs/237-provider-boundary-hardening/spec.md` and the review-stop expectations in `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml` and `specs/238-provider-identity-target-scope/quickstart.md`
|
||||||
|
- [X] T029 [US3] Run the US3 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories are independently functional, and shared provider surfaces are guarded against reintroducing Microsoft-first default truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finalize formatting, validation, and guardrail close-out across the full slice.
|
||||||
|
|
||||||
|
- [X] T030 [P] Refresh the implementation notes, logical contract wording, and validation commands in `specs/238-provider-identity-target-scope/plan.md`, `specs/238-provider-identity-target-scope/quickstart.md`, and `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml`
|
||||||
|
- [X] T031 [P] Run formatting for touched PHP files in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Services/Providers/`, `apps/platform/app/Support/Providers/TargetScope/`, `apps/platform/tests/Unit/Providers/`, and `apps/platform/tests/Feature/`
|
||||||
|
- [X] T032 Run the final focused validation lane from `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
|
||||||
|
- [X] T033 Record the guardrail close-out, `document-in-feature` disposition, and deferred provider-boundary follow-up status in `specs/238-provider-identity-target-scope/plan.md` and `specs/238-provider-identity-target-scope/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow the stable descriptor contract from User Story 1 on shared resource surfaces.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and should follow User Story 1 because onboarding and audit wording reuse the same target-scope descriptor and mutation vocabulary.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: This is the MVP and should ship first.
|
||||||
|
- **User Story 2 (P1)**: Conceptually independent after Phase 2, but it reuses the target-scope descriptor and shared resource semantics stabilized in User Story 1.
|
||||||
|
- **User Story 3 (P2)**: Conceptually independent after Phase 2, but its onboarding, audit, confirmation-gate, exception-boundary, and guardrail tasks remain part of the shippable slice because they satisfy FR-238-011, FR-238-014, FR-238-015, and FR-238-016.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests should be written and fail before the corresponding implementation tasks.
|
||||||
|
- Shared descriptor and resolution changes must land before any surface-specific adoption task consumes them.
|
||||||
|
- Serialize edits in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` when multiple story tasks touch the same resource file.
|
||||||
|
- Finish each story's verification task before moving to the next priority when working sequentially.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **Setup**: `T002` and `T003` can run in parallel.
|
||||||
|
- **Foundational**: `T005`, `T007`, and `T008` can run in parallel after `T004` defines the descriptor primitives.
|
||||||
|
- **US1 tests**: `T009`, `T010`, and `T011` can run in parallel.
|
||||||
|
- **US1 implementation**: `T013` and `T014` can run in parallel after `T012` settles the shared form contract.
|
||||||
|
- **US2 tests**: `T016`, `T017`, and `T018` can run in parallel.
|
||||||
|
- **US2 implementation**: `T020` can run in parallel with `T021`, but both should follow `T019` because `ProviderConnectionResource.php` is shared.
|
||||||
|
- **US3 tests**: `T023`, `T024`, and `T025` can run in parallel.
|
||||||
|
- **US3 implementation**: `T026` and `T028` can run in parallel; `T027` should serialize after `T019` and `T012` because it touches `ProviderConnectionResource.php`.
|
||||||
|
- **Polish**: `T030` and `T031` can run in parallel before `T032` and `T033`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US1 coverage in parallel:
|
||||||
|
T009 apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php
|
||||||
|
T010 apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php
|
||||||
|
T011 apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php
|
||||||
|
|
||||||
|
# Then split the non-overlapping implementation follow-up:
|
||||||
|
T013 apps/platform/app/Services/Providers/ProviderConnectionMutationService.php and apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php
|
||||||
|
T014 apps/platform/app/Services/Providers/ProviderIdentityResolver.php and apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US2 summary and identity coverage in parallel:
|
||||||
|
T016 apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php and apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php
|
||||||
|
T017 apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php
|
||||||
|
T018 apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php and apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php
|
||||||
|
|
||||||
|
# Then split non-overlapping implementation follow-up:
|
||||||
|
T020 apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php and apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php
|
||||||
|
T021 apps/platform/app/Services/Providers/ProviderConnectionResolution.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US3 onboarding, audit, and guard coverage in parallel:
|
||||||
|
T023 apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php
|
||||||
|
T024 apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php
|
||||||
|
T025 apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php
|
||||||
|
|
||||||
|
# Then split non-overlapping implementation follow-up:
|
||||||
|
T026 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
T028 specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml and specs/238-provider-identity-target-scope/quickstart.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP / Shippable Slice
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1 and Phase 4: User Story 2.
|
||||||
|
4. Complete all of Phase 5: User Story 3 so onboarding, audit wording, confirmation-gate proof, exception-boundary codification, and guard coverage land with the same shippable slice.
|
||||||
|
5. Stop and validate with `T015`, `T022`, `T029`, and `T032`.
|
||||||
|
6. Review whether list, detail, create, edit, onboarding, audit wording, and guardrails now expose one stable target-scope contract before moving to close-out only work.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup and Foundational establish the shared target-scope descriptor and neutral resolution path.
|
||||||
|
2. Add User Story 1 and validate create and edit neutrality.
|
||||||
|
3. Add User Story 2 and validate inspection, summary, and status separation parity.
|
||||||
|
4. Add User Story 3 and validate onboarding reuse, audit wording, and guardrail protection.
|
||||||
|
5. Finish with formatting, final validation, and close-out documentation.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Complete Setup and Foundational together.
|
||||||
|
2. After Phase 2:
|
||||||
|
- Developer A: create and edit neutrality in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` plus supporting mutation wiring.
|
||||||
|
- Developer B: list and detail summary alignment in `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` and the new target-scope support files.
|
||||||
|
- Developer C: onboarding, audit wording, and guardrails in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`.
|
||||||
|
3. Serialize edits in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` because User Stories 1, 2, and 3 all touch that file.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
The narrowest shippable increment is Phase 1 through Phase 5. Phase 6 is close-out, formatting, and final documentation refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the files do not overlap.
|
||||||
|
- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`.
|
||||||
|
- The narrowest proving lane remains `fast-feedback` plus `confidence`; do not widen into browser or heavy-governance without explicit follow-up justification.
|
||||||
|
- Keep Microsoft-specific identity contextual and bounded to provider-owned surfaces; do not turn this slice into a second-provider or credential-model redesign.
|
||||||
Loading…
Reference in New Issue
Block a user