diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 0000000..73b75a8 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,30 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'meta_jsonb' => 'array', + 'last_seen_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function lastSeenRun(): BelongsTo + { + return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id'); + } +} diff --git a/app/Models/InventorySyncRun.php b/app/Models/InventorySyncRun.php new file mode 100644 index 0000000..9250153 --- /dev/null +++ b/app/Models/InventorySyncRun.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + public const STATUS_RUNNING = 'running'; + + public const STATUS_SUCCESS = 'success'; + + public const STATUS_PARTIAL = 'partial'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_SKIPPED = 'skipped'; + + protected $guarded = []; + + protected $casts = [ + 'selection_payload' => 'array', + 'had_errors' => 'boolean', + 'error_codes' => 'array', + 'error_context' => 'array', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function scopeCompleted(Builder $query): Builder + { + return $query + ->whereIn('status', [ + self::STATUS_SUCCESS, + self::STATUS_PARTIAL, + self::STATUS_FAILED, + self::STATUS_SKIPPED, + ]) + ->whereNotNull('finished_at'); + } +} diff --git a/app/Services/Inventory/InventoryConcurrencyLimiter.php b/app/Services/Inventory/InventoryConcurrencyLimiter.php new file mode 100644 index 0000000..1dda447 --- /dev/null +++ b/app/Services/Inventory/InventoryConcurrencyLimiter.php @@ -0,0 +1,40 @@ +acquireSlot('inventory_sync:global:slot:', $max); + } + + public function acquireTenantSlot(int $tenantId): ?Lock + { + $max = (int) config('tenantpilot.inventory_sync.concurrency.per_tenant_max', 1); + $max = max(0, $max); + + return $this->acquireSlot("inventory_sync:tenant:{$tenantId}:slot:", $max); + } + + private function acquireSlot(string $prefix, int $max): ?Lock + { + for ($slot = 0; $slot < $max; $slot++) { + $lock = Cache::lock($prefix.$slot, $this->lockTtlSeconds); + + if ($lock->get()) { + return $lock; + } + } + + return null; + } +} diff --git a/app/Services/Inventory/InventoryMetaSanitizer.php b/app/Services/Inventory/InventoryMetaSanitizer.php new file mode 100644 index 0000000..29e1bb3 --- /dev/null +++ b/app/Services/Inventory/InventoryMetaSanitizer.php @@ -0,0 +1,101 @@ + $meta + * @return array{odata_type?: string, etag?: string|null, scope_tag_ids?: list, assignment_target_count?: int|null, warnings?: list} + */ + public function sanitize(array $meta): array + { + $sanitized = []; + + $odataType = $meta['odata_type'] ?? null; + if (is_string($odataType) && trim($odataType) !== '') { + $sanitized['odata_type'] = trim($odataType); + } + + $etag = $meta['etag'] ?? null; + if ($etag === null || is_string($etag)) { + $sanitized['etag'] = $etag === null ? null : trim($etag); + } + + $scopeTagIds = $meta['scope_tag_ids'] ?? null; + if (is_array($scopeTagIds)) { + $sanitized['scope_tag_ids'] = $this->stringList($scopeTagIds); + } + + $assignmentTargetCount = $meta['assignment_target_count'] ?? null; + if (is_int($assignmentTargetCount)) { + $sanitized['assignment_target_count'] = $assignmentTargetCount; + } elseif (is_numeric($assignmentTargetCount)) { + $sanitized['assignment_target_count'] = (int) $assignmentTargetCount; + } elseif ($assignmentTargetCount === null) { + $sanitized['assignment_target_count'] = null; + } + + $warnings = $meta['warnings'] ?? null; + if (is_array($warnings)) { + $sanitized['warnings'] = $this->boundedStringList($warnings, 25, 200); + } + + return array_filter( + $sanitized, + static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '' + ); + } + + /** + * @param list $values + * @return list + */ + private function stringList(array $values): array + { + $result = []; + + foreach ($values as $value) { + if (! is_string($value)) { + continue; + } + + $value = trim($value); + if ($value === '') { + continue; + } + + $result[] = $value; + } + + return array_values(array_unique($result)); + } + + /** + * @param list $values + * @return list + */ + private function boundedStringList(array $values, int $maxItems, int $maxLen): array + { + $items = []; + + foreach ($values as $value) { + if (count($items) >= $maxItems) { + break; + } + + if (! is_string($value)) { + continue; + } + + $value = trim($value); + if ($value === '') { + continue; + } + + $items[] = mb_substr($value, 0, $maxLen); + } + + return array_values(array_unique($items)); + } +} diff --git a/app/Services/Inventory/InventoryMissingService.php b/app/Services/Inventory/InventoryMissingService.php new file mode 100644 index 0000000..b1ff4e6 --- /dev/null +++ b/app/Services/Inventory/InventoryMissingService.php @@ -0,0 +1,66 @@ + $selectionPayload + * @return array{latestRun: InventorySyncRun|null, missing: Collection, lowConfidence: bool} + */ + public function missingForSelection(Tenant $tenant, array $selectionPayload): array + { + $normalized = $this->selectionHasher->normalize($selectionPayload); + $normalized['policy_types'] = $this->policyTypeResolver->filterRuntime($normalized['policy_types']); + $selectionHash = $this->selectionHasher->hash($normalized); + + $latestRun = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $selectionHash) + ->whereIn('status', [ + InventorySyncRun::STATUS_SUCCESS, + InventorySyncRun::STATUS_PARTIAL, + InventorySyncRun::STATUS_FAILED, + InventorySyncRun::STATUS_SKIPPED, + ]) + ->orderByDesc('finished_at') + ->orderByDesc('id') + ->first(); + + if (! $latestRun) { + return [ + 'latestRun' => null, + 'missing' => InventoryItem::query()->whereRaw('1 = 0')->get(), + 'lowConfidence' => true, + ]; + } + + $missingQuery = InventoryItem::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('policy_type', $normalized['policy_types']) + ->where(function ($query) use ($latestRun): void { + $query + ->whereNull('last_seen_run_id') + ->orWhere('last_seen_run_id', '!=', $latestRun->getKey()); + }); + + $lowConfidence = $latestRun->status !== InventorySyncRun::STATUS_SUCCESS || (bool) ($latestRun->had_errors ?? false); + + return [ + 'latestRun' => $latestRun, + 'missing' => $missingQuery->get(), + 'lowConfidence' => $lowConfidence, + ]; + } +} diff --git a/app/Services/Inventory/InventorySelectionHasher.php b/app/Services/Inventory/InventorySelectionHasher.php new file mode 100644 index 0000000..3ea249c --- /dev/null +++ b/app/Services/Inventory/InventorySelectionHasher.php @@ -0,0 +1,89 @@ + $selectionPayload + * @return array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} + */ + public function normalize(array $selectionPayload): array + { + $policyTypes = $this->stringList($selectionPayload['policy_types'] ?? []); + sort($policyTypes); + + $categories = $this->stringList($selectionPayload['categories'] ?? []); + sort($categories); + + return [ + 'policy_types' => $policyTypes, + 'categories' => $categories, + 'include_foundations' => (bool) ($selectionPayload['include_foundations'] ?? false), + 'include_dependencies' => (bool) ($selectionPayload['include_dependencies'] ?? false), + ]; + } + + /** + * @param array $selectionPayload + */ + public function canonicalJson(array $selectionPayload): string + { + $normalized = $this->normalize($selectionPayload); + $normalized = $this->ksortRecursive($normalized); + + return (string) json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * @param array $selectionPayload + */ + public function hash(array $selectionPayload): string + { + return hash('sha256', $this->canonicalJson($selectionPayload)); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $item) { + if (! is_string($item)) { + continue; + } + + $item = trim($item); + if ($item === '') { + continue; + } + + $result[] = $item; + } + + return array_values(array_unique($result)); + } + + private function ksortRecursive(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $isList = array_is_list($value); + if (! $isList) { + ksort($value); + } + + foreach ($value as $key => $child) { + $value[$key] = $this->ksortRecursive($child); + } + + return $value; + } +} diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php new file mode 100644 index 0000000..69f8e44 --- /dev/null +++ b/app/Services/Inventory/InventorySyncService.php @@ -0,0 +1,323 @@ + $selectionPayload + */ + public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun + { + $normalizedSelection = $this->selectionHasher->normalize($selectionPayload); + $normalizedSelection['policy_types'] = $this->policyTypeResolver->filterRuntime($normalizedSelection['policy_types']); + $selectionHash = $this->selectionHasher->hash($normalizedSelection); + + $now = CarbonImmutable::now('UTC'); + + $globalSlot = $this->concurrencyLimiter->acquireGlobalSlot(); + if (! $globalSlot instanceof Lock) { + return $this->createSkippedRun( + tenant: $tenant, + selectionHash: $selectionHash, + selectionPayload: $normalizedSelection, + now: $now, + errorCode: 'concurrency_limit_global', + ); + } + + $tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id); + if (! $tenantSlot instanceof Lock) { + $globalSlot->release(); + + return $this->createSkippedRun( + tenant: $tenant, + selectionHash: $selectionHash, + selectionPayload: $normalizedSelection, + now: $now, + errorCode: 'concurrency_limit_tenant', + ); + } + + $selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900); + if (! $selectionLock->get()) { + $tenantSlot->release(); + $globalSlot->release(); + + return $this->createSkippedRun( + tenant: $tenant, + selectionHash: $selectionHash, + selectionPayload: $normalizedSelection, + now: $now, + errorCode: 'lock_contended', + ); + } + + $run = InventorySyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $normalizedSelection, + 'status' => InventorySyncRun::STATUS_RUNNING, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => $now, + 'finished_at' => null, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + + try { + return $this->executeRun($run, $tenant, $normalizedSelection); + } finally { + $selectionLock->release(); + $tenantSlot->release(); + $globalSlot->release(); + } + } + + /** + * @param array{policy_types: list, categories: list, include_foundations: bool, include_dependencies: bool} $normalizedSelection + */ + private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection): InventorySyncRun + { + $observed = 0; + $upserted = 0; + $errors = 0; + $errorCodes = []; + $hadErrors = false; + + try { + $typesConfig = $this->supportedTypeConfigByType(); + + foreach ($normalizedSelection['policy_types'] as $policyType) { + $typeConfig = $typesConfig[$policyType] ?? null; + + if (! is_array($typeConfig)) { + continue; + } + + $response = $this->listPoliciesWithRetry($policyType, [ + 'tenant' => $tenant->tenant_id ?? $tenant->external_id, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $typeConfig['platform'] ?? null, + 'filter' => $typeConfig['filter'] ?? null, + ]); + + if ($response->failed()) { + $hadErrors = true; + $errors++; + $errorCodes[] = $this->mapGraphFailureToErrorCode($response); + + continue; + } + + foreach ($response->data as $policyData) { + if (! is_array($policyData)) { + continue; + } + + $externalId = $policyData['id'] ?? $policyData['external_id'] ?? null; + if (! is_string($externalId) || $externalId === '') { + continue; + } + + $observed++; + + $displayName = $policyData['displayName'] ?? $policyData['name'] ?? null; + $displayName = is_string($displayName) ? $displayName : null; + + $scopeTagIds = $policyData['roleScopeTagIds'] ?? null; + $assignmentTargetCount = null; + $assignments = $policyData['assignments'] ?? null; + if (is_array($assignments)) { + $assignmentTargetCount = count($assignments); + } + + $meta = $this->metaSanitizer->sanitize([ + 'odata_type' => $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null, + 'etag' => $policyData['@odata.etag'] ?? null, + 'scope_tag_ids' => is_array($scopeTagIds) ? $scopeTagIds : null, + 'assignment_target_count' => $assignmentTargetCount, + 'warnings' => [], + ]); + + InventoryItem::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId, + ], + [ + 'display_name' => $displayName, + 'category' => $typeConfig['category'] ?? null, + 'platform' => $typeConfig['platform'] ?? null, + 'meta_jsonb' => $meta, + 'last_seen_at' => now(), + 'last_seen_run_id' => $run->getKey(), + ] + ); + + $upserted++; + } + } + + $status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS; + + $run->update([ + 'status' => $status, + 'had_errors' => $hadErrors, + 'error_codes' => array_values(array_unique($errorCodes)), + 'error_context' => null, + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'errors_count' => $errors, + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return $run->refresh(); + } catch (Throwable $throwable) { + $run->update([ + 'status' => InventorySyncRun::STATUS_FAILED, + 'had_errors' => true, + 'error_codes' => ['unexpected_exception'], + 'error_context' => $this->safeErrorContext($throwable), + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'errors_count' => $errors + 1, + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return $run->refresh(); + } + } + + /** + * @return array> + */ + private function supportedTypeConfigByType(): array + { + /** @var array> $supported */ + $supported = config('tenantpilot.supported_policy_types', []); + + $byType = []; + foreach ($supported as $config) { + $type = $config['type'] ?? null; + if (is_string($type) && $type !== '') { + $byType[$type] = $config; + } + } + + return $byType; + } + + private function selectionLockKey(Tenant $tenant, string $selectionHash): string + { + return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); + } + + /** + * @param array $selectionPayload + */ + private function createSkippedRun( + Tenant $tenant, + string $selectionHash, + array $selectionPayload, + CarbonImmutable $now, + string $errorCode, + ): InventorySyncRun { + return InventorySyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => $selectionHash, + 'selection_payload' => $selectionPayload, + 'status' => InventorySyncRun::STATUS_SKIPPED, + 'had_errors' => true, + 'error_codes' => [$errorCode], + 'error_context' => null, + 'started_at' => $now, + 'finished_at' => $now, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]); + } + + private function mapGraphFailureToErrorCode(GraphResponse $response): string + { + $status = (int) ($response->status ?? 0); + + return match ($status) { + 403 => 'graph_forbidden', + 429 => 'graph_throttled', + 503 => 'graph_transient', + default => 'graph_transient', + }; + } + + private function listPoliciesWithRetry(string $policyType, array $options): GraphResponse + { + $maxAttempts = 3; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $response = $this->graphClient->listPolicies($policyType, $options); + + if (! $response->failed()) { + return $response; + } + + $status = (int) ($response->status ?? 0); + if (! in_array($status, [429, 503], true)) { + return $response; + } + + if ($attempt >= $maxAttempts) { + return $response; + } + + $baseMs = 250 * (2 ** ($attempt - 1)); + $jitterMs = random_int(0, 250); + usleep(($baseMs + $jitterMs) * 1000); + } + + return new GraphResponse(false, [], null, ['error' => ['code' => 'unexpected_exception', 'message' => 'retry loop failed']]); + } + + /** + * @return array + */ + private function safeErrorContext(Throwable $throwable): array + { + $message = $throwable->getMessage(); + + $message = preg_replace('/Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/', 'Bearer [REDACTED]', (string) $message); + $message = mb_substr((string) $message, 0, 500); + + return [ + 'exception_class' => get_class($throwable), + 'message' => $message, + ]; + } +} diff --git a/config/tenantpilot.php b/config/tenantpilot.php index d59d3cf..1a14ee0 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -320,6 +320,13 @@ 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), ], + 'inventory_sync' => [ + 'concurrency' => [ + 'global_max' => (int) env('TENANTPILOT_INVENTORY_SYNC_CONCURRENCY_GLOBAL_MAX', 2), + 'per_tenant_max' => (int) env('TENANTPILOT_INVENTORY_SYNC_CONCURRENCY_PER_TENANT_MAX', 1), + ], + ], + 'display' => [ 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 0000000..6cdffa2 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,38 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => fake()->uuid(), + 'display_name' => fake()->words(3, true), + 'category' => 'Configuration', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10', 'windows']), + 'meta_jsonb' => [ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => null, + 'scope_tag_ids' => [], + 'assignment_target_count' => null, + 'warnings' => [], + ], + 'last_seen_at' => now(), + 'last_seen_run_id' => null, + ]; + } +} diff --git a/database/factories/InventorySyncRunFactory.php b/database/factories/InventorySyncRunFactory.php new file mode 100644 index 0000000..2246f57 --- /dev/null +++ b/database/factories/InventorySyncRunFactory.php @@ -0,0 +1,43 @@ + + */ +class InventorySyncRunFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $selectionPayload = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + return [ + 'tenant_id' => Tenant::factory(), + 'selection_hash' => hash('sha256', (string) json_encode($selectionPayload)), + 'selection_payload' => $selectionPayload, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'had_errors' => false, + 'error_codes' => [], + 'error_context' => null, + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'errors_count' => 0, + ]; + } +} diff --git a/database/migrations/2026_01_07_142719_create_inventory_items_table.php b/database/migrations/2026_01_07_142719_create_inventory_items_table.php new file mode 100644 index 0000000..d36a82b --- /dev/null +++ b/database/migrations/2026_01_07_142719_create_inventory_items_table.php @@ -0,0 +1,19 @@ +id(); + + $table->foreignId('tenant_id')->constrained(); + + $table->string('selection_hash', 64); + $table->jsonb('selection_payload')->nullable(); + + $table->string('status'); + $table->boolean('had_errors')->default(false); + $table->jsonb('error_codes')->nullable(); + $table->jsonb('error_context')->nullable(); + + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + + $table->unsignedInteger('items_observed_count')->default(0); + $table->unsignedInteger('items_upserted_count')->default(0); + $table->unsignedInteger('errors_count')->default(0); + + $table->timestamps(); + + $table->index(['tenant_id', 'selection_hash']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'finished_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_sync_runs'); + } +}; diff --git a/database/migrations/2026_01_07_142720_create_inventory_items_table.php b/database/migrations/2026_01_07_142720_create_inventory_items_table.php new file mode 100644 index 0000000..5946a89 --- /dev/null +++ b/database/migrations/2026_01_07_142720_create_inventory_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->string('policy_type'); + $table->string('external_id'); + $table->string('display_name')->nullable(); + $table->string('category')->nullable(); + $table->string('platform')->nullable(); + $table->jsonb('meta_jsonb')->nullable(); + $table->timestampTz('last_seen_at')->nullable(); + $table->foreignId('last_seen_run_id') + ->nullable() + ->constrained('inventory_sync_runs') + ->nullOnDelete(); + $table->timestamps(); + + $table->unique(['tenant_id', 'policy_type', 'external_id']); + $table->index(['tenant_id', 'policy_type']); + $table->index(['tenant_id', 'category']); + $table->index('last_seen_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/specs/040-inventory-core/tasks.md b/specs/040-inventory-core/tasks.md index 217c800..8c44aa0 100644 --- a/specs/040-inventory-core/tasks.md +++ b/specs/040-inventory-core/tasks.md @@ -4,35 +4,35 @@ # Tasks: Inventory Core (040) ## P1 — MVP (US1/US2) -- [ ] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields) -- [ ] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes) -- [ ] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256) -- [ ] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates) -- [ ] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths -- [ ] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash) -- [ ] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence -- [ ] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync) -- [ ] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`) +- [X] T001 [US1] Add migrations: `inventory_items` (unique: tenant_id+policy_type+external_id; indexes; last_seen fields) +- [X] T002 [US1] Add migrations: `inventory_sync_runs` (tenant_id, selection_hash, status, started/finished, counts, stable error codes) +- [X] T003 [US1] Implement deterministic `selection_hash` (canonical JSON: sorted keys + sorted arrays; sha256) +- [X] T004 [US1] Implement inventory upsert semantics (idempotent, no duplicates) +- [X] T005 [US1] Enforce tenant isolation for all inventory + run read/write paths +- [X] T006 [US2] Implement derived “missing” query semantics vs latest completed run for same (tenant_id, selection_hash) +- [X] T007 [US2] Missing confidence rule: partial/failed or had_errors => low confidence +- [X] T008 [US2] Enforce `meta_jsonb` whitelist (drop unknown keys; never fail sync) +- [X] T009 [US1] Guardrail: inventory sync must not create snapshots/backups (no writes to `policy_versions`/`backup_*`) ## P2 — Observability & Safety (US3 + NFR) -- [ ] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable) -- [ ] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash) -- [ ] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy) -- [ ] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503) -- [ ] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required) +- [X] T010 [US3] Run lifecycle: ensure run records include counts + stable error codes (visible and actionable) +- [X] T011 [NFR] Locking/idempotency: prevent overlapping runs per (tenant_id, selection_hash) +- [X] T012 [NFR] Concurrency: enforce global + per-tenant limits (queue/semaphore strategy) +- [X] T013 [NFR] Throttling resilience: backoff + jitter for transient Graph failures (429/503) +- [X] T014 [NFR] Safe logging & safe persistence: store only stable `error_codes` + bounded safe context in run records (no secrets/tokens; no log parsing required) ## Tests (Required for runtime behavior) -- [ ] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update -- [ ] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash) -- [ ] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X) -- [ ] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence -- [ ] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence) -- [ ] T025 [NFR] Pest: selection_hash determinism (array ordering invariant) -- [ ] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash) -- [ ] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes) -- [ ] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged) +- [X] T020 [US1] Pest: upsert prevents duplicates; `last_seen_at` and `last_seen_run_id` update +- [X] T021 [US2] Pest: missing derived per latest completed run for same (tenant_id, selection_hash) +- [X] T022 [US2] Pest: selection isolation (run for selection Y does not affect selection X) +- [X] T023 [US2] Pest: partial/failed/had_errors => missing is low confidence +- [X] T024 [US2] Pest: meta whitelist drops unknown keys (no exception; no persistence) +- [X] T025 [NFR] Pest: selection_hash determinism (array ordering invariant) +- [X] T026 [NFR] Pest: lock prevents overlapping runs for same (tenant_id, selection_hash) +- [X] T027 [NFR] Pest: run error persistence contains no secrets/tokens (assert error context is bounded; no “Bearer ” / access token patterns; prefer error_codes) +- [X] T028 [US1] Pest: inventory sync creates no rows in `policy_versions` and `backup_*` tables (assert counts unchanged) ## Notes diff --git a/tests/Feature/Inventory/InventorySyncServiceTest.php b/tests/Feature/Inventory/InventorySyncServiceTest.php new file mode 100644 index 0000000..6f6fb7f --- /dev/null +++ b/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -0,0 +1,296 @@ +throwable instanceof Throwable) { + throw $this->throwable; + } + + if (in_array($policyType, $this->failedTypes, true)) { + return new GraphResponse(false, [], 403, ['error' => ['code' => 'Forbidden', 'message' => 'forbidden']], [], []); + } + + return new GraphResponse(true, $this->policiesByType[$policyType] ?? []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; +} + +test('inventory sync upserts and updates last_seen fields without duplicates', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], + ], + ])); + + $service = app(InventorySyncService::class); + + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + $runA = $service->syncNow($tenant, $selection); + expect($runA->status)->toBe('success'); + + $item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first(); + expect($item)->not->toBeNull(); + expect($item->external_id)->toBe('cfg-1'); + expect($item->last_seen_run_id)->toBe($runA->id); + + $runB = $service->syncNow($tenant, $selection); + + $items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get(); + expect($items)->toHaveCount(1); + + $items->first()->refresh(); + expect($items->first()->last_seen_run_id)->toBe($runB->id); +}); + +test('meta whitelist drops unknown keys without failing', function () { + $tenant = Tenant::factory()->create(); + + $sanitizer = app(InventoryMetaSanitizer::class); + + $meta = $sanitizer->sanitize([ + 'odata_type' => '#microsoft.graph.deviceConfiguration', + 'etag' => 'W/\"123\"', + 'scope_tag_ids' => ['0', 'tag-1'], + 'assignment_target_count' => '5', + 'warnings' => ['ok'], + 'unknown_key' => 'should_not_persist', + ]); + + $item = \App\Models\InventoryItem::query()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'cfg-1', + 'display_name' => 'Config 1', + 'meta_jsonb' => $meta, + 'last_seen_at' => now(), + 'last_seen_run_id' => null, + ]); + + $item->refresh(); + + $stored = is_array($item->meta_jsonb) ? $item->meta_jsonb : []; + + expect($stored)->not->toHaveKey('unknown_key'); + expect($stored['assignment_target_count'] ?? null)->toBe(5); +}); + +test('inventory missing is derived from latest completed run and low confidence on partial runs', function () { + $tenant = Tenant::factory()->create(); + + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], + ], + ])); + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ])); + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + $missingService = app(InventoryMissingService::class); + $result = $missingService->missingForSelection($tenant, $selection); + + expect($result['missing'])->toHaveCount(1); + expect($result['lowConfidence'])->toBeFalse(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ], failedTypes: ['deviceConfiguration'])); + + app(InventorySyncService::class)->syncNow($tenant, $selection); + + $result2 = $missingService->missingForSelection($tenant, $selection); + expect($result2['missing'])->toHaveCount(1); + expect($result2['lowConfidence'])->toBeTrue(); +}); + +test('selection isolation: run for selection Y does not affect selection X missing', function () { + $tenant = Tenant::factory()->create(); + + $selectionX = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + $selectionY = [ + 'policy_types' => ['deviceCompliancePolicy'], + 'categories' => ['Compliance'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [ + ['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'], + ], + 'deviceCompliancePolicy' => [ + ['id' => 'cmp-1', 'displayName' => 'Compliance 1', '@odata.type' => '#microsoft.graph.deviceCompliancePolicy'], + ], + ])); + + $service = app(InventorySyncService::class); + $service->syncNow($tenant, $selectionX); + + $service->syncNow($tenant, $selectionY); + + $missingService = app(InventoryMissingService::class); + $resultX = $missingService->missingForSelection($tenant, $selectionX); + + expect($resultX['missing'])->toHaveCount(0); +}); + +test('lock prevents overlapping runs for same tenant and selection', function () { + $tenant = Tenant::factory()->create(); + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ])); + + $service = app(InventorySyncService::class); + + $selection = [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]; + + $hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection); + $lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900); + expect($lock->get())->toBeTrue(); + + $run = $service->syncNow($tenant, $selection); + + expect($run->status)->toBe('skipped'); + expect($run->error_codes)->toContain('lock_contended'); + + $lock->release(); +}); + +test('inventory sync does not create snapshot or backup rows', function () { + $tenant = Tenant::factory()->create(); + + $baseline = [ + 'policy_versions' => PolicyVersion::query()->count(), + 'backup_sets' => BackupSet::query()->count(), + 'backup_items' => BackupItem::query()->count(), + 'backup_schedules' => BackupSchedule::query()->count(), + 'backup_schedule_runs' => BackupScheduleRun::query()->count(), + ]; + + app()->instance(GraphClientInterface::class, fakeGraphClient([ + 'deviceConfiguration' => [], + ])); + + $service = app(InventorySyncService::class); + + $service->syncNow($tenant, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]); + + expect(PolicyVersion::query()->count())->toBe($baseline['policy_versions']); + expect(BackupSet::query()->count())->toBe($baseline['backup_sets']); + expect(BackupItem::query()->count())->toBe($baseline['backup_items']); + expect(BackupSchedule::query()->count())->toBe($baseline['backup_schedules']); + expect(BackupScheduleRun::query()->count())->toBe($baseline['backup_schedule_runs']); +}); + +test('run error persistence is safe and does not include bearer tokens', function () { + $tenant = Tenant::factory()->create(); + + $throwable = new RuntimeException('Graph failed: Bearer abc.def.ghi'); + + app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable)); + + $service = app(InventorySyncService::class); + + $run = $service->syncNow($tenant, [ + 'policy_types' => ['deviceConfiguration'], + 'categories' => ['Configuration'], + 'include_foundations' => false, + 'include_dependencies' => false, + ]); + + expect($run->status)->toBe('failed'); + + $context = is_array($run->error_context) ? $run->error_context : []; + $message = (string) ($context['message'] ?? ''); + + expect($message)->not->toContain('abc.def.ghi'); + expect($message)->toContain('Bearer [REDACTED]'); +}); diff --git a/tests/Unit/Inventory/InventorySelectionHasherTest.php b/tests/Unit/Inventory/InventorySelectionHasherTest.php new file mode 100644 index 0000000..a77308a --- /dev/null +++ b/tests/Unit/Inventory/InventorySelectionHasherTest.php @@ -0,0 +1,23 @@ + ['deviceCompliancePolicy', 'deviceConfiguration'], + 'categories' => ['Compliance', 'Configuration'], + 'include_foundations' => true, + 'include_dependencies' => false, + ]; + + $payloadB = [ + 'include_dependencies' => false, + 'include_foundations' => true, + 'categories' => ['Configuration', 'Compliance'], + 'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + ]; + + expect($hasher->hash($payloadA))->toBe($hasher->hash($payloadB)); +});