feat/047-inventory-foundations-nodes #51
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -8,6 +8,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
|
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
|
||||||
- PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph)
|
- PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph)
|
||||||
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
||||||
|
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
||||||
|
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -27,9 +29,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -19,12 +19,19 @@ class InventoryCoverage extends Page
|
|||||||
/**
|
/**
|
||||||
* @var array<int, array<string, mixed>>
|
* @var array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public array $supportedTypes = [];
|
public array $supportedPolicyTypes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $foundationTypes = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$types = config('tenantpilot.supported_policy_types', []);
|
$policyTypes = config('tenantpilot.supported_policy_types', []);
|
||||||
|
$foundationTypes = config('tenantpilot.foundation_types', []);
|
||||||
|
|
||||||
$this->supportedTypes = is_array($types) ? $types : [];
|
$this->supportedPolicyTypes = is_array($policyTypes) ? $policyTypes : [];
|
||||||
|
$this->foundationTypes = is_array($foundationTypes) ? $foundationTypes : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,10 +100,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
|
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
|
||||||
|
|
||||||
return $edges->take(100); // both directions combined
|
|
||||||
|
|
||||||
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
|
|
||||||
})
|
})
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
@ -123,12 +119,12 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$typeOptions = collect(config('tenantpilot.supported_policy_types', []))
|
$typeOptions = collect(static::allTypeMeta())
|
||||||
->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)])
|
->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)])
|
||||||
->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '')
|
->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$categoryOptions = collect(config('tenantpilot.supported_policy_types', []))
|
$categoryOptions = collect(static::allTypeMeta())
|
||||||
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
|
->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)])
|
||||||
->filter(fn ($value, $key) => is_string($key) && $key !== '')
|
->filter(fn ($value, $key) => is_string($key) && $key !== '')
|
||||||
->all();
|
->all();
|
||||||
@ -194,7 +190,21 @@ private static function typeMeta(?string $type): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect(config('tenantpilot.supported_policy_types', []))
|
return collect(static::allTypeMeta())
|
||||||
->firstWhere('type', $type) ?? [];
|
->firstWhere('type', $type) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function allTypeMeta(): array
|
||||||
|
{
|
||||||
|
$supported = config('tenantpilot.supported_policy_types', []);
|
||||||
|
$foundations = config('tenantpilot.foundation_types', []);
|
||||||
|
|
||||||
|
return array_merge(
|
||||||
|
is_array($supported) ? $supported : [],
|
||||||
|
is_array($foundations) ? $foundations : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -245,7 +245,16 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
try {
|
try {
|
||||||
$typesConfig = $this->supportedTypeConfigByType();
|
$typesConfig = $this->supportedTypeConfigByType();
|
||||||
|
|
||||||
foreach ($normalizedSelection['policy_types'] as $policyType) {
|
$policyTypes = $normalizedSelection['policy_types'] ?? [];
|
||||||
|
$foundationTypes = $this->foundationTypes();
|
||||||
|
|
||||||
|
if ((bool) ($normalizedSelection['include_foundations'] ?? false)) {
|
||||||
|
$policyTypes = array_values(array_unique(array_merge($policyTypes, $foundationTypes)));
|
||||||
|
} else {
|
||||||
|
$policyTypes = array_values(array_diff($policyTypes, $foundationTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($policyTypes as $policyType) {
|
||||||
$typeConfig = $typesConfig[$policyType] ?? null;
|
$typeConfig = $typesConfig[$policyType] ?? null;
|
||||||
|
|
||||||
if (! is_array($typeConfig)) {
|
if (! is_array($typeConfig)) {
|
||||||
@ -505,8 +514,16 @@ private function supportedTypeConfigByType(): array
|
|||||||
/** @var array<int, array<string, mixed>> $supported */
|
/** @var array<int, array<string, mixed>> $supported */
|
||||||
$supported = config('tenantpilot.supported_policy_types', []);
|
$supported = config('tenantpilot.supported_policy_types', []);
|
||||||
|
|
||||||
|
/** @var array<int, array<string, mixed>> $foundations */
|
||||||
|
$foundations = config('tenantpilot.foundation_types', []);
|
||||||
|
|
||||||
|
$all = array_merge(
|
||||||
|
is_array($supported) ? $supported : [],
|
||||||
|
is_array($foundations) ? $foundations : [],
|
||||||
|
);
|
||||||
|
|
||||||
$byType = [];
|
$byType = [];
|
||||||
foreach ($supported as $config) {
|
foreach ($all as $config) {
|
||||||
$type = $config['type'] ?? null;
|
$type = $config['type'] ?? null;
|
||||||
if (is_string($type) && $type !== '') {
|
if (is_string($type) && $type !== '') {
|
||||||
$byType[$type] = $config;
|
$byType[$type] = $config;
|
||||||
@ -516,6 +533,23 @@ private function supportedTypeConfigByType(): array
|
|||||||
return $byType;
|
return $byType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function foundationTypes(): array
|
||||||
|
{
|
||||||
|
$types = config('tenantpilot.foundation_types', []);
|
||||||
|
if (! is_array($types)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($types)
|
||||||
|
->map(fn (array $row) => $row['type'] ?? null)
|
||||||
|
->filter(fn ($type) => is_string($type) && $type !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||||
{
|
{
|
||||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<x-filament::page>
|
<x-filament::page>
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
|
<div class="text-base font-medium">Policies</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@ -12,7 +13,35 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
@foreach ($supportedTypes as $row)
|
@foreach ($supportedPolicyTypes as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="text-base font-medium">Foundations</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th class="py-2 pr-4 font-medium">Type</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Label</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Category</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Restore</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Risk</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
@foreach ($foundationTypes as $row)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
|
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
|
||||||
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
|
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
# Requirements Checklist — Foundations in Inventory (047)
|
||||||
|
|
||||||
|
## Constitution Gates
|
||||||
|
|
||||||
|
- [x] Tenant isolation: all foundation sync reads/writes are scoped to Tenant::current()/tenant_id (no leakage).
|
||||||
|
- [x] No snapshot/backup side effects: Inventory sync must not write to policy_versions/backup_* tables.
|
||||||
|
- [x] Config-driven types: foundation types are sourced from config('tenantpilot.foundation_types') only (no hardcoded lists).
|
||||||
|
- [x] No UI Graph calls: Inventory/Dependencies UI must render using DB-only resolution (no runtime Graph/Entra lookups).
|
||||||
|
- [x] Idempotency: re-running sync does not create duplicates; last_seen_at/last_seen_run_id update deterministically.
|
||||||
|
- [x] Data minimization: foundation meta_jsonb is sanitized (stored == InventoryMetaSanitizer::sanitize(stored)).
|
||||||
|
- [x] Observability: InventorySyncRun observed/upserted counts include foundations when enabled, exclude when disabled.
|
||||||
|
- [x] Tests exist and were executed (targeted at minimum).
|
||||||
|
|
||||||
|
## Feature 047 Functional Coverage
|
||||||
|
|
||||||
|
- [x] FR-001 Foundation types MVP are synced when include_foundations=true (roleScopeTag, assignmentFilter, notificationMessageTemplate).
|
||||||
|
- [x] FR-002 include_foundations=false produces no foundation node sync side effects.
|
||||||
|
- [x] FR-003 Foundation nodes stored as InventoryItems with stable identity (tenant_id + policy_type + external_id).
|
||||||
|
- [x] FR-004 Inventory Coverage UI shows Policies + Foundations.
|
||||||
|
- [x] FR-005 Inventory Items UI can filter/browse foundations.
|
||||||
|
|
||||||
|
## Test Gates
|
||||||
|
|
||||||
|
- [x] T020/T021: include_foundations on/off behavior is covered by feature tests.
|
||||||
|
- [x] T023: foundation meta_jsonb sanitized invariant (no payload dump).
|
||||||
|
- [x] T024: run counts include/exclude foundations (deterministic setup).
|
||||||
|
- [x] Pint run (T020) and targeted tests run (T021).
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://tenantpilot.local/contracts/inventory-coverage.schema.json",
|
||||||
|
"title": "Inventory Coverage View Model",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"supportedPolicyTypes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/typeMeta" }
|
||||||
|
},
|
||||||
|
"foundationTypes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/typeMeta" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["supportedPolicyTypes", "foundationTypes"],
|
||||||
|
"$defs": {
|
||||||
|
"typeMeta": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"type": { "type": "string" },
|
||||||
|
"label": { "type": "string" },
|
||||||
|
"category": { "type": "string" },
|
||||||
|
"platform": { "type": "string" },
|
||||||
|
"restore": { "type": "string" },
|
||||||
|
"risk": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["type", "label", "category"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://tenantpilot.local/contracts/inventory-selection.schema.json",
|
||||||
|
"title": "Inventory Selection Payload",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"policy_types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"include_foundations": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "When true, the sync run includes all configured foundation types in addition to selected policy types."
|
||||||
|
},
|
||||||
|
"include_dependencies": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["policy_types", "categories", "include_foundations", "include_dependencies"]
|
||||||
|
}
|
||||||
39
specs/047-inventory-foundations-nodes/data-model.md
Normal file
39
specs/047-inventory-foundations-nodes/data-model.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Phase 1 Data Model: Foundations in Inventory (047)
|
||||||
|
|
||||||
|
## Existing Entities
|
||||||
|
|
||||||
|
### `InventoryItem`
|
||||||
|
Represents the last observed state for a tenant-scoped Intune object.
|
||||||
|
|
||||||
|
**Key fields (existing schema)**
|
||||||
|
- `tenant_id` (FK)
|
||||||
|
- `policy_type` (string)
|
||||||
|
- `external_id` (string)
|
||||||
|
- `display_name` (nullable string)
|
||||||
|
- `category` (nullable string)
|
||||||
|
- `platform` (nullable string)
|
||||||
|
- `meta_jsonb` (jsonb, safe/whitelisted)
|
||||||
|
- `last_seen_at` (timestamp)
|
||||||
|
- `last_seen_run_id` (FK to `InventorySyncRun`)
|
||||||
|
|
||||||
|
## New Semantics (no schema change)
|
||||||
|
|
||||||
|
### Foundation Nodes
|
||||||
|
A foundation node is an `InventoryItem` where:
|
||||||
|
- `policy_type` is one of `config('tenantpilot.foundation_types')[*].type`
|
||||||
|
- `category` is set from the foundation type config (MVP expects `Foundations`)
|
||||||
|
|
||||||
|
**In-scope foundation types (MVP)**
|
||||||
|
- `roleScopeTag` (Scope Tag)
|
||||||
|
- `assignmentFilter` (Assignment Filter)
|
||||||
|
- `notificationMessageTemplate` (Notification Message Template)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- Tenant isolation: all foundation nodes are strictly tenant-scoped.
|
||||||
|
- Dependencies (Spec 042) can reference foundations via edges, and Spec 042.2 can resolve their display names locally by matching `(tenant_id, policy_type, external_id)`.
|
||||||
|
|
||||||
|
## Validation / Invariants
|
||||||
|
|
||||||
|
- Idempotency: repeated sync runs update `last_seen_at` and `last_seen_run_id` deterministically without creating duplicates.
|
||||||
|
- Data minimization: foundation sync must not store non-whitelisted payload data in `meta_jsonb`.
|
||||||
128
specs/047-inventory-foundations-nodes/plan.md
Normal file
128
specs/047-inventory-foundations-nodes/plan.md
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# Implementation Plan: Foundations in Inventory (047)
|
||||||
|
|
||||||
|
**Branch**: `feat/047-inventory-foundations-nodes` | **Date**: 2026-01-10 | **Spec**: `specs/047-inventory-foundations-nodes/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/047-inventory-foundations-nodes/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When `include_foundations=true`, inventory sync includes all configured foundation types (`roleScopeTag`, `assignmentFilter`, `notificationMessageTemplate`) as `InventoryItem` records for the tenant. Inventory Coverage and Inventory Items UI surface foundations alongside policies, enabling Spec 042.2 dependency target name resolution from the local DB (no UI Graph lookups).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||||
|
for the project. The structure here is presented in advisory capacity to guide
|
||||||
|
the iteration process.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.x (Laravel 12)
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||||
|
**Storage**: PostgreSQL (JSONB for `InventoryItem.meta_jsonb`)
|
||||||
|
**Testing**: Pest v4 + PHPUnit; formatting via Pint
|
||||||
|
**Target Platform**: Web admin app (Filament) + queued jobs (Sail-first locally)
|
||||||
|
**Project Type**: Web application
|
||||||
|
**Performance Goals**: No new explicit perf goals; foundations are small cardinality and must not introduce N+1 or full-table loads.
|
||||||
|
**Constraints**: Must preserve inventory sync idempotency, locks, and run observability; no UI-time Graph calls for name resolution.
|
||||||
|
**Scale/Scope**: Tenant-scoped inventory; foundations expected to be small compared to policies.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||||
|
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||||
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
|
- Automation: queued/scheduled ops are locked, idempotent, observable; handle 429/503 with backoff+jitter
|
||||||
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
|
||||||
|
**Result**: PASS (no violations).
|
||||||
|
|
||||||
|
- Inventory-first: foundations become `InventoryItem` “last observed” state.
|
||||||
|
- Read/write separation: sync remains read-only (Graph reads only).
|
||||||
|
- Graph contract path: foundation types are already represented in `config/graph_contracts.php` and accessed via `GraphClientInterface`.
|
||||||
|
- Tenant isolation: all upserts keyed by `(tenant_id, policy_type, external_id)`.
|
||||||
|
- Data minimization: still uses `InventoryMetaSanitizer` to store only safe subset.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/[###-feature]/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||||
|
for this feature. Delete unused options and expand the chosen structure with
|
||||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||||
|
not include Option labels.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── InventoryCoverage.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── InventoryItemResource.php
|
||||||
|
└── Services/
|
||||||
|
└── Inventory/
|
||||||
|
└── InventorySyncService.php
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── tenantpilot.php
|
||||||
|
└── graph_contracts.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
└── pages/
|
||||||
|
└── inventory-coverage.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Filament/
|
||||||
|
│ └── InventoryPagesTest.php
|
||||||
|
└── Inventory/
|
||||||
|
└── InventorySyncServiceTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Web application (Laravel + Filament). No new directories introduced.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
N/A (no constitution violations).
|
||||||
|
|
||||||
|
## Phase 0: Research Output
|
||||||
|
|
||||||
|
- Generated: `specs/047-inventory-foundations-nodes/research.md`
|
||||||
|
- Key outcomes:
|
||||||
|
- Foundations are synced via the existing inventory sync flow.
|
||||||
|
- Graph contracts already cover the three foundation types.
|
||||||
|
|
||||||
|
## Phase 1: Design & Contracts Output
|
||||||
|
|
||||||
|
- Data model: `specs/047-inventory-foundations-nodes/data-model.md`
|
||||||
|
- Contracts:
|
||||||
|
- `specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json`
|
||||||
|
- `specs/047-inventory-foundations-nodes/contracts/inventory-coverage.schema.json`
|
||||||
|
- Quickstart: `specs/047-inventory-foundations-nodes/quickstart.md`
|
||||||
|
|
||||||
|
## Phase 2: Implementation Checklist (high level)
|
||||||
|
|
||||||
|
- Inventory sync respects `include_foundations` selection semantics.
|
||||||
|
- Foundations appear in Inventory Items list (filterable) and Coverage page.
|
||||||
|
- Tests cover tenant isolation + include_foundations on/off behavior.
|
||||||
32
specs/047-inventory-foundations-nodes/quickstart.md
Normal file
32
specs/047-inventory-foundations-nodes/quickstart.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Quickstart: Foundations in Inventory (047)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Verify that foundations are synced as `InventoryItem` records when `include_foundations=true`, and are not synced when `include_foundations=false`.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
- Local dev via Sail is running.
|
||||||
|
- You have at least one tenant configured with valid Graph credentials.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Run an inventory sync with foundations enabled
|
||||||
|
- Use the Inventory UI sync button (Spec 046) with `include_foundations=true`, or run the relevant flow in your preferred admin path.
|
||||||
|
|
||||||
|
2. Confirm foundation nodes exist
|
||||||
|
- In Inventory Items, filter `Category = Foundations`.
|
||||||
|
- Confirm you can find:
|
||||||
|
- Scope tags (`roleScopeTag`)
|
||||||
|
- Assignment filters (`assignmentFilter`)
|
||||||
|
- Notification templates (`notificationMessageTemplate`)
|
||||||
|
|
||||||
|
3. Confirm coverage shows both matrices
|
||||||
|
- Open Inventory → Coverage.
|
||||||
|
- Confirm both headings are present: “Policies” and “Foundations”.
|
||||||
|
|
||||||
|
4. Confirm name resolution (Spec 042.2)
|
||||||
|
- Open a policy that references scope tags or assignment filters.
|
||||||
|
- In the Dependencies section, confirm targets render with resolved display names sourced from local inventory.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- If foundation types do not appear, confirm they exist in `config('tenantpilot.foundation_types')` and have Graph contracts in `config/graph_contracts.php`.
|
||||||
|
- If UI doesn’t reflect changes, rebuild assets if needed (`npm run dev` / `composer run dev`) and confirm cache is cleared.
|
||||||
36
specs/047-inventory-foundations-nodes/research.md
Normal file
36
specs/047-inventory-foundations-nodes/research.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Phase 0 Research: Foundations in Inventory (047)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This feature makes specific Intune “foundation” objects first-class `InventoryItem` records so the Dependencies UI (Spec 042.2) can resolve names from the local DB (no UI-time Graph calls).
|
||||||
|
|
||||||
|
Foundation types in scope are already configured in `config('tenantpilot.foundation_types')`:
|
||||||
|
- `roleScopeTag`
|
||||||
|
- `assignmentFilter`
|
||||||
|
- `notificationMessageTemplate`
|
||||||
|
|
||||||
|
Graph contract registry entries exist for all three types in `config/graph_contracts.php` under `types.*.resource`.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision: Reuse existing Inventory sync flow (`InventorySyncService`) to sync foundation types
|
||||||
|
- **Rationale**: Keeps inventory the “last observed” source of truth, preserves existing locking/idempotency/run observability, and avoids duplicating Graph pagination/retry behavior.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Separate “Foundations sync” job/service: rejected because it would duplicate run bookkeeping and selection hashing semantics.
|
||||||
|
- UI-time resolution via Graph: rejected (explicitly out of scope; violates Spec 042.2 / FR-006).
|
||||||
|
|
||||||
|
### Decision: Treat foundation types as regular `policy_type` values with a stable `category=Foundations`
|
||||||
|
- **Rationale**: No schema change; Inventory UI and filters already understand `category` and `policy_type`.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- New table for foundations: rejected (adds joins and complexity; not needed for MVP).
|
||||||
|
|
||||||
|
### Decision: Coverage UI presents two matrices (Policies + Foundations)
|
||||||
|
- **Rationale**: Makes support surface explicit and avoids mixing foundational types into policy-type rows.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Single combined coverage table with category filter: rejected for MVP clarity.
|
||||||
|
|
||||||
|
## Clarifications Resolved
|
||||||
|
|
||||||
|
- **Selection semantics**: `include_foundations=true` means “always sync all configured foundation types in addition to selected policy types”. `include_foundations=false` means “never sync foundation types (even if explicitly present in `policy_types`)”.
|
||||||
|
- **Graph contracts**: Foundation types must be represented in `config/graph_contracts.php` so they are handled via the single contract path.
|
||||||
|
- **Data minimization**: Only safe, whitelisted `meta_jsonb` is stored (no raw payload dump).
|
||||||
143
specs/047-inventory-foundations-nodes/spec.md
Normal file
143
specs/047-inventory-foundations-nodes/spec.md
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# Feature Specification: Foundations in Inventory (047)
|
||||||
|
|
||||||
|
**Feature Branch**: `feat/047-inventory-foundations-nodes`
|
||||||
|
**Created**: 2026-01-10
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Make foundational Intune objects (Scope Tags, Assignment Filters, Notification Templates) first-class Inventory nodes so:
|
||||||
|
- Dependency name resolution (Spec 042.2) can resolve display names locally
|
||||||
|
- Inventory coverage can communicate both **Policies** and **Foundations**
|
||||||
|
- Sync behavior matches selection flags (`include_foundations=true`)
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-10
|
||||||
|
|
||||||
|
- Q: How should NFR-002 (Data minimization) be defined/tested? → A: Define it as `meta_jsonb == InventoryMetaSanitizer::sanitize(meta_jsonb)` and `json_encode(meta_jsonb)` must not contain `Bearer `.
|
||||||
|
- Q: Should “no UI Graph calls” be enforced by an automated test guard? → A: Yes — add a test that fails if any UI rendering/resolution path calls `GraphClientInterface`.
|
||||||
|
- Q: What should happen if a foundation object is not returned by Graph in a later run? → A: Do not delete InventoryItem rows; treat Inventory as “last observed” and let entries become stale (no `last_seen_*` update).
|
||||||
|
- Q: Should `include_foundations=false` hide/purge foundations in the UI? → A: No — it only controls what this run syncs and counts; foundations remain visible if they exist and may become stale via `last_seen_*`.
|
||||||
|
- Q: How should unresolved dependency targets be displayed (without Graph calls)? → A: Show `Unresolved (<id>)` so missing foundations are visible for debugging.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope (MVP)
|
||||||
|
- Sync foundational object types as InventoryItems when `include_foundations=true`
|
||||||
|
- Show foundations in Inventory UI (items list + coverage)
|
||||||
|
- Enable local resolution in 042.2 (no additional Graph calls from the UI)
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
- Entra group inventory / group name resolution
|
||||||
|
- Additional foundation types beyond the initial list (can be extended later)
|
||||||
|
- Any Intune write paths (create/update/delete)
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
- Tenant Admin (primary)
|
||||||
|
- MSP Operator (read-only cross-tenant later; not required here)
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
- **Policy Nodes**: InventoryItems whose `policy_type` is in `tenantpilot.supported_policy_types`
|
||||||
|
- **Foundation Nodes**: InventoryItems whose `policy_type` is in `tenantpilot.foundation_types`
|
||||||
|
- **Edges**: Dependency relationships stored in `inventory_links` (Spec 042)
|
||||||
|
|
||||||
|
## User Scenarios & Testing
|
||||||
|
|
||||||
|
### Scenario 1: Sync policies + foundations
|
||||||
|
Given I start an inventory sync with `include_foundations=true`
|
||||||
|
When the sync completes successfully
|
||||||
|
Then foundation nodes (scope tags, assignment filters, templates) exist as InventoryItems for the tenant.
|
||||||
|
|
||||||
|
### Scenario 2: Resolve dependency names
|
||||||
|
Given an InventoryItem has dependencies referencing scope tags/assignment filters
|
||||||
|
When I view the item’s dependencies
|
||||||
|
Then the UI shows the resolved display names (local DB) instead of unresolved targets.
|
||||||
|
If a dependency target cannot be resolved locally, the UI MUST display `Unresolved (<id>)` (no Graph calls).
|
||||||
|
|
||||||
|
### Scenario 3: Inventory browsing
|
||||||
|
Given Inventory Items contain both policies and foundations
|
||||||
|
When I filter inventory to “Foundations”
|
||||||
|
Then I only see foundation nodes (and can search by name).
|
||||||
|
|
||||||
|
### Scenario 4: Coverage communication
|
||||||
|
When I open Inventory Coverage
|
||||||
|
Then I can view both “Policies” and “Foundations” support matrices.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### FR-001 Foundations types (MVP)
|
||||||
|
System MUST support syncing the following foundation policy_types as InventoryItems:
|
||||||
|
- `roleScopeTag` (Scope Tags)
|
||||||
|
- `assignmentFilter` (Assignment Filters)
|
||||||
|
- `notificationMessageTemplate` (Notification Message Templates)
|
||||||
|
|
||||||
|
Source of truth: `config('tenantpilot.foundation_types')`.
|
||||||
|
|
||||||
|
### FR-002 Selection behavior
|
||||||
|
If `include_foundations=true`, an inventory sync run MUST:
|
||||||
|
- sync selected policy types
|
||||||
|
- AND sync all foundation types from `tenantpilot.foundation_types` for the tenant
|
||||||
|
|
||||||
|
If `include_foundations=false`, foundation types MUST NOT be synced as inventory items.
|
||||||
|
|
||||||
|
`include_foundations` only controls what the run observes/upserts (and therefore run counts); it MUST NOT purge existing foundation InventoryItems or “magically” hide them in the UI.
|
||||||
|
|
||||||
|
### FR-003 InventoryItems shape
|
||||||
|
Foundation nodes MUST be stored as InventoryItems using the existing schema:
|
||||||
|
- `tenant_id`, `policy_type`, `external_id`, `display_name`, `category`, `platform`, `meta_jsonb`, `last_seen_at`, `last_seen_run_id`
|
||||||
|
|
||||||
|
Foundation nodes MUST be stored as InventoryItems and MUST have:
|
||||||
|
- `policy_type` set to the foundation type key (e.g. `roleScopeTag`, `assignmentFilter`, `notificationMessageTemplate`)
|
||||||
|
- `category` set to the literal string `Foundations` (used for UI filtering/presets)
|
||||||
|
|
||||||
|
### FR-004 Inventory Coverage UI
|
||||||
|
Coverage page MUST present:
|
||||||
|
- “Policies” table (existing behavior)
|
||||||
|
- “Foundations” table (new; derived from `tenantpilot.foundation_types`)
|
||||||
|
|
||||||
|
### FR-005 Inventory Items UI
|
||||||
|
Inventory Items list MUST allow:
|
||||||
|
- filtering to Foundations (e.g., Category = Foundations)
|
||||||
|
- searching by display name
|
||||||
|
- viewing details (existing view)
|
||||||
|
|
||||||
|
### FR-006 No extra Graph calls in UI
|
||||||
|
The UI MUST NOT perform Graph lookups for foundation name resolution. Resolution MUST come from local InventoryItems.
|
||||||
|
|
||||||
|
This MUST be enforced by an automated test that fails if any UI rendering/resolution path calls `GraphClientInterface`.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
### NFR-001 Tenant isolation
|
||||||
|
All reads/writes MUST be tenant-scoped and covered by tests.
|
||||||
|
|
||||||
|
### NFR-002 Data minimization
|
||||||
|
Foundation sync MUST store only a safe subset of metadata consistent with Inventory rules:
|
||||||
|
- For any stored InventoryItem, `meta_jsonb` MUST equal `InventoryMetaSanitizer::sanitize(meta_jsonb)`.
|
||||||
|
- `json_encode(meta_jsonb)` MUST NOT contain `Bearer `.
|
||||||
|
|
||||||
|
### NFR-003 Idempotency
|
||||||
|
Re-running foundation sync MUST be idempotent (no duplicates) and update `last_seen_at`/`last_seen_run_id` deterministically.
|
||||||
|
|
||||||
|
The sync MUST NOT delete InventoryItem rows when objects are not observed in a run; absence is treated as “not observed” (e.g., permission/scope/transient failure) and becomes stale via `last_seen_at`/run evaluation.
|
||||||
|
|
||||||
|
### NFR-004 Observability
|
||||||
|
Sync run record MUST be accurate:
|
||||||
|
- counts include foundations when `include_foundations=true`
|
||||||
|
- warnings/errors are persisted on the run record as per Inventory conventions
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- SC1: After a foundations-enabled sync, dependencies for scope_tag/assignment_filter render as resolved for the majority of items that reference them.
|
||||||
|
- SC2: Inventory Coverage clearly communicates what is supported for “Policies” vs “Foundations”.
|
||||||
|
- SC3: No new permissions beyond existing foundation read scopes are required for this feature.
|
||||||
|
|
||||||
|
## Related Specs
|
||||||
|
|
||||||
|
- Core Inventory: `specs/040-inventory-core/spec.md`
|
||||||
|
- Inventory UI: `specs/041-inventory-ui/spec.md`
|
||||||
|
- Dependencies Graph: `specs/042-inventory-dependencies-graph/spec.md`
|
||||||
|
- Inventory Sync Button: `specs/046-inventory-sync-button/spec.md`
|
||||||
133
specs/047-inventory-foundations-nodes/tasks.md
Normal file
133
specs/047-inventory-foundations-nodes/tasks.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Tasks: Foundations in Inventory (047)
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/047-inventory-foundations-nodes/`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) because this feature changes runtime behavior.
|
||||||
|
|
||||||
|
## User Stories (Prioritized)
|
||||||
|
|
||||||
|
- **User Story 1 (P1) — Sync policies + foundations**: When `include_foundations=true`, foundations are synced as tenant-scoped `InventoryItem` rows; when false, they are not synced.
|
||||||
|
- **User Story 2 (P2) — Inventory browsing**: Inventory Items list can be filtered to “Foundations” and searched by name.
|
||||||
|
- **User Story 3 (P2) — Coverage communication**: Coverage page presents separate “Policies” and “Foundations” matrices.
|
||||||
|
- **User Story 4 (P3) — Resolve dependency names**: Dependencies UI resolves foundation names via local `InventoryItem` rows only (no UI Graph calls).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
- [ ] T001 Confirm foundation types are configured in config/tenantpilot.php (`foundation_types`) and foundations are categorized as "Foundations" in Inventory
|
||||||
|
- [ ] T002 Confirm Graph contract registry includes resources for `assignmentFilter`, `roleScopeTag`, `notificationMessageTemplate` in config/graph_contracts.php
|
||||||
|
- [ ] T003 [P] Confirm Inventory selection payload schema includes `include_foundations` (specs/047-inventory-foundations-nodes/contracts/inventory-selection.schema.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
- [ ] T004 Ensure inventory sync selection normalization retains include_foundations semantics in app/Services/Inventory/InventorySelectionHasher.php
|
||||||
|
- [ ] T005 Ensure inventory sync run observability/count fields remain accurate when adding foundations in app/Services/Inventory/InventorySyncService.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Sync policies + foundations (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Foundation objects exist as `InventoryItem` rows when foundations are included.
|
||||||
|
|
||||||
|
**Independent Test Criteria**:
|
||||||
|
- Run sync with `include_foundations=true` and see foundation `InventoryItem` rows for the tenant.
|
||||||
|
- Run sync with `include_foundations=false` and see no foundation `InventoryItem` rows (even if a foundation type appears in `policy_types`).
|
||||||
|
|
||||||
|
- [ ] T006 [US1] Implement foundation-type inclusion/exclusion based on `include_foundations` in app/Services/Inventory/InventorySyncService.php
|
||||||
|
- [ ] T007 [US1] Ensure type metadata lookup merges supported policy types + foundation types for sync category/platform assignment in app/Services/Inventory/InventorySyncService.php
|
||||||
|
- [ ] T008 [P] [US1] Add Pest test for include_foundations=true foundation upserts in tests/Feature/Inventory/InventorySyncServiceTest.php
|
||||||
|
- [ ] T009 [P] [US1] Add Pest test for include_foundations=false excludes foundations even when selected in tests/Feature/Inventory/InventorySyncServiceTest.php
|
||||||
|
- [ ] T010 [US1] Verify idempotency: repeated sync updates last_seen_* without duplicate rows for foundation types in tests/Feature/Inventory/InventorySyncServiceTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Inventory browsing (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Inventory list can filter to foundations and show correct labels.
|
||||||
|
|
||||||
|
**Independent Test Criteria**:
|
||||||
|
- Inventory Items table offers Category filter values including `Foundations`.
|
||||||
|
- Filtering Category=Foundations returns only foundation items.
|
||||||
|
|
||||||
|
- [ ] T011 [US2] Update type label/category metadata resolution to include foundations in app/Filament/Resources/InventoryItemResource.php
|
||||||
|
- [ ] T012 [US2] Update table filter option lists to include foundation categories/types in app/Filament/Resources/InventoryItemResource.php
|
||||||
|
- [ ] T013 [P] [US2] Add Pest test asserting Inventory Items list page loads and includes Foundations category filter option (or equivalent rendered text) in tests/Feature/Filament/InventoryPagesTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Coverage communication (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Coverage clearly separates Policies vs Foundations.
|
||||||
|
|
||||||
|
**Independent Test Criteria**:
|
||||||
|
- Coverage page renders headings “Policies” and “Foundations”.
|
||||||
|
- Foundations table rows are derived from `config('tenantpilot.foundation_types')`.
|
||||||
|
|
||||||
|
- [ ] T014 [US3] Update Coverage page view-model to expose supported policy types + foundation types in app/Filament/Pages/InventoryCoverage.php
|
||||||
|
- [ ] T015 [US3] Update Coverage Blade view to render two tables in resources/views/filament/pages/inventory-coverage.blade.php
|
||||||
|
- [ ] T016 [P] [US3] Add/adjust Pest test assertions for both headings in tests/Feature/Filament/InventoryPagesTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 — Resolve dependency names (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Dependencies UI shows resolved names for foundations using local DB inventory items.
|
||||||
|
|
||||||
|
**Independent Test Criteria**:
|
||||||
|
- Given an edge referencing a scope tag or assignment filter, the dependencies UI shows the resolved display name when a matching foundation `InventoryItem` exists.
|
||||||
|
- UI performs no Graph calls for resolution (DB-only resolver path).
|
||||||
|
|
||||||
|
- [ ] T017 [US4] Ensure dependency name resolution uses DB-only resolver (DependencyTargetResolver + foundation mapping) and does not call Graph client during rendering
|
||||||
|
- [ ] T018 [P] [US4] Add/adjust resolver unit test for foundation resolution via InventoryItem rows in tests/Unit/DependencyTargetResolverTest.php
|
||||||
|
- [ ] T019 [P] [US4] Add/adjust feature test validating dependencies view renders resolved foundation names (tenant-scoped) in tests/Feature/InventoryItemDependenciesTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [x] T020 [P] Run formatting on changed files with ./vendor/bin/pint --dirty
|
||||||
|
- [x] T021 Run targeted tests for this feature with ./vendor/bin/sail test tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Filament/InventoryPagesTest.php
|
||||||
|
- [ ] T022 [P] Validate manual quickstart steps in specs/047-inventory-foundations-nodes/quickstart.md
|
||||||
|
- [x] T023 [P] [US1] Add feature test: foundation InventoryItem meta_jsonb is sanitized (`stored == sanitizer->sanitize(stored)`) after sync (no payload dump) in tests/Feature/Inventory/InventorySyncServiceTest.php
|
||||||
|
- [x] T024 [P] [US1] Add feature test: InventorySyncRun observed/upserted counts include foundations when enabled (tenant A) and exclude them when disabled (tenant B) (deterministic) in tests/Feature/Inventory/InventorySyncServiceTest.php
|
||||||
|
- [ ] T025 [P] [US4] Verify task references match actual resolver classes used (DependencyTargetResolver + mapping); adjust task text only (no behavior change)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)** blocks all other stories (foundations must exist before they can be browsed/resolved).
|
||||||
|
- **US2 (P2)** depends on US1 (needs foundation data to browse meaningfully).
|
||||||
|
- **US3 (P2)** is config-driven but depends on US1 for end-to-end verification.
|
||||||
|
- **US4 (P3)** depends on US1 (needs foundation inventory items to resolve names).
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- MVP = **Phase 3 (US1)** + **Phase 7 (T020–T021)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### Within US1
|
||||||
|
|
||||||
|
- Run in parallel:
|
||||||
|
- T008 (include_foundations=true test) + T009 (include_foundations=false test)
|
||||||
|
- Then implement T006–T007 and validate against T010
|
||||||
|
|
||||||
|
### Across Stories (after US1 complete)
|
||||||
|
|
||||||
|
- US2 UI tasks (T011–T013) can proceed in parallel with US3 coverage tasks (T014–T016).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format Validation
|
||||||
|
|
||||||
|
- Every task line starts with `- [ ]` and includes a sequential TaskID (T001…)
|
||||||
|
- Story phases use `[US1]`…`[US4]` labels; Setup/Foundational/Polish have no story label
|
||||||
|
- Tasks marked `[P]` are parallelizable (different files / no blocking dependency)
|
||||||
@ -22,5 +22,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Coverage');
|
->assertSee('Coverage')
|
||||||
|
->assertSee('Policies')
|
||||||
|
->assertSee('Foundations');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -101,6 +101,177 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
expect($items->first()->last_seen_run_id)->toBe($runB->id);
|
expect($items->first()->last_seen_run_id)->toBe($runB->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inventory sync includes foundation types when include_foundations is true', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
||||||
|
'deviceConfiguration' => [],
|
||||||
|
'roleScopeTag' => [
|
||||||
|
['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'],
|
||||||
|
],
|
||||||
|
'assignmentFilter' => [
|
||||||
|
['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'],
|
||||||
|
],
|
||||||
|
'notificationMessageTemplate' => [
|
||||||
|
['id' => 'tmpl-1', 'displayName' => 'Template 1', '@odata.type' => '#microsoft.graph.notificationMessageTemplate'],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$service = app(InventorySyncService::class);
|
||||||
|
|
||||||
|
$run = $service->syncNow($tenant, [
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'categories' => [],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'include_dependencies' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($run->status)->toBe('success');
|
||||||
|
|
||||||
|
expect(\App\Models\InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('policy_type', 'roleScopeTag')
|
||||||
|
->where('external_id', 'tag-1')
|
||||||
|
->where('category', 'Foundations')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
|
expect(\App\Models\InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('policy_type', 'assignmentFilter')
|
||||||
|
->where('external_id', 'filter-1')
|
||||||
|
->where('category', 'Foundations')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
|
expect(\App\Models\InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('policy_type', 'notificationMessageTemplate')
|
||||||
|
->where('external_id', 'tmpl-1')
|
||||||
|
->where('category', 'Foundations')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inventory sync does not sync foundation types when include_foundations is false', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
||||||
|
'roleScopeTag' => [
|
||||||
|
['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$service = app(InventorySyncService::class);
|
||||||
|
|
||||||
|
$run = $service->syncNow($tenant, [
|
||||||
|
'policy_types' => ['roleScopeTag'],
|
||||||
|
'categories' => [],
|
||||||
|
'include_foundations' => false,
|
||||||
|
'include_dependencies' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($run->status)->toBe('success');
|
||||||
|
|
||||||
|
expect(\App\Models\InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('policy_type', 'roleScopeTag')
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foundation inventory items store sanitized meta_jsonb after sync (no payload dump)', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
||||||
|
'deviceConfiguration' => [],
|
||||||
|
'roleScopeTag' => [
|
||||||
|
[
|
||||||
|
'id' => 'tag-1',
|
||||||
|
'displayName' => 'Scope Tag 1',
|
||||||
|
'@odata.type' => '#microsoft.graph.roleScopeTag',
|
||||||
|
'veryLargePayload' => str_repeat('x', 10_000),
|
||||||
|
'client_secret' => 'should-not-end-up-anywhere',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$service = app(InventorySyncService::class);
|
||||||
|
|
||||||
|
$run = $service->syncNow($tenant, [
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'categories' => [],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'include_dependencies' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($run->status)->toBe('success');
|
||||||
|
|
||||||
|
$foundationItem = \App\Models\InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('policy_type', 'roleScopeTag')
|
||||||
|
->where('external_id', 'tag-1')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($foundationItem)->not->toBeNull();
|
||||||
|
|
||||||
|
$stored = is_array($foundationItem->meta_jsonb) ? $foundationItem->meta_jsonb : [];
|
||||||
|
$sanitizer = app(InventoryMetaSanitizer::class);
|
||||||
|
|
||||||
|
expect($stored)->toBe($sanitizer->sanitize($stored));
|
||||||
|
expect(json_encode($stored))->not->toContain('Bearer ');
|
||||||
|
expect(json_encode($stored))->not->toContain('should-not-end-up-anywhere');
|
||||||
|
expect(json_encode($stored))->not->toContain(str_repeat('x', 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inventory sync run counts include foundations when enabled and exclude them when disabled (deterministic)', function () {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|
||||||
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
||||||
|
'deviceConfiguration' => [
|
||||||
|
[
|
||||||
|
'id' => 'pol-1',
|
||||||
|
'displayName' => 'Policy 1',
|
||||||
|
'@odata.type' => '#microsoft.graph.deviceConfiguration',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'roleScopeTag' => [
|
||||||
|
['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'],
|
||||||
|
],
|
||||||
|
'assignmentFilter' => [
|
||||||
|
['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'],
|
||||||
|
],
|
||||||
|
'notificationMessageTemplate' => [
|
||||||
|
[
|
||||||
|
'id' => 'tmpl-1',
|
||||||
|
'displayName' => 'Template 1',
|
||||||
|
'@odata.type' => '#microsoft.graph.notificationMessageTemplate',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$service = app(InventorySyncService::class);
|
||||||
|
|
||||||
|
$runA = $service->syncNow($tenantA, [
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'categories' => [],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'include_dependencies' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($runA->status)->toBe('success');
|
||||||
|
expect($runA->items_observed_count)->toBe(4);
|
||||||
|
expect($runA->items_upserted_count)->toBe(4);
|
||||||
|
|
||||||
|
$runB = $service->syncNow($tenantB, [
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'categories' => [],
|
||||||
|
'include_foundations' => false,
|
||||||
|
'include_dependencies' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($runB->status)->toBe('success');
|
||||||
|
expect($runB->items_observed_count)->toBe(1);
|
||||||
|
expect($runB->items_upserted_count)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () {
|
test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\InventoryLink;
|
use App\Models\InventoryLink;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
||||||
@ -246,6 +247,51 @@
|
|||||||
->assertSee('Group (external): 428f24…');
|
->assertSee('Group (external): 428f24…');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$graph = \Mockery::mock(GraphClientInterface::class);
|
||||||
|
$graph->shouldNotReceive('listPolicies');
|
||||||
|
$graph->shouldNotReceive('getPolicy');
|
||||||
|
$graph->shouldNotReceive('getOrganization');
|
||||||
|
$graph->shouldNotReceive('applyPolicy');
|
||||||
|
$graph->shouldNotReceive('getServicePrincipalPermissions');
|
||||||
|
$graph->shouldNotReceive('request');
|
||||||
|
app()->instance(GraphClientInterface::class, $graph);
|
||||||
|
|
||||||
|
/** @var InventoryItem $item */
|
||||||
|
$item = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'external_id' => (string) Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scopeTag = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'policy_type' => 'roleScopeTag',
|
||||||
|
'external_id' => '6',
|
||||||
|
'display_name' => 'Finance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryLink::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'source_type' => 'inventory_item',
|
||||||
|
'source_id' => $item->external_id,
|
||||||
|
'target_type' => 'foundation_object',
|
||||||
|
'target_id' => $scopeTag->external_id,
|
||||||
|
'relationship_type' => 'scoped_by',
|
||||||
|
'metadata' => [
|
||||||
|
'last_known_name' => null,
|
||||||
|
'foundation_type' => 'scope_tag',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
||||||
|
$this->get($url)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Scope Tag: Finance');
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks guest access to inventory item dependencies view', function () {
|
it('blocks guest access to inventory item dependencies view', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user