From 74ab2d1404bb5ff950c30eb8be8fab4ab3c00ef0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 19 Feb 2026 14:15:46 +0100 Subject: [PATCH] feat: Phase 2 foundational - capabilities, migrations, models, factories, badges, support classes T003-T018b: Add workspace_baselines.view/manage capabilities, role mappings, baseline_capture/baseline_compare operation labels, severity summary keys, 5 migrations, 4 models, 4 factories, BaselineScope, BaselineReasonCodes, BaselineProfileStatus badge domain + mapper. --- .github/agents/copilot-instructions.md | 3 +- app/Models/BaselineProfile.php | 50 +++++ app/Models/BaselineSnapshot.php | 35 +++ app/Models/BaselineSnapshotItem.php | 23 ++ app/Models/BaselineTenantAssignment.php | 40 ++++ .../Auth/WorkspaceRoleCapabilityMap.php | 6 + app/Support/Auth/Capabilities.php | 5 + app/Support/Badges/BadgeCatalog.php | 1 + app/Support/Badges/BadgeDomain.php | 1 + .../Domains/BaselineProfileStatusBadge.php | 25 +++ app/Support/Baselines/BaselineReasonCodes.php | 24 ++ app/Support/Baselines/BaselineScope.php | 113 ++++++++++ app/Support/OperationCatalog.php | 4 + app/Support/OpsUx/OperationSummaryKeys.php | 3 + database/factories/BaselineProfileFactory.php | 61 +++++ .../factories/BaselineSnapshotFactory.php | 30 +++ .../factories/BaselineSnapshotItemFactory.php | 30 +++ .../BaselineTenantAssignmentFactory.php | 31 +++ ..._100001_create_baseline_profiles_table.php | 32 +++ ...100002_create_baseline_snapshots_table.php | 40 ++++ ...3_create_baseline_snapshot_items_table.php | 33 +++ ...eate_baseline_tenant_assignments_table.php | 29 +++ ...19_100005_add_source_to_findings_table.php | 24 ++ .../checklists/requirements.md | 50 +++++ .../baseline-governance.openapi.yaml | 156 +++++++++++++ .../data-model.md | 142 ++++++++++++ .../plan.md | 166 ++++++++++++++ .../quickstart.md | 60 +++++ .../research.md | 101 +++++++++ .../spec.md | 167 ++++++++++++++ .../tasks.md | 209 ++++++++++++++++++ 31 files changed, 1693 insertions(+), 1 deletion(-) create mode 100644 app/Models/BaselineProfile.php create mode 100644 app/Models/BaselineSnapshot.php create mode 100644 app/Models/BaselineSnapshotItem.php create mode 100644 app/Models/BaselineTenantAssignment.php create mode 100644 app/Support/Badges/Domains/BaselineProfileStatusBadge.php create mode 100644 app/Support/Baselines/BaselineReasonCodes.php create mode 100644 app/Support/Baselines/BaselineScope.php create mode 100644 database/factories/BaselineProfileFactory.php create mode 100644 database/factories/BaselineSnapshotFactory.php create mode 100644 database/factories/BaselineSnapshotItemFactory.php create mode 100644 database/factories/BaselineTenantAssignmentFactory.php create mode 100644 database/migrations/2026_02_19_100001_create_baseline_profiles_table.php create mode 100644 database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php create mode 100644 database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php create mode 100644 database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php create mode 100644 database/migrations/2026_02_19_100005_add_source_to_findings_table.php create mode 100644 specs/101-golden-master-baseline-governance-v1/checklists/requirements.md create mode 100644 specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml create mode 100644 specs/101-golden-master-baseline-governance-v1/data-model.md create mode 100644 specs/101-golden-master-baseline-governance-v1/plan.md create mode 100644 specs/101-golden-master-baseline-governance-v1/quickstart.md create mode 100644 specs/101-golden-master-baseline-governance-v1/research.md create mode 100644 specs/101-golden-master-baseline-governance-v1/spec.md create mode 100644 specs/101-golden-master-baseline-governance-v1/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index c83cbd3..62ca367 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -30,6 +30,7 @@ ## Active Technologies - PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal) - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness) - PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions) +- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1) - PHP 8.4.15 (feat/005-bulk-operations) @@ -49,7 +50,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 101-golden-master-baseline-governance-v1: Added PHP 8.4.x - 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications -- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` diff --git a/app/Models/BaselineProfile.php b/app/Models/BaselineProfile.php new file mode 100644 index 0000000..27abca8 --- /dev/null +++ b/app/Models/BaselineProfile.php @@ -0,0 +1,50 @@ + 'array', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function createdByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function activeSnapshot(): BelongsTo + { + return $this->belongsTo(BaselineSnapshot::class, 'active_snapshot_id'); + } + + public function snapshots(): HasMany + { + return $this->hasMany(BaselineSnapshot::class); + } + + public function tenantAssignments(): HasMany + { + return $this->hasMany(BaselineTenantAssignment::class); + } +} diff --git a/app/Models/BaselineSnapshot.php b/app/Models/BaselineSnapshot.php new file mode 100644 index 0000000..70aa411 --- /dev/null +++ b/app/Models/BaselineSnapshot.php @@ -0,0 +1,35 @@ + 'array', + 'captured_at' => 'datetime', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function baselineProfile(): BelongsTo + { + return $this->belongsTo(BaselineProfile::class); + } + + public function items(): HasMany + { + return $this->hasMany(BaselineSnapshotItem::class); + } +} diff --git a/app/Models/BaselineSnapshotItem.php b/app/Models/BaselineSnapshotItem.php new file mode 100644 index 0000000..29f1172 --- /dev/null +++ b/app/Models/BaselineSnapshotItem.php @@ -0,0 +1,23 @@ + 'array', + ]; + + public function snapshot(): BelongsTo + { + return $this->belongsTo(BaselineSnapshot::class, 'baseline_snapshot_id'); + } +} diff --git a/app/Models/BaselineTenantAssignment.php b/app/Models/BaselineTenantAssignment.php new file mode 100644 index 0000000..14a42bc --- /dev/null +++ b/app/Models/BaselineTenantAssignment.php @@ -0,0 +1,40 @@ + 'array', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function baselineProfile(): BelongsTo + { + return $this->belongsTo(BaselineProfile::class); + } + + public function assignedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by_user_id'); + } +} diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php index 3257ee8..796fbc3 100644 --- a/app/Services/Auth/WorkspaceRoleCapabilityMap.php +++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php @@ -36,6 +36,8 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_SETTINGS_MANAGE, Capabilities::ALERTS_VIEW, Capabilities::ALERTS_MANAGE, + Capabilities::WORKSPACE_BASELINES_VIEW, + Capabilities::WORKSPACE_BASELINES_MANAGE, ], WorkspaceRole::Manager->value => [ @@ -54,6 +56,8 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_SETTINGS_MANAGE, Capabilities::ALERTS_VIEW, Capabilities::ALERTS_MANAGE, + Capabilities::WORKSPACE_BASELINES_VIEW, + Capabilities::WORKSPACE_BASELINES_MANAGE, ], WorkspaceRole::Operator->value => [ @@ -66,12 +70,14 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP, Capabilities::WORKSPACE_SETTINGS_VIEW, Capabilities::ALERTS_VIEW, + Capabilities::WORKSPACE_BASELINES_VIEW, ], WorkspaceRole::Readonly->value => [ Capabilities::WORKSPACE_VIEW, Capabilities::WORKSPACE_SETTINGS_VIEW, Capabilities::ALERTS_VIEW, + Capabilities::WORKSPACE_BASELINES_VIEW, ], ]; diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 1046dd4..033e3ee 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -96,6 +96,11 @@ class Capabilities public const PROVIDER_RUN = 'provider.run'; + // Workspace baselines (Golden Master governance) + public const WORKSPACE_BASELINES_VIEW = 'workspace_baselines.view'; + + public const WORKSPACE_BASELINES_MANAGE = 'workspace_baselines.manage'; + // Audit public const AUDIT_VIEW = 'audit.view'; diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index e32fde5..6481460 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -39,6 +39,7 @@ final class BadgeCatalog BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class, BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class, BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class, + BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, ]; /** diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 9ce4b34..6f8db9b 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -31,4 +31,5 @@ enum BadgeDomain: string case VerificationReportOverall = 'verification_report_overall'; case AlertDeliveryStatus = 'alert_delivery_status'; case AlertDestinationLastTestStatus = 'alert_destination_last_test_status'; + case BaselineProfileStatus = 'baseline_profile_status'; } diff --git a/app/Support/Badges/Domains/BaselineProfileStatusBadge.php b/app/Support/Badges/Domains/BaselineProfileStatusBadge.php new file mode 100644 index 0000000..bdc2945 --- /dev/null +++ b/app/Support/Badges/Domains/BaselineProfileStatusBadge.php @@ -0,0 +1,25 @@ + new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'), + BaselineProfile::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'), + BaselineProfile::STATUS_ARCHIVED => new BadgeSpec('Archived', 'warning', 'heroicon-m-archive-box'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Baselines/BaselineReasonCodes.php b/app/Support/Baselines/BaselineReasonCodes.php new file mode 100644 index 0000000..0dabf27 --- /dev/null +++ b/app/Support/Baselines/BaselineReasonCodes.php @@ -0,0 +1,24 @@ + $policyTypes + */ + public function __construct( + public readonly array $policyTypes = [], + ) {} + + /** + * Create from the scope_jsonb column value. + * + * @param array|null $scopeJsonb + */ + public static function fromJsonb(?array $scopeJsonb): self + { + if ($scopeJsonb === null) { + return new self; + } + + $policyTypes = $scopeJsonb['policy_types'] ?? []; + + return new self( + policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [], + ); + } + + /** + * Normalize the effective scope by intersecting profile scope with an optional override. + * + * Override can only narrow the profile scope (subset enforcement). + * If the profile scope is empty (all types), the override becomes the effective scope. + * If the override is empty or null, the profile scope is used as-is. + */ + public static function effective(self $profileScope, ?self $overrideScope): self + { + if ($overrideScope === null || $overrideScope->isEmpty()) { + return $profileScope; + } + + if ($profileScope->isEmpty()) { + return $overrideScope; + } + + $intersected = array_values(array_intersect($profileScope->policyTypes, $overrideScope->policyTypes)); + + return new self(policyTypes: $intersected); + } + + /** + * An empty scope means "all types". + */ + public function isEmpty(): bool + { + return $this->policyTypes === []; + } + + /** + * Check if a policy type is included in this scope. + */ + public function includes(string $policyType): bool + { + if ($this->isEmpty()) { + return true; + } + + return in_array($policyType, $this->policyTypes, true); + } + + /** + * Validate that override is a subset of the profile scope. + */ + public static function isValidOverride(self $profileScope, self $overrideScope): bool + { + if ($overrideScope->isEmpty()) { + return true; + } + + if ($profileScope->isEmpty()) { + return true; + } + + foreach ($overrideScope->policyTypes as $type) { + if (! in_array($type, $profileScope->policyTypes, true)) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + public function toJsonb(): array + { + return [ + 'policy_types' => $this->policyTypes, + ]; + } +} diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index c7ab739..a99b234 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -45,6 +45,8 @@ public static function labels(): array 'policy_version.force_delete' => 'Delete policy versions', 'alerts.evaluate' => 'Alerts evaluation', 'alerts.deliver' => 'Alerts delivery', + 'baseline_capture' => 'Baseline capture', + 'baseline_compare' => 'Baseline compare', ]; } @@ -72,6 +74,8 @@ public static function expectedDurationSeconds(string $operationType): ?int 'assignments.fetch', 'assignments.restore' => 60, 'ops.reconcile_adapter_runs' => 120, 'alerts.evaluate', 'alerts.deliver' => 120, + 'baseline_capture' => 120, + 'baseline_compare' => 120, default => null, }; } diff --git a/app/Support/OpsUx/OperationSummaryKeys.php b/app/Support/OpsUx/OperationSummaryKeys.php index 0892dd6..7452a1f 100644 --- a/app/Support/OpsUx/OperationSummaryKeys.php +++ b/app/Support/OpsUx/OperationSummaryKeys.php @@ -24,6 +24,9 @@ public static function all(): array 'deleted', 'items', 'tenants', + 'high', + 'medium', + 'low', ]; } } diff --git a/database/factories/BaselineProfileFactory.php b/database/factories/BaselineProfileFactory.php new file mode 100644 index 0000000..a9ae350 --- /dev/null +++ b/database/factories/BaselineProfileFactory.php @@ -0,0 +1,61 @@ + + */ +class BaselineProfileFactory extends Factory +{ + protected $model = BaselineProfile::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'name' => fake()->unique()->words(3, true), + 'description' => fake()->optional()->sentence(), + 'version_label' => fake()->optional()->numerify('v#.#'), + 'status' => BaselineProfile::STATUS_DRAFT, + 'scope_jsonb' => ['policy_types' => []], + 'active_snapshot_id' => null, + 'created_by_user_id' => null, + ]; + } + + public function active(): static + { + return $this->state(fn (): array => [ + 'status' => BaselineProfile::STATUS_ACTIVE, + ]); + } + + public function archived(): static + { + return $this->state(fn (): array => [ + 'status' => BaselineProfile::STATUS_ARCHIVED, + ]); + } + + public function withScope(array $policyTypes): static + { + return $this->state(fn (): array => [ + 'scope_jsonb' => ['policy_types' => $policyTypes], + ]); + } + + public function createdBy(User $user): static + { + return $this->state(fn (): array => [ + 'created_by_user_id' => $user->getKey(), + ]); + } +} diff --git a/database/factories/BaselineSnapshotFactory.php b/database/factories/BaselineSnapshotFactory.php new file mode 100644 index 0000000..dae6764 --- /dev/null +++ b/database/factories/BaselineSnapshotFactory.php @@ -0,0 +1,30 @@ + + */ +class BaselineSnapshotFactory extends Factory +{ + protected $model = BaselineSnapshot::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'baseline_profile_id' => BaselineProfile::factory(), + 'snapshot_identity_hash' => hash('sha256', fake()->uuid()), + 'captured_at' => now(), + 'summary_jsonb' => ['total_items' => 0], + ]; + } +} diff --git a/database/factories/BaselineSnapshotItemFactory.php b/database/factories/BaselineSnapshotItemFactory.php new file mode 100644 index 0000000..4c2216d --- /dev/null +++ b/database/factories/BaselineSnapshotItemFactory.php @@ -0,0 +1,30 @@ + + */ +class BaselineSnapshotItemFactory extends Factory +{ + protected $model = BaselineSnapshotItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'baseline_snapshot_id' => BaselineSnapshot::factory(), + 'subject_type' => 'policy', + 'subject_external_id' => fake()->uuid(), + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', fake()->uuid()), + 'meta_jsonb' => ['display_name' => fake()->words(3, true)], + ]; + } +} diff --git a/database/factories/BaselineTenantAssignmentFactory.php b/database/factories/BaselineTenantAssignmentFactory.php new file mode 100644 index 0000000..287d2d4 --- /dev/null +++ b/database/factories/BaselineTenantAssignmentFactory.php @@ -0,0 +1,31 @@ + + */ +class BaselineTenantAssignmentFactory extends Factory +{ + protected $model = BaselineTenantAssignment::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'tenant_id' => Tenant::factory(), + 'baseline_profile_id' => BaselineProfile::factory(), + 'override_scope_jsonb' => null, + 'assigned_by_user_id' => null, + ]; + } +} diff --git a/database/migrations/2026_02_19_100001_create_baseline_profiles_table.php b/database/migrations/2026_02_19_100001_create_baseline_profiles_table.php new file mode 100644 index 0000000..2bcf26e --- /dev/null +++ b/database/migrations/2026_02_19_100001_create_baseline_profiles_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('version_label')->nullable(); + $table->string('status')->default('draft'); + $table->jsonb('scope_jsonb'); + $table->unsignedBigInteger('active_snapshot_id')->nullable(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + $table->unique(['workspace_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('baseline_profiles'); + } +}; diff --git a/database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php b/database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php new file mode 100644 index 0000000..ab9d776 --- /dev/null +++ b/database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('baseline_profile_id')->constrained('baseline_profiles')->cascadeOnDelete(); + $table->string('snapshot_identity_hash', 64); + $table->timestampTz('captured_at'); + $table->jsonb('summary_jsonb')->nullable(); + $table->timestamps(); + + $table->unique(['workspace_id', 'baseline_profile_id', 'snapshot_identity_hash'], 'baseline_snapshots_dedupe_unique'); + $table->index(['workspace_id', 'baseline_profile_id', 'captured_at'], 'baseline_snapshots_lookup_idx'); + }); + + Schema::table('baseline_profiles', function (Blueprint $table): void { + $table->foreign('active_snapshot_id') + ->references('id') + ->on('baseline_snapshots') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('baseline_profiles', function (Blueprint $table): void { + $table->dropForeign(['active_snapshot_id']); + }); + + Schema::dropIfExists('baseline_snapshots'); + } +}; diff --git a/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php b/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php new file mode 100644 index 0000000..ef6df08 --- /dev/null +++ b/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('baseline_snapshot_id')->constrained('baseline_snapshots')->cascadeOnDelete(); + $table->string('subject_type'); + $table->string('subject_external_id'); + $table->string('policy_type'); + $table->string('baseline_hash', 64); + $table->jsonb('meta_jsonb')->nullable(); + $table->timestamps(); + + $table->unique( + ['baseline_snapshot_id', 'subject_type', 'subject_external_id'], + 'baseline_snapshot_items_subject_unique' + ); + $table->index(['baseline_snapshot_id', 'policy_type'], 'baseline_snapshot_items_policy_type_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('baseline_snapshot_items'); + } +}; diff --git a/database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php b/database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php new file mode 100644 index 0000000..dcdd6bc --- /dev/null +++ b/database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('baseline_profile_id')->constrained('baseline_profiles')->cascadeOnDelete(); + $table->jsonb('override_scope_jsonb')->nullable(); + $table->foreignId('assigned_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['workspace_id', 'tenant_id']); + $table->index(['workspace_id', 'baseline_profile_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('baseline_tenant_assignments'); + } +}; diff --git a/database/migrations/2026_02_19_100005_add_source_to_findings_table.php b/database/migrations/2026_02_19_100005_add_source_to_findings_table.php new file mode 100644 index 0000000..5071b39 --- /dev/null +++ b/database/migrations/2026_02_19_100005_add_source_to_findings_table.php @@ -0,0 +1,24 @@ +string('source')->nullable()->after('finding_type'); + $table->index(['tenant_id', 'source']); + }); + } + + public function down(): void + { + Schema::table('findings', function (Blueprint $table): void { + $table->dropIndex(['tenant_id', 'source']); + $table->dropColumn('source'); + }); + } +}; diff --git a/specs/101-golden-master-baseline-governance-v1/checklists/requirements.md b/specs/101-golden-master-baseline-governance-v1/checklists/requirements.md new file mode 100644 index 0000000..be0d1f2 --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/checklists/requirements.md @@ -0,0 +1,50 @@ +# Specification Quality Checklist: Golden Master / Baseline Governance v1 (R1.1–R1.4) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-19 +**Last Validated**: 2026-02-19 (post-analysis remediation) +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Post-Analysis Remediation (2026-02-19) + +- [x] D1: Duplicate FR-003 removed (kept complete version with override clause) +- [x] I1: Status lifecycle resolved — `draft ↔ active → archived` (deactivate = return to draft); spec.md + data-model.md aligned +- [x] I3: "Delete" removed from UI Action Matrix — v1 is archive-only, no hard-delete +- [x] C1: BadgeDomain task (T018a) added for BaselineProfileStatus badge compliance (BADGE-001) +- [x] C2: Factory tasks (T018b) added for all 4 new models +- [x] U1: `findings.source` default resolved — nullable, NULL default, legacy findings unaffected +- [x] U2: Empty-scope edge case (EC-005) covered in T031 test description +- [x] U3: Concurrent operation dedup (EC-004) covered in T032 + T040 test descriptions +- [x] U4: T033 amended with explicit manage-capability gating via WorkspaceUiEnforcement +- [x] U5: `scope_jsonb` schema defined in data-model.md (`{ "policy_types": [...] }`) +- [x] E1: SC-001/SC-002 performance spot-check added to T060 + +## Notes + +- All 12 analysis findings have been remediated +- Task count updated: 60 → 62 (T018a, T018b added in Phase 2) diff --git a/specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml b/specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml new file mode 100644 index 0000000..c288211 --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml @@ -0,0 +1,156 @@ +openapi: 3.0.3 +info: + title: Baseline Governance v1 (Golden Master) + version: 1.0.0 + description: | + Conceptual HTTP contract for Baseline Governance actions. + + Note: The implementation is Filament + Livewire; these endpoints describe the server-side behavior + (authorization, precondition failures, operation run creation) in a REST-like form for clarity. + +servers: + - url: /admin + +paths: + /workspaces/{workspaceId}/baselines: + get: + summary: List baseline profiles + parameters: + - $ref: '#/components/parameters/workspaceId' + responses: + '200': + description: OK + + /workspaces/{workspaceId}/baselines/{baselineProfileId}: + get: + summary: View baseline profile + parameters: + - $ref: '#/components/parameters/workspaceId' + - $ref: '#/components/parameters/baselineProfileId' + responses: + '200': + description: OK + '404': + description: Not found (workspace not entitled) + '403': + description: Forbidden (missing capability) + + /workspaces/{workspaceId}/baselines/{baselineProfileId}/capture: + post: + summary: Capture immutable baseline snapshot from a tenant + parameters: + - $ref: '#/components/parameters/workspaceId' + - $ref: '#/components/parameters/baselineProfileId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [source_tenant_id] + properties: + source_tenant_id: + type: integer + responses: + '202': + description: Enqueued (OperationRun created/reused) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunStartResponse' + '422': + description: Precondition failure (no OperationRun created) + content: + application/json: + schema: + $ref: '#/components/schemas/PreconditionFailure' + examples: + missingSourceTenant: + value: + reason_code: baseline.capture.missing_source_tenant + '404': + description: Not found (workspace not entitled) + '403': + description: Forbidden (missing capability) + + /tenants/{tenantId}/baseline-compare: + post: + summary: Compare tenant state to assigned baseline and generate drift findings + parameters: + - $ref: '#/components/parameters/tenantId' + responses: + '202': + description: Enqueued (OperationRun created/reused) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunStartResponse' + '422': + description: Precondition failure (no OperationRun created) + content: + application/json: + schema: + $ref: '#/components/schemas/PreconditionFailure' + examples: + noAssignment: + value: + reason_code: baseline.compare.no_assignment + profileNotActive: + value: + reason_code: baseline.compare.profile_not_active + noActiveSnapshot: + value: + reason_code: baseline.compare.no_active_snapshot + '404': + description: Not found (tenant/workspace not entitled) + '403': + description: Forbidden (missing capability) + + /tenants/{tenantId}/baseline-compare/latest: + get: + summary: Fetch latest baseline compare summary for tenant + parameters: + - $ref: '#/components/parameters/tenantId' + responses: + '200': + description: OK + +components: + parameters: + workspaceId: + name: workspaceId + in: path + required: true + schema: + type: integer + tenantId: + name: tenantId + in: path + required: true + schema: + type: integer + baselineProfileId: + name: baselineProfileId + in: path + required: true + schema: + type: integer + + schemas: + OperationRunStartResponse: + type: object + required: [operation_run_id] + properties: + operation_run_id: + type: integer + reused: + type: boolean + description: True if an already-queued/running run was returned + + PreconditionFailure: + type: object + required: [reason_code] + properties: + reason_code: + type: string + description: Stable code for UI + support triage diff --git a/specs/101-golden-master-baseline-governance-v1/data-model.md b/specs/101-golden-master-baseline-governance-v1/data-model.md new file mode 100644 index 0000000..f22c437 --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/data-model.md @@ -0,0 +1,142 @@ +# Phase 1 — Data Model + +This is the proposed data model for **101 — Golden Master / Baseline Governance v1**. + +## Ownership Model + +- **Workspace-owned** + - Baseline profiles + - Baseline snapshots + snapshot items (data-minimized, reusable across tenants) + +- **Tenant-owned** + - Tenant assignments (joins a tenant to the workspace baseline standard) + - Operation runs for capture/compare + - Findings produced by compares + +This follows constitution SCOPE-001 conventions: tenant-owned tables include `workspace_id` + `tenant_id` NOT NULL; workspace-owned tables include `workspace_id` and do not include `tenant_id`. + +## Entities + +## 1) baseline_profiles (workspace-owned) + +**Purpose**: Defines the baseline (“what good looks like”) and its scope. + +**Fields** +- `id` (pk) +- `workspace_id` (fk workspaces, NOT NULL) +- `name` (string, NOT NULL) +- `description` (text, nullable) +- `version_label` (string, nullable) +- `status` (string enum: `draft|active|archived`, NOT NULL) +- `scope_jsonb` (jsonb, NOT NULL) + - v1 schema: `{ "policy_types": ["string", ...] }` — array of policy type keys from `InventoryPolicyTypeMeta` + - An empty array means "all types" (no filtering); each string must be a known policy type key + - Future versions may add additional filter dimensions (e.g., `platforms`, `tags`) +- `active_snapshot_id` (fk baseline_snapshots, nullable) +- `created_by_user_id` (fk users, nullable) +- timestamps + +**Indexes/constraints** +- index: `(workspace_id, status)` +- uniqueness: `(workspace_id, name)` (optional but recommended if UI expects names unique per workspace) + +**Validation notes** +- status transitions: `draft ↔ active → archived` (archived is terminal in v1; deactivate returns active → draft) + +## 2) baseline_snapshots (workspace-owned) + +**Purpose**: Immutable baseline snapshot captured from a tenant. + +**Fields** +- `id` (pk) +- `workspace_id` (fk workspaces, NOT NULL) +- `baseline_profile_id` (fk baseline_profiles, NOT NULL) +- `snapshot_identity_hash` (string(64), NOT NULL) + - sha256 of normalized captured content +- `captured_at` (timestamp tz, NOT NULL) +- `summary_jsonb` (jsonb, nullable) + - counts/metadata (e.g., total items) +- timestamps + +**Indexes/constraints** +- unique: `(workspace_id, baseline_profile_id, snapshot_identity_hash)` (dedupe) +- index: `(workspace_id, baseline_profile_id, captured_at desc)` + +## 3) baseline_snapshot_items (workspace-owned) + +**Purpose**: Immutable items within a snapshot. + +**Fields** +- `id` (pk) +- `baseline_snapshot_id` (fk baseline_snapshots, NOT NULL) +- `subject_type` (string, NOT NULL) — e.g. `policy` +- `subject_external_id` (string, NOT NULL) — stable key for the policy within the tenant inventory +- `policy_type` (string, NOT NULL) — for filtering and summary +- `baseline_hash` (string(64), NOT NULL) — stable content hash for the baseline version +- `meta_jsonb` (jsonb, nullable) — minimized display metadata (no secrets) +- timestamps + +**Indexes/constraints** +- unique: `(baseline_snapshot_id, subject_type, subject_external_id)` +- index: `(baseline_snapshot_id, policy_type)` + +## 4) baseline_tenant_assignments (tenant-owned) + +**Purpose**: Assigns exactly one baseline profile per tenant (v1), with optional scope override that can only narrow. + +**Fields** +- `id` (pk) +- `workspace_id` (fk workspaces, NOT NULL) +- `tenant_id` (fk tenants, NOT NULL) +- `baseline_profile_id` (fk baseline_profiles, NOT NULL) +- `override_scope_jsonb` (jsonb, nullable) +- `assigned_by_user_id` (fk users, nullable) +- timestamps + +**Indexes/constraints** +- unique: `(workspace_id, tenant_id)` +- index: `(workspace_id, baseline_profile_id)` + +**Validation notes** +- Override must be subset of profile scope (enforced server-side; store the final effective scope hash in the compare run context for traceability). + +## 5) findings additions (tenant-owned) + +**Purpose**: Persist baseline compare drift findings using existing findings system. + +**Required v1 additions** +- add `findings.source` (string, **nullable**, default `NULL`) + - baseline compare uses `source='baseline.compare'` + - existing findings receive `NULL`; legacy drift-generate findings are queried with `whereNull('source')` or unconditionally +- index: `(tenant_id, source)` + +**Baseline compare finding conventions** +- `finding_type = 'drift'` +- `scope_key = 'baseline_profile:'` +- `fingerprint = sha256(tenant_id|scope_key|subject_type|subject_external_id|change_type|baseline_hash|current_hash)` (using `DriftHasher`) +- `evidence_jsonb` includes: + - `source` (until DB column exists) + - `baseline.profile_id`, `baseline.snapshot_id` + - `baseline.hash`, `current.hash` + - `change_type` (missing_policy|different_version|unexpected_policy) + - any minimized diff pointers required for UI + +## 6) operation_runs conventions + +**Operation types** +- `baseline_capture` +- `baseline_compare` + +**Run context** (json) +- capture: + - `baseline_profile_id` + - `source_tenant_id` + - `effective_scope` / `selection_hash` +- compare: + - `baseline_profile_id` + - `baseline_snapshot_id` (frozen at enqueue time) + - `effective_scope` / `selection_hash` + +**Summary counts** +- compare should set a compact breakdown for dashboards: + - totals and severity breakdowns diff --git a/specs/101-golden-master-baseline-governance-v1/plan.md b/specs/101-golden-master-baseline-governance-v1/plan.md new file mode 100644 index 0000000..a7cc95b --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/plan.md @@ -0,0 +1,166 @@ +#+#+#+#+ +# Implementation Plan: Golden Master / Baseline Governance v1 (R1.1–R1.4) + +**Branch**: `101-golden-master-baseline-governance-v1` | **Date**: 2026-02-19 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +## Summary + +Implement a workspace-owned **Golden Master baseline** that can be captured from a tenant into an immutable snapshot, assigned to tenants, and compared (“Soll vs Ist”) to generate drift findings + a compact operational summary. + +This feature reuses existing patterns: +- tenant-scoped `OperationRun` for capture/compare observability + idempotency +- `findings` for drift tracking with sha256 fingerprints + +## Technical Context + +**Language/Version**: PHP 8.4.x +**Framework**: Laravel 12 +**Admin UI**: Filament v5 (requires Livewire v4.0+) +**Storage**: PostgreSQL (Sail locally); SQLite is used in some tests +**Testing**: Pest v4 (`vendor/bin/sail artisan test`) +**Target Platform**: Docker via Laravel Sail (macOS dev) +**Project Type**: Laravel monolith (server-rendered Filament + Livewire) +**Performance Goals**: +- Compare completes within ~2 minutes for typical tenants (≤ 500 in-scope policies) +**Constraints**: +- Baseline/monitoring pages must be DB-only at render time (no outbound HTTP) +- Operation start surfaces enqueue-only (no remote work inline) +**Scale/Scope**: +- Multi-tenant, workspace isolation is an authorization boundary + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: compare operates on “last observed” inventory/policy versions; baseline snapshot is an explicit immutable capture. +- Read/write separation: capture/compare are operational jobs; any mutations are confirmed, audited, and tested. +- Graph contract path: no Graph calls on UI surfaces; any Graph usage (if introduced) must go through `GraphClientInterface` and `config/graph_contracts.php`. +- Deterministic capabilities: new baseline capabilities are added to the canonical registry and role maps. +- RBAC-UX semantics: non-member is 404; member but missing capability is 403. +- Run observability: capture/compare are always `OperationRun`-tracked; start surfaces only create/reuse runs and enqueue work. +- Data minimization: workspace-owned baseline snapshots are minimized and must not store tenant secrets. +- Badge semantics (BADGE-001): finding severity/status uses existing `BadgeCatalog` mapping. +- Filament Action Surface Contract: resources/pages created or modified must define Header/Row/Bulk/Empty-State + inspect affordance. + +✅ Phase 0 and Phase 1 design choices satisfy the constitution. No violations are required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/101-golden-master-baseline-governance-v1/ +├── plan.md +├── spec.md +├── research.md +├── data-model.md +├── quickstart.md +└── contracts/ + └── baseline-governance.openapi.yaml +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +├── Jobs/ +├── Models/ +├── Policies/ +├── Services/ +└── Support/ + +database/ +└── migrations/ + +resources/ +└── views/ + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Implement baseline governance as standard Laravel models, migrations, services, jobs, and Filament resources/pages under the existing `app/` conventions. + +## Phase 0 — Research (completed) + +Outputs: +- [research.md](research.md) + +Key decisions captured: +- Workspace-owned, data-minimized baseline snapshots (reusable across tenants) +- `OperationRun` types `baseline_capture` and `baseline_compare` using `ensureRunWithIdentity()` for idempotent starts +- Precondition failures return 422 with stable `reason_code` and do not create runs +- Findings reuse `findings` with sha256 fingerprints; Phase 2 adds `findings.source` to satisfy `source = baseline.compare` + +## Phase 1 — Design & Contracts (completed) + +Outputs: +- [data-model.md](data-model.md) +- [contracts/baseline-governance.openapi.yaml](contracts/baseline-governance.openapi.yaml) +- [quickstart.md](quickstart.md) + +### UI Surfaces (Filament v5) + +- Workspace-context: Governance → Baselines + - Resource for baseline profile CRUD + - Capture action (manage capability) + - Tenant assignment management (manage capability) +- Tenant-context: “Soll vs Ist” landing page + - Compare-now action (view capability) + - Links to latest compare run + findings + +### Authorization & capabilities + +- Add new workspace capabilities: + - `workspace_baselines.view` + - `workspace_baselines.manage` +- Enforce membership as 404; capability denial as 403. + +### Operation types + +- `baseline_capture` (tenant = source tenant) +- `baseline_compare` (tenant = target/current tenant) + +### Agent context update (completed) + +The agent context update script was run during planning. It should be re-run after this plan update if the automation depends on parsed plan values. + +## Phase 2 — Execution Plan (to be translated into tasks.md) + +1) **Migrations + models** + - Add baseline tables (`baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`) + - Add `findings.source` column + index to meet FR-015 + +2) **Services** + - Baseline scope resolution (profile scope ∩ assignment override) + - Snapshot identity calculation and dedupe strategy + - Compare engine to produce drift items and severity mapping (FR-009) + +3) **Jobs (queued)** + - `CaptureBaselineSnapshotJob` (creates snapshot, items, updates active snapshot when applicable) + - `CompareBaselineToTenantJob` (creates/updates findings + writes run summary_counts) + +4) **Filament UI** + - Baseline profiles resource (list/view/edit) with action surfaces per spec matrix + - Tenant assignment UI surface (v1: single baseline per tenant) + - “Soll vs Ist” tenant landing page and dashboard card action + +5) **RBAC + policies** + - Capability registry + role maps + UI enforcement + - Regression tests for 404 vs 403 semantics + +6) **Tests (Pest)** + - CRUD authorization + action surface expectations + - Capture snapshot dedupe + - Compare finding fingerprint idempotency + - Precondition failures return 422 and create no `OperationRun` + +7) **Formatting** + - Run `vendor/bin/sail bin pint --dirty` + +## Complexity Tracking + +None. diff --git a/specs/101-golden-master-baseline-governance-v1/quickstart.md b/specs/101-golden-master-baseline-governance-v1/quickstart.md new file mode 100644 index 0000000..66dda14 --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/quickstart.md @@ -0,0 +1,60 @@ +# Phase 1 — Quickstart (Developer) + +This quickstart is for exercising Baseline Governance v1 locally. + +## Prereqs +- Docker running +- Laravel Sail available + +## Setup +1. Start containers: `vendor/bin/sail up -d` +2. Install deps (if needed): `vendor/bin/sail composer install` +3. Migrate: `vendor/bin/sail artisan migrate` +4. Build frontend assets (if UI changes aren’t visible): `vendor/bin/sail npm run dev` + +## Happy path walkthrough + +### 1) Create a baseline profile +- Navigate to Admin → Governance → Baselines +- Create a profile with: + - name + - status = draft + - scope filter (policy types/domains) + +### 2) Capture from a source tenant +- From the Baseline Profile view page, trigger “Capture from tenant” +- Select a source tenant +- Confirm the action +- You should see a queued notification with “View run” that links to Monitoring → Operations + +Expected: +- An `OperationRun` of type `baseline_capture` is created (or reused if one is already queued/running) +- On success, an immutable `baseline_snapshot` is created and the profile’s `active_snapshot_id` is updated (when profile is active) + +### 3) Assign baseline to a tenant +- Navigate to the tenant context (Admin → choose tenant) +- Assign the baseline profile to the tenant (v1: exactly one baseline per tenant) +- Optionally define an override filter that narrows scope + +### 4) Compare now (Soll vs Ist) +- Navigate to the “Soll vs Ist” landing page for the tenant +- Click “Compare now” + +Expected: +- An `OperationRun` of type `baseline_compare` is created/reused +- Findings are created/updated with stable fingerprints +- The compare run summary is persisted (totals + severity breakdown) + +## Precondition failure checks + +These should return **HTTP 422** with `reason_code`, and must **not** create an `OperationRun`: +- compare with no assignment: `baseline.compare.no_assignment` +- compare when profile not active: `baseline.compare.profile_not_active` +- compare when no active snapshot: `baseline.compare.no_active_snapshot` +- capture with missing source tenant: `baseline.capture.missing_source_tenant` + +## Test focus (when implementation lands) +- BaselineProfile CRUD + RBAC (404 vs 403) +- Capture idempotency (dedupe snapshot identity) +- Compare idempotency (dedupe finding fingerprint) +- Action surfaces comply with the Filament Action Surface Contract diff --git a/specs/101-golden-master-baseline-governance-v1/research.md b/specs/101-golden-master-baseline-governance-v1/research.md new file mode 100644 index 0000000..14d68ec --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/research.md @@ -0,0 +1,101 @@ +# Phase 0 — Research + +This document records the key technical decisions for **101 — Golden Master / Baseline Governance v1 (R1.1–R1.4)**, grounded in existing TenantPilot patterns. + +## Existing System Constraints (confirmed in repo) + +### Operation runs are tenant-scoped and deduped at DB level +- `OperationRunService::ensureRunWithIdentity()` requires a `Tenant` with a non-null `workspace_id`, and always creates runs with `workspace_id` + `tenant_id`. +- Active-run idempotency is enforced via the `operation_runs_active_unique` partial unique index (queued/running). + +**Implication**: Baseline capture/compare must always be executed as **tenant-owned `OperationRun` records**, even though baseline profiles are workspace-owned. + +### Findings fingerprinting expects sha256 (64 chars) +- `findings.fingerprint` is `string(64)` and unique by `(tenant_id, fingerprint)`. +- `DriftHasher` already implements a stable sha256 fingerprint scheme. + +**Implication**: Any baseline-compare “finding key” should be hashed before storage, and must be stable across repeated compares. + +## Decisions + +### D-001 — Baseline snapshot storage is workspace-owned (and data-minimized) +**Decision**: Store `baseline_snapshots` and `baseline_snapshot_items` as **workspace-owned** tables (`workspace_id` NOT NULL, **no `tenant_id`**) so a golden master snapshot can be used across tenants without requiring access to the source tenant. + +**Rationale**: +- The product intent is a workspace-level standard (“Golden Master”) reusable across multiple tenants. +- Treat the snapshot as a **standard artifact**, not tenant evidence, and enforce strict data-minimization so we do not leak tenant-specific content. + +**Guardrails**: +- Snapshot items store only policy identity + stable content hashes and minimal display metadata. +- Any tenant identifiers (e.g., “captured from tenant”) live in the **capture `OperationRun.context`** and audit logs, not on workspace-owned snapshot rows. + +**Alternatives considered**: +- Tenant-owned baseline snapshot (include `tenant_id`): rejected because it would require cross-tenant reads of tenant-owned records to compare other tenants, which would either violate tenant isolation or force “must be member of the source tenant” semantics. + +### D-002 — OperationRun types and identity inputs +**Decision**: +- Introduce `OperationRun.type` values: + - `baseline_capture` + - `baseline_compare` +- Use `OperationRunService::ensureRunWithIdentity()` for idempotent start surfaces. + +**Identity inputs**: +- `baseline_capture`: identity inputs include `baseline_profile_id`. +- `baseline_compare`: identity inputs include `baseline_profile_id`. + +**Rationale**: +- Guarantees one active run per tenant+baseline profile (matches partial unique index behavior). +- Keeps identity stable even if the active snapshot is switched mid-flight; the run context should freeze `baseline_snapshot_id` at enqueue time for determinism. + +**Alternatives considered**: +- Include `baseline_snapshot_id` in identity: rejected for v1 because we primarily want “single active compare per tenant/profile”, not “single active compare per snapshot”. + +### D-003 — Precondition failures return 422 and do not create OperationRuns +**Decision**: Enforce FR-014 exactly: +- The start surface validates preconditions **before** calling `OperationRunService`. +- If unmet, return **HTTP 422** with a stable `reason_code` and **do not** create an `OperationRun`. + +**Rationale**: +- Aligns with spec clarifications and avoids polluting Monitoring → Operations with non-startable attempts. + +### D-004 — Findings storage uses existing `findings` table; add a source discriminator +**Decision**: +- Store baseline-compare drift as `Finding::FINDING_TYPE_DRIFT`. +- Persist `source = baseline.compare` per FR-015. + +**Implementation note**: +- The current `findings` schema does not have a `source` column. +- In Phase 2 implementation we should add `findings.source` (string, **nullable**, default `NULL`) with an index `(tenant_id, source)` and use `source='baseline.compare'`. +- Existing findings receive `NULL` — legacy drift-generate findings are queried with `whereNull('source')` or unconditionally. +- A future backfill migration may set `source='drift.generate'` for historical findings if needed for reporting. + +**Alternatives considered**: +- Store `source` only in `evidence_jsonb`: workable, but makes filtering and long-term reporting harder and is less explicit. +- Non-null default `'drift.generate'`: rejected because retroactively tagging all existing findings requires careful validation and is a separate concern. + +### D-005 — Baseline compare scope_key strategy +**Decision**: Use `findings.scope_key = 'baseline_profile:' . baseline_profile_id` for baseline-compare findings. + +**Rationale**: +- Keeps a stable grouping key for tenant UI (“Soll vs Ist” for the assigned baseline). +- Avoids over-coupling to inventory selection hashes in v1. + +### D-006 — Authorization model (404 vs 403) +**Decision**: +- Membership is enforced as deny-as-not-found (404) via existing membership checks. +- Capability denials are 403 after membership is established. + +**Capabilities**: +- Add workspace capabilities: + - `workspace_baselines.view` + - `workspace_baselines.manage` + +**Rationale**: +- Matches the feature spec’s two-capability requirement. +- Keeps baseline governance controlled at workspace plane, while still enforcing tenant membership for tenant-context pages/actions. + +**Alternatives considered**: +- Add a tenant-plane capability for compare start: rejected for v1 to keep to the two-capability spec and avoid introducing a second permission axis for the same action. + +## Open Questions (none blocking Phase 1) +- None. diff --git a/specs/101-golden-master-baseline-governance-v1/spec.md b/specs/101-golden-master-baseline-governance-v1/spec.md new file mode 100644 index 0000000..dd93765 --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/spec.md @@ -0,0 +1,167 @@ +# Feature Specification: Golden Master / Baseline Governance v1 (R1.1–R1.4) + +**Feature Branch**: `101-golden-master-baseline-governance-v1` +**Created**: 2026-02-19 +**Status**: Draft +**Input**: Introduce a workspace-owned “Golden Master” baseline that can be captured from a tenant, compared against the current tenant state, and surfaced in the UI as “Soll vs Ist” with drift findings and an operational summary. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: Admin Governance “Baselines” area; tenant-facing dashboard card; drift comparison landing (“Soll vs Ist”) +- **Data Ownership**: Baselines and snapshots are workspace-owned; tenant assignments are tenant-owned (workspace_id + tenant_id); drift findings are tenant-owned (workspace_id + tenant_id) +- **RBAC**: workspace membership required for any visibility; capability gating for view vs manage (see Requirements) + +## Clarifications + +### Session 2026-02-19 + +- Q: Which v1 drift severity mapping should we use? → A: Fixed mapping: missing_policy=high, different_version=medium, unexpected_policy=low. +- Q: Which snapshot should compare runs use in v1? → A: Always use BaselineProfile.active_snapshot; if missing, block compare with a clear reason. +- Q: How should compare/capture handle precondition failures? → A: UI disables where possible AND server blocks start with 422 + stable reason_code; no OperationRun is created for failed preconditions. +- Q: Where should drift findings be stored in v1? → A: Use existing findings storage with source=baseline.compare; fingerprint/idempotency lives there. +- Q: How should tenant override filters combine with the profile filter? → A: Override narrows scope; effective filter = profile ∩ override. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Create and manage a baseline profile (Priority: P1) + +As a workspace governance owner/manager, I can define what “good” looks like by creating a Baseline Profile, controlling its status (draft/active/archived), and scoping which policy domains are included. + +**Why this priority**: Without a baseline profile, there is nothing to capture or compare against. + +**Independent Test**: A user with baseline manage rights can create/edit/archive a baseline profile and see it listed; a read-only user can list/view but cannot mutate. + +**Acceptance Scenarios**: + +1. **Given** I am a workspace member with baseline manage capability, **When** I create a baseline profile with a name, optional description, optional version label, and a policy-domain filter, **Then** the profile is persisted and visible in the Baselines list. +2. **Given** I am a workspace member with baseline view-only capability, **When** I open a baseline profile, **Then** I can view its details but cannot edit/archive/delete it. +3. **Given** I am not a member of the workspace, **When** I attempt to access baseline pages, **Then** I receive a not-found response (deny-as-not-found). + +--- + +### User Story 2 - Capture an immutable baseline snapshot from a tenant (Priority: P2) + +As a baseline manager, I can capture a snapshot of the current tenant configuration that the baseline profile covers, producing an immutable “baseline snapshot” that can later be compared. + +**Why this priority**: The baseline must be based on a real, point-in-time state to be meaningful and auditable. + +**Independent Test**: Capturing twice with unchanged tenant state reuses the same snapshot identity and does not create duplicates. + +**Acceptance Scenarios**: + +1. **Given** a baseline profile exists and I have baseline manage capability, **When** I trigger “Capture from tenant” and choose a source tenant, **Then** a new capture operation is created and eventually produces an immutable snapshot. +2. **Given** a capture was already completed for the same baseline profile and the tenant’s relevant policies are unchanged, **When** I capture again, **Then** the system reuses the existing snapshot (idempotent/deduped). +3. **Given** the baseline profile is active, **When** a capture completes successfully, **Then** the profile’s “active snapshot” points to the captured snapshot. + +--- + +### User Story 3 - Compare baseline vs current tenant to detect drift (Priority: P3) + +As an operator/manager, I can run “Compare now” for a tenant, and the system produces drift findings and a summary that can be used for assurance and triage. + +**Why this priority**: Drift detection is the core governance signal; it makes the baseline actionable. + +**Independent Test**: A compare run produces findings for missing policies, different versions, and unexpected policies, and stores a compact summary. + +**Acceptance Scenarios**: + +1. **Given** a tenant is assigned to an active baseline profile with an active snapshot, **When** I run “Compare now”, **Then** a compare operation runs and produces drift findings and a drift summary. +2. **Given** the same drift item is detected in repeated compares, **When** compares are run multiple times, **Then** the same finding is updated (idempotent fingerprint) rather than duplicated. +3. **Given** I am a workspace member without baseline view capability, **When** I try to start a compare, **Then** the request is forbidden. + +### Edge Cases + +- Baseline profile is draft or archived: compare is blocked; users are told what must be changed (e.g., “activate baseline”). +- Tenant has no baseline assignment: compare button is disabled and the UI explains why. +- Baseline profile has no active snapshot yet: compare is blocked with a clear reason. +- Concurrent operation starts: the system prevents multiple “active” captures/compares for the same scope. +- Baseline filter yields no relevant policies: capture creates an empty snapshot and compare returns “no items checked”, without errors. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Baseline Profiles)**: The system MUST allow workspace baseline managers to create, edit, activate, deactivate (return to draft), and archive baseline profiles. Status transitions: draft ↔ active → archived (archived is terminal in v1). +- **FR-002 (Scope Control)**: Each baseline profile MUST define which policy domains/types are included in the baseline. +- **FR-003 (Tenant Assignment)**: The system MUST support assigning exactly one baseline profile per tenant per workspace (v1), with an optional per-tenant override of the profile’s scope; in v1 the override may only narrow scope (effective filter = profile ∩ override). +- **FR-004 (Capture as Operation)**: Capturing a baseline snapshot MUST be tracked as an observable operation with a clear lifecycle (started/completed/failed). +- **FR-005 (Immutable Snapshots)**: Baseline snapshots and their snapshot items MUST be immutable once created. +- **FR-006 (Capture Idempotency)**: Captures MUST be deduplicated so that repeated captures with the same effective content reuse the existing snapshot identity. +- **FR-007 (Compare as Operation)**: Comparing a tenant against its baseline MUST be tracked as an observable operation with a clear lifecycle. +- **FR-008 (Drift Findings)**: Compare MUST produce drift findings using at least these drift types: missing baseline policy, different version, and unexpected policy within the baseline scope. +- **FR-009 (Severity Rules)**: Drift findings MUST be assigned severities using centrally defined rules so that severity is consistent and testable; v1 fixed mapping is: missing baseline policy = high, different version = medium, unexpected policy = low. +- **FR-010 (Finding Idempotency)**: Drift findings MUST be deduplicated with a stable fingerprint so repeated compares update existing open findings instead of creating duplicates. +- **FR-011 (Summary Output)**: Each compare operation MUST persist a summary containing totals and severity breakdowns suitable for dashboards. +- **FR-012 (UI “Soll vs Ist”)**: The UI MUST allow selecting a baseline profile and viewing the latest compare runs and drift findings for a tenant. +- **FR-013 (Compare Snapshot Selection)**: Compare runs MUST always use the baseline profile’s active snapshot; if no active snapshot exists, compare MUST be blocked with a clear reason. +- **FR-014 (Precondition Failure Contract)**: When capture/compare cannot start due to unmet preconditions, the UI MUST disable the action where possible, and the server MUST reject the request with HTTP 422 containing a stable `reason_code`; in this case, the system MUST NOT create an OperationRun. +- **FR-015 (Findings Storage)**: Drift findings produced by compare MUST be persisted using the existing findings system with `source = baseline.compare`. + +### Precondition `reason_code` (v1) + +- `baseline.compare.no_assignment` +- `baseline.compare.profile_not_active` +- `baseline.compare.no_active_snapshot` +- `baseline.capture.missing_source_tenant` + +### Constitution Alignment: Safety, Isolation, Observability + +- **Ops/Observability**: Capture and compare MUST be observable and auditable operations, and surfaced in the existing operations monitoring experience. +- **DB-only UI**: Baseline pages and drift pages MUST NOT require outbound network calls during page render or user clicks that only start/view operations; external calls (if any) must happen in background work. +- **Tenant Isolation / RBAC semantics**: + - non-member of workspace or tenant scope → deny-as-not-found (404) + - member but missing capability → forbidden (403) +- **Least privilege**: Two capabilities MUST exist and be enforced: + - baseline view: can view baselines and start compare operations + - baseline manage: can manage baselines, assignments, and capture snapshots +- **Auditability**: Baseline-related operations MUST emit audit log entries for started/completed/failed events. +- **Safe logging**: Failures MUST not include secrets or sensitive tenant data; failure reasons must be stable and suitable for support triage. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Baseline Profiles | Admin → Governance → Baselines | Create baseline profile | View/Edit page available | View, Edit | Archive (grouped) | Create baseline profile | Capture from tenant; Activate/Deactivate; Assign to tenants | Save, Cancel | Yes | Destructive actions require confirmation; mutations are manage-gated; hard-delete is out of scope for v1 (archive only) | +| Drift Landing (“Soll vs Ist”) | Tenant view | Run compare now | Links to last compare operation and findings list | — | — | — | — | — | Yes | Starting compare is view-gated; results visible only to entitled users | +| Tenant Dashboard Card | Tenant dashboard | Run compare now | Click to drift landing and/or last compare operation | — | — | — | — | — | Yes | Button disabled with explanation when no assignment or no active snapshot | + +## Key Entities *(include if feature involves data)* + +- **Baseline Profile**: A workspace-owned definition of what should be in-scope for governance, with a lifecycle status (draft/active/archived). +- **Tenant Assignment**: A workspace-managed mapping that declares which baseline applies to a tenant, optionally overriding scope. +- **Baseline Snapshot**: An immutable point-in-time record of the baseline’s in-scope policy references captured from a tenant. +- **Snapshot Item**: A single baseline entry representing one in-scope policy reference in the snapshot. +- **Drift Finding**: A record representing an observed deviation between baseline and tenant state, deduplicated by a stable fingerprint. +- **Drift Finding Source**: Drift findings produced by this feature use `source = baseline.compare`. +- **Operation Summary**: A compact, persisted summary of a capture/compare run suitable for dashboard display. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A baseline manager can create a baseline profile and perform an initial capture in under 5 minutes. +- **SC-002**: For a typical tenant (≤ 500 in-scope policies), a compare run completes and surfaces a summary within 2 minutes. +- **SC-003**: Re-running compare within 24 hours for unchanged drift does not create duplicate findings (0 duplicate drift fingerprints). +- **SC-004**: Unauthorized users (non-members) receive no baseline visibility (deny-as-not-found) and members without capability cannot mutate (forbidden). + +## Non-Goals (v1) + +- Evidence packs / stored reports for audit exports +- Advanced findings workflow (exceptions, auto-closing, recurrence handling) +- Cross-tenant portfolio comparisons +- Baseline inheritance across organizations (e.g., MSP → customer) +- Assignment/scope-tag baselines beyond the policy domains/types included in the profile scope + +## Assumptions + +- Tenants already have an inventory of policy versions that can be referenced for capture and compare. +- An operations monitoring experience exists where capture/compare runs can be viewed. +- A drift findings system exists that can store and display findings and severities. + +## Dependencies + +- Inventory + policy version history is available and trustworthy. +- Operation run tracking and monitoring is available. +- RBAC + UI enforcement semantics are already established (404 for non-member, 403 for missing capability). +- Alerts are optional for v1; the feature remains valuable without alert integrations. diff --git a/specs/101-golden-master-baseline-governance-v1/tasks.md b/specs/101-golden-master-baseline-governance-v1/tasks.md new file mode 100644 index 0000000..51c6c07 --- /dev/null +++ b/specs/101-golden-master-baseline-governance-v1/tasks.md @@ -0,0 +1,209 @@ +--- + +description: "Task breakdown for implementing Feature 101" +--- + +# Tasks: Golden Master / Baseline Governance v1 + +**Input**: Design documents from `specs/101-golden-master-baseline-governance-v1/` + +**Key constraints (from spec/plan)** +- Filament v5 + Livewire v4.0+ only. +- UI surfaces are DB-only at render time (no outbound HTTP). +- Capture/compare start surfaces are enqueue-only (no remote work inline). +- Precondition failures return **HTTP 422** with stable `reason_code` and **must not** create an `OperationRun`. +- RBAC semantics: non-member → **404** (deny-as-not-found); member but missing capability → **403**. +- Findings use sha256 64-char fingerprints (reuse `App\Services\Drift\DriftHasher`). + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Ensure the repo is ready for implementation + verification. + +- [ ] T001 Verify feature docs are in place in specs/101-golden-master-baseline-governance-v1/ +- [ ] T002 Verify local dev prerequisites via quickstart in specs/101-golden-master-baseline-governance-v1/quickstart.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core primitives used across all user stories (schema, capabilities, operation UX). + +**Checkpoint**: After this phase, US1/US2/US3 work can proceed. + +- [ ] T003 Add baseline capabilities to app/Support/Auth/Capabilities.php (`workspace_baselines.view`, `workspace_baselines.manage`) +- [ ] T004 Add baseline capabilities to app/Services/Auth/WorkspaceRoleCapabilityMap.php (Owner/Manager = manage; Operator/Readonly = view) +- [ ] T005 [P] Add operation labels for baseline_capture + baseline_compare in app/Support/OperationCatalog.php +- [ ] T006 [P] Extend summary keys to include severity breakdown keys in app/Support/OpsUx/OperationSummaryKeys.php (add `high`, `medium`, `low`) +- [ ] T007 Create baseline schema migrations in database/migrations/*_create_baseline_profiles_table.php (workspace-owned) +- [ ] T008 Create baseline schema migrations in database/migrations/*_create_baseline_snapshots_table.php (workspace-owned) +- [ ] T009 Create baseline schema migrations in database/migrations/*_create_baseline_snapshot_items_table.php (workspace-owned) +- [ ] T010 Create baseline schema migrations in database/migrations/*_create_baseline_tenant_assignments_table.php (tenant-owned) +- [ ] T011 Create findings source migration in database/migrations/*_add_source_to_findings_table.php +- [ ] T012 [P] Add BaselineProfile model in app/Models/BaselineProfile.php (casts for scope_jsonb; status constants/enum) +- [ ] T013 [P] Add BaselineSnapshot model in app/Models/BaselineSnapshot.php (casts for summary_jsonb) +- [ ] T014 [P] Add BaselineSnapshotItem model in app/Models/BaselineSnapshotItem.php (casts for meta_jsonb) +- [ ] T015 [P] Add BaselineTenantAssignment model in app/Models/BaselineTenantAssignment.php (casts for override_scope_jsonb) +- [ ] T016 [P] Update Finding model for new column in app/Models/Finding.php (fillable/casts for `source` as needed by existing conventions) +- [ ] T017 [P] Add baseline scope value object / helpers in app/Support/Baselines/BaselineScope.php (normalize + intersect profile/override) +- [ ] T018 [P] Add baseline reason codes in app/Support/Baselines/BaselineReasonCodes.php (constants for 422 responses) +- [ ] T018a [P] Add BadgeDomain::BaselineProfileStatus case to app/Support/Badges/BadgeDomain.php + create domain mapper class in app/Support/Badges/Domains/BaselineProfileStatusBadges.php + register in BadgeCatalog (BADGE-001 compliance for draft/active/archived) +- [ ] T018b [P] Add model factories: database/factories/BaselineProfileFactory.php, BaselineSnapshotFactory.php, BaselineSnapshotItemFactory.php, BaselineTenantAssignmentFactory.php (required for Pest tests in Phase 3–5) + +--- + +## Phase 3: User Story 1 — Baseline Profile CRUD (Priority: P1) 🎯 MVP + +**Goal**: Workspace managers define baseline profiles (scope + lifecycle) with correct RBAC and action surfaces. + +**Independent Test**: +- As a member with `workspace_baselines.manage`, create/edit/archive a profile. +- As a member with `workspace_baselines.view`, list/view but cannot mutate. +- As a non-member, baseline pages/actions deny-as-not-found (404). + +### Tests (required in this repo) + +- [ ] T019 [P] [US1] Add feature test scaffolding in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php +- [ ] T020 [P] [US1] Add 404 vs 403 semantics tests in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php +- [ ] T021 [P] [US1] Add Action Surface contract coverage for the new resource in tests/Feature/Guards/ActionSurfaceValidatorTest.php (resource discovered or explicitly whitelisted per existing test conventions) + +### Implementation + +- [ ] T022 [US1] Create Baseline Profile Filament resource in app/Filament/Resources/BaselineProfileResource.php (navigation group = Governance) +- [ ] T023 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php +- [ ] T024 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php +- [ ] T025 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +- [ ] T026 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php +- [ ] T027 [US1] Implement form/table schema in app/Filament/Resources/BaselineProfileResource.php (scope editor backed by App\Support\Inventory\InventoryPolicyTypeMeta) +- [ ] T028 [US1] Implement RBAC (404 vs 403) and UI enforcement in app/Filament/Resources/BaselineProfileResource.php using App\Services\Auth\WorkspaceCapabilityResolver + App\Support\Rbac\WorkspaceUiEnforcement +- [ ] T029 [US1] Implement audit logging for baseline profile mutations in app/Filament/Resources/BaselineProfileResource.php using App\Services\Audit\WorkspaceAuditLogger +- [ ] T030 [US1] Ensure the resource is safe for global search (either keep View/Edit pages enabled OR disable global search explicitly) in app/Filament/Resources/BaselineProfileResource.php + +**Checkpoint**: Baseline profiles CRUD works and is independently testable. + +--- + +## Phase 4: User Story 2 — Capture Immutable Baseline Snapshot (Priority: P2) + +**Goal**: Managers can enqueue capture from a tenant to create (or reuse) an immutable, workspace-owned snapshot. + +**Independent Test**: +- Trigger capture twice for unchanged effective content → the same `snapshot_identity_hash` is reused (no duplicates). +- If profile is active, capture success updates `active_snapshot_id`. + +### Tests (required in this repo) + +- [ ] T031 [P] [US2] Add capture enqueue + precondition tests in tests/Feature/Baselines/BaselineCaptureTest.php (include: empty-scope capture produces empty snapshot without errors [EC-005]) +- [ ] T032 [P] [US2] Add snapshot dedupe test in tests/Feature/Baselines/BaselineCaptureTest.php (include: concurrent capture request for same scope reuses active run [EC-004]) + +### Implementation + +- [ ] T033 [P] [US2] Add capture action UI to app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php (Action::make()->action()->requiresConfirmation(); select source tenant; gated by `workspace_baselines.manage` via WorkspaceUiEnforcement — disabled with tooltip for view-only members, server-side 403 on execution) +- [ ] T034 [US2] Add capture start service in app/Services/Baselines/BaselineCaptureService.php (precondition validation before OperationRun creation; enqueue via OperationRunService) +- [ ] T035 [P] [US2] Add snapshot identity helper in app/Services/Baselines/BaselineSnapshotIdentity.php (sha256 over normalized items; reuse DriftHasher::hashNormalized where appropriate) +- [ ] T036 [P] [US2] Add capture job in app/Jobs/CaptureBaselineSnapshotJob.php (DB-only reads; creates snapshot + items; updates profile active snapshot when applicable) +- [ ] T037 [US2] Add audit logging for capture started/completed/failed in app/Jobs/CaptureBaselineSnapshotJob.php using App\Services\Intune\AuditLogger +- [ ] T038 [US2] Persist run context + summary_counts for capture in app/Jobs/CaptureBaselineSnapshotJob.php (use only allowed summary keys) + +**Checkpoint**: Capture creates immutable snapshots and dedupes repeated capture. + +--- + +## Phase 5: User Story 3 — Assign + Compare (“Soll vs Ist”) (Priority: P3) + +**Goal**: Operators can assign a baseline to a tenant (v1: exactly one), then enqueue compare-now to generate drift findings + summary. + +**Independent Test**: +- With assignment + active snapshot: compare enqueues an operation and produces findings + summary. +- Re-running compare updates existing findings (same fingerprint) rather than duplicating. + +### Tests (required in this repo) + +- [ ] T039 [P] [US3] Add assignment CRUD tests (RBAC + uniqueness) in tests/Feature/Baselines/BaselineAssignmentTest.php +- [ ] T040 [P] [US3] Add compare precondition 422 tests (no OperationRun created) in tests/Feature/Baselines/BaselineComparePreconditionsTest.php (include: concurrent compare reuses active run [EC-004]; draft/archived profile blocks compare [EC-001]) +- [ ] T041 [P] [US3] Add compare idempotent finding fingerprint tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php +- [ ] T042 [P] [US3] Add compare summary_counts severity breakdown tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php + +### Implementation — Assignment (v1) + +- [ ] T043 [P] [US3] Add BaselineTenantAssignments relation manager in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +- [ ] T044 [US3] Enforce assignment RBAC (404 vs 403) and audits in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php (manage-gated mutations; view-gated visibility) +- [ ] T045 [US3] Implement effective scope validation (override narrows only) in app/Services/Baselines/BaselineScope.php + +### Implementation — Tenant landing + dashboard card + +- [ ] T046 [P] [US3] Add tenant landing page in app/Filament/Pages/BaselineCompareLanding.php (navigation label “Soll vs Ist”; DB-only) +- [ ] T047 [P] [US3] Add landing view in resources/views/filament/pages/baseline-compare-landing.blade.php (shows assignment state, last run link, findings link) +- [ ] T048 [P] [US3] Add tenant dashboard card widget in app/Filament/Widgets/Dashboard/BaselineCompareNow.php +- [ ] T049 [US3] Wire the widget into app/Filament/Pages/TenantDashboard.php (add widget class to getWidgets()) + +### Implementation — Compare engine + job + +- [ ] T050 [US3] Add compare start service in app/Services/Baselines/BaselineCompareService.php (preconditions; enqueue OperationRun; freeze snapshot_id in run context) +- [ ] T051 [P] [US3] Add compare job in app/Jobs/CompareBaselineToTenantJob.php (DB-only; compute drift items; upsert findings with `source='baseline.compare'`) +- [ ] T052 [US3] Implement fingerprinting + idempotent upsert in app/Jobs/CompareBaselineToTenantJob.php (use App\Services\Drift\DriftHasher::fingerprint) +- [ ] T053 [US3] Implement severity mapping (missing=high, different=medium, unexpected=low) in app/Jobs/CompareBaselineToTenantJob.php +- [ ] T054 [US3] Persist run summary_counts with totals + severity breakdown in app/Jobs/CompareBaselineToTenantJob.php (requires T006) +- [ ] T055 [US3] Add audit logging for compare started/completed/failed in app/Jobs/CompareBaselineToTenantJob.php using App\Services\Intune\AuditLogger + +**Checkpoint**: Tenant “Soll vs Ist” UI works end-to-end and compare generates deduped findings. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [ ] T056 [P] Add operation duration hinting for new operation types in app/Support/OperationCatalog.php (expectedDurationSeconds for baseline_capture/baseline_compare) +- [ ] T057 Ensure all destructive actions have confirmation in app/Filament/Resources/BaselineProfileResource.php and app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +- [ ] T058 Run formatting on touched files via `vendor/bin/sail bin pint --dirty` +- [ ] T059 Run focused test suite via `vendor/bin/sail artisan test --compact tests/Feature/Baselines` +- [ ] T060 Run the quickstart walkthrough in specs/101-golden-master-baseline-governance-v1/quickstart.md and adjust any mismatches; spot-check SC-001 (create+capture < 5 min) and SC-002 (compare < 2 min for ≤ 500 policies) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 (Setup) → Phase 2 (Foundational) → US phases +- Phase 2 blocks US1/US2/US3. + +### User Story Dependencies + +- **US1 (P1)** is the MVP and enables profile CRUD. +- **US2 (P2)** depends on US1 (needs profiles) and Phase 2 schema/services. +- **US3 (P3)** depends on US1 (needs profiles) and Phase 2 schema; compare also depends on US2 having produced an active snapshot. + +--- + +## Parallel Execution Examples + +### US1 parallelizable work + +- T023, T024, T025, T026 (resource pages) can be implemented in parallel. +- T019 and T020 (authorization tests) can be implemented in parallel with the resource skeleton. + +### US2 parallelizable work + +- T035 (snapshot identity helper) and T036 (capture job) can be implemented in parallel once the schema exists. +- T031–T032 tests can be written before the job/service implementation. + +### US3 parallelizable work + +- T046–T048 (landing page + view + widget) can be done in parallel with T050–T054 (compare service/job). +- T039–T042 tests can be written before implementation. + +--- + +## Implementation Strategy + +### MVP First (US1 only) + +1. Complete Phase 1 + Phase 2 +2. Deliver Phase 3 (US1) +3. Run: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` + +### Incremental delivery + +- Add US2 (capture) → validate dedupe + active snapshot updates +- Add US3 (assign + compare) → validate 422 contract + idempotent findings + summary output