From a30be840849c4a697c9976eda77467ac847a8806 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 19 Feb 2026 23:56:09 +0000 Subject: [PATCH] Baseline governance UX polish + view Infolist (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - Baseline Compare landing: enterprise UI (stats grid, critical drift banner, better actions), navigation grouping under Governance, and Action Surface Contract declaration. - Baseline Profile view page: switches from disabled form fields to proper Infolist entries for a clean read-only view. - Fixes tenant name column usages (`display_name` → `name`) in baseline assignment flows. - Dashboard: improved baseline governance widget with severity breakdown + last compared. Notes: - Filament v5 / Livewire v4 compatible. - Destructive actions remain confirmed (`->requiresConfirmation()`). Tests: - `vendor/bin/sail artisan test --compact tests/Feature/Baselines` - `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/123 --- .agents/skills/pest-testing/SKILL.md | 167 +++++++ .../skills/tailwindcss-development/SKILL.md | 129 ++++++ .codex/config.toml | 4 + .github/agents/copilot-instructions.md | 3 +- .github/skills/pest-testing/SKILL.md | 167 +++++++ .../skills/tailwindcss-development/SKILL.md | 129 ++++++ .specify/memory/constitution.md | 49 ++- .specify/templates/plan-template.md | 3 +- .specify/templates/spec-template.md | 6 +- .specify/templates/tasks-template.md | 8 + Agents.md | 357 +++++---------- GEMINI.md | 357 +++++---------- app/Filament/Pages/BaselineCompareLanding.php | 298 +++++++++++++ app/Filament/Pages/DriftLanding.php | 2 +- app/Filament/Pages/TenantDashboard.php | 2 + .../Resources/BaselineProfileResource.php | 396 +++++++++++++++++ .../Pages/CreateBaselineProfile.php | 56 +++ .../Pages/EditBaselineProfile.php | 48 ++ .../Pages/ListBaselineProfiles.php | 23 + .../Pages/ViewBaselineProfile.php | 142 ++++++ ...selineTenantAssignmentsRelationManager.php | 246 +++++++++++ app/Filament/Resources/FindingResource.php | 2 +- .../Widgets/Dashboard/BaselineCompareNow.php | 90 ++++ app/Jobs/CaptureBaselineSnapshotJob.php | 285 ++++++++++++ app/Jobs/CompareBaselineToTenantJob.php | 394 +++++++++++++++++ app/Models/BaselineProfile.php | 50 +++ app/Models/BaselineSnapshot.php | 35 ++ app/Models/BaselineSnapshotItem.php | 23 + app/Models/BaselineTenantAssignment.php | 40 ++ app/Providers/Filament/AdminPanelProvider.php | 2 + .../Auth/WorkspaceRoleCapabilityMap.php | 6 + .../Baselines/BaselineCaptureService.php | 79 ++++ .../Baselines/BaselineCompareService.php | 97 ++++ .../Baselines/BaselineSnapshotIdentity.php | 59 +++ app/Support/Audit/AuditActionId.php | 13 + 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 + boost.json | 16 +- composer.json | 2 +- composer.lock | 30 +- 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 + .../pages/baseline-compare-landing.blade.php | 188 ++++++++ .../dashboard/baseline-compare-now.blade.php | 68 +++ .../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 +++++++++ .../Baselines/BaselineAssignmentTest.php | 126 ++++++ .../Feature/Baselines/BaselineCaptureTest.php | 349 +++++++++++++++ .../Baselines/BaselineCompareFindingsTest.php | 414 ++++++++++++++++++ .../BaselineComparePreconditionsTest.php | 178 ++++++++ .../BaselineProfileAuthorizationTest.php | 156 +++++++ .../Guards/ActionSurfaceContractTest.php | 20 + 71 files changed, 6331 insertions(+), 520 deletions(-) create mode 100644 .agents/skills/pest-testing/SKILL.md create mode 100644 .agents/skills/tailwindcss-development/SKILL.md create mode 100644 .codex/config.toml create mode 100644 .github/skills/pest-testing/SKILL.md create mode 100644 .github/skills/tailwindcss-development/SKILL.md create mode 100644 app/Filament/Pages/BaselineCompareLanding.php create mode 100644 app/Filament/Resources/BaselineProfileResource.php create mode 100644 app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php create mode 100644 app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php create mode 100644 app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php create mode 100644 app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php create mode 100644 app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php create mode 100644 app/Filament/Widgets/Dashboard/BaselineCompareNow.php create mode 100644 app/Jobs/CaptureBaselineSnapshotJob.php create mode 100644 app/Jobs/CompareBaselineToTenantJob.php 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/Services/Baselines/BaselineCaptureService.php create mode 100644 app/Services/Baselines/BaselineCompareService.php create mode 100644 app/Services/Baselines/BaselineSnapshotIdentity.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 resources/views/filament/pages/baseline-compare-landing.blade.php create mode 100644 resources/views/filament/widgets/dashboard/baseline-compare-now.blade.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 create mode 100644 tests/Feature/Baselines/BaselineAssignmentTest.php create mode 100644 tests/Feature/Baselines/BaselineCaptureTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareFindingsTest.php create mode 100644 tests/Feature/Baselines/BaselineComparePreconditionsTest.php create mode 100644 tests/Feature/Baselines/BaselineProfileAuthorizationTest.php diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 0000000..5619861 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,167 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## When to Apply + +Activate this skill when: + +- Creating new tests (unit, feature, or browser) +- Modifying existing tests +- Debugging test failures +- Working with browser testing or smoke testing +- Writing architecture tests or visual regression tests + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,129 @@ +--- +name: tailwindcss-development +description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## When to Apply + +Activate this skill when: + +- Adding styles to components or pages +- Working with responsive design +- Implementing dark mode +- Extracting repeated patterns into components +- Debugging spacing or layout issues + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..795d550 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,4 @@ +[mcp_servers.laravel-boost] +command = "vendor/bin/sail" +args = ["artisan", "boost:mcp"] +cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas" 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/.github/skills/pest-testing/SKILL.md b/.github/skills/pest-testing/SKILL.md new file mode 100644 index 0000000..5619861 --- /dev/null +++ b/.github/skills/pest-testing/SKILL.md @@ -0,0 +1,167 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## When to Apply + +Activate this skill when: + +- Creating new tests (unit, feature, or browser) +- Modifying existing tests +- Debugging test failures +- Working with browser testing or smoke testing +- Writing architecture tests or visual regression tests + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file diff --git a/.github/skills/tailwindcss-development/SKILL.md b/.github/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.github/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,129 @@ +--- +name: tailwindcss-development +description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## When to Apply + +Activate this skill when: + +- Adding styles to components or pages +- Working with responsive design +- Implementing dark mode +- Extracting repeated patterns into components +- Debugging spacing or layout issues + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index e17ec6f..3b66104 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,22 +1,19 @@ # TenantPilot Constitution @@ -179,7 +176,7 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE) - Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column. - Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows. - View/Detail MUST define Header Actions (Edit + “More” group when applicable). -- View/Detail SHOULD be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists. +- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists. - Create/Edit MUST provide consistent Save/Cancel UX. Grouping & safety @@ -199,6 +196,38 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE) - A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason. - CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing. +### Filament UI — Layout & Information Architecture Standards (UX-001) + +Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens. + +Page layout +- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`. +- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level. +- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps). +- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists. + +View pages +- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms. +- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001). +- Long text MUST render as readable prose (not textarea styling). + +Empty states +- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state. +- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state. + +Actions & flows +- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup). +- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation). +- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005). + +Table work-surface defaults +- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range). +- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings. + +Enforcement +- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent. +- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR. + Spec Scope Fields (SCOPE-002) - Every feature spec MUST declare: @@ -245,4 +274,4 @@ ### Versioning Policy (SemVer) - **MINOR**: new principle/section or materially expanded guidance. - **MAJOR**: removing/redefining principles in a backward-incompatible way. -**Version**: 1.8.2 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-14 +**Version**: 1.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-19 diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index a0dd948..17afc7a 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -44,8 +44,7 @@ ## Constitution Check - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests -- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - +- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency ## Project Structure ### Documentation (this feature) diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index 2fe1fe8..ce057d6 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -115,7 +115,11 @@ ## Requirements *(mandatory)* **Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied. If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. - +**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen, +the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards +(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific +title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions. +If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale. +```php protected function isAccessible(User $user, ?string $path = null): bool { ... } - - -## Comments -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on. - -## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. +``` ## Enums + - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +## Comments + +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. + +## PHPDoc Blocks + +- Add useful array shape type definitions when appropriate. + === sail rules === -## Laravel Sail +# Laravel Sail - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. @@ -770,20 +821,21 @@ ## Laravel Sail === tests rules === -## Test Enforcement +# Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way - Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database +## Database + - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Use Eloquent models and relationships before suggesting raw database queries. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. @@ -791,43 +843,53 @@ ### Database - Use Laravel's query builder for very complex database operations. ### Model Creation + - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. ### APIs & Eloquent Resources + - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Controllers & Validation +## Controllers & Validation + - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Check sibling Form Requests to see if the application uses array or string based validation rules. -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +## Authentication & Authorization -### Authentication & Authorization - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). -### URL Generation +## URL Generation + - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### Testing +## Testing + - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error +## Vite Error + - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version-specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure +## Laravel 12 Structure + - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. - Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. @@ -835,224 +897,39 @@ ### Laravel 12 Structure - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. -### Database +## Database + - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models + - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. -=== livewire/core rules === - -## Livewire - -- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests. -- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components. -- State should live on the server, with the UI reflecting it. -- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions. - -## Livewire Best Practices -- Livewire components require a single root element. -- Use `wire:loading` and `wire:dirty` for delightful loading states. -- Add `wire:key` in loops: - - ```blade - @foreach ($items as $item) -
- {{ $item->name }} -
- @endforeach - ``` - -- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - - - public function mount(User $user) { $this->user = $user; } - public function updatedSearch() { $this->resetPage(); } - - -## Testing Livewire - - - Livewire::test(Counter::class) - ->assertSet('count', 0) - ->call('increment') - ->assertSet('count', 1) - ->assertSee(1) - ->assertStatus(200); - - - - $this->get('/posts/create') - ->assertSeeLivewire(CreatePost::class); - - === pint/core rules === -## Laravel Pint Code Formatter +# Laravel Pint Code Formatter -- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. +- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues. === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. -### Pest Tests -- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `vendor/bin/sail artisan test --compact`. -- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - - -=== pest/v4 rules === - -## Pest 4 - -- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. -- Browser testing is incredibly powerful and useful for this project. -- Browser tests should live in `tests/Browser/`. -- Use the `search-docs` tool for detailed guidance on utilizing these features. - -### Browser Testing -- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. -- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. -- If requested, test on multiple browsers (Chrome, Firefox, Safari). -- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging when appropriate. - -### Example Tests - - -it('may reset the password', function () { - Notification::fake(); - - $this->actingAs(User::factory()->create()); - - $page = visit('/sign-in'); // Visit on a real browser... - - $page->assertSee('Sign In') - ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!') - - Notification::assertSent(ResetPassword::class); -}); - - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); - +- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`. +- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. +- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. === tailwindcss/core rules === -## Tailwind CSS +# Tailwind CSS -- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own. -- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.). -- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically. -- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - -### Spacing -- When listing items, use gap utilities for spacing; don't use margins. - - -
-
Superior
-
Michigan
-
Erie
-
-
- -### Dark Mode -- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - -=== tailwindcss/v4 rules === - -## Tailwind CSS 4 - -- Always use Tailwind CSS v4; do not use the deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. -- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. - - -@theme { - --color-brand: oklch(0.72 0.11 178); -} - - -- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - - - - @tailwind base; - - @tailwind components; - - @tailwind utilities; - + @import "tailwindcss"; - - -### Replaced Utilities -- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement. -- Opacity values are still numeric. - -| Deprecated | Replacement | -|------------+--------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | +- Always use existing Tailwind conventions; check project patterns before adding new ones. +- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. +- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. ## Active Technologies diff --git a/GEMINI.md b/GEMINI.md index 1ed6555..f03eea5 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -229,6 +229,7 @@ ## Reference Materials === .ai/filament-v5-blueprint rules === ## Source of Truth + If any Filament behavior is uncertain, lookup the exact section in: - docs/research/filament-v5-notes.md and prefer that over guesses. @@ -238,6 +239,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES) # Filament Blueprint (v5) ## 1) Non-negotiables + - Filament v5 requires Livewire v4.0+. - Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`). - Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results. @@ -253,6 +255,7 @@ ## 1) Non-negotiables - https://filamentphp.com/docs/5.x/styling/css-hooks ## 2) Directory & naming conventions + - Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture. - Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`. @@ -261,6 +264,7 @@ ## 2) Directory & naming conventions - https://filamentphp.com/docs/5.x/advanced/modular-architecture ## 3) Panel setup defaults + - Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels. - Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel. - Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review. @@ -274,6 +278,7 @@ ## 3) Panel setup defaults - https://filamentphp.com/docs/5.x/advanced/assets ## 4) Navigation & information architecture + - Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately. - Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows. - Treat cluster code structure as a recommendation (organizational benefit), not a required rule. @@ -287,6 +292,7 @@ ## 4) Navigation & information architecture - https://filamentphp.com/docs/5.x/navigation/user-menu ## 5) Resource patterns + - Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows. - Global search: - If a resource is intended for global search: ensure Edit/View page exists. @@ -299,6 +305,7 @@ ## 5) Resource patterns - https://filamentphp.com/docs/5.x/resources/global-search ## 6) Page lifecycle & query rules + - Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading. - Prefer render hooks for layout injection; avoid publishing internal views. @@ -307,6 +314,7 @@ ## 6) Page lifecycle & query rules - https://filamentphp.com/docs/5.x/advanced/render-hooks ## 7) Infolists vs RelationManagers (decision tree) + - Interactive CRUD / attach / detach under owner record → RelationManager. - Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields. - Inline CRUD inside owner form → Repeater. @@ -317,6 +325,7 @@ ## 7) Infolists vs RelationManagers (decision tree) - https://filamentphp.com/docs/5.x/infolists/overview ## 8) Form patterns (validation, reactivity, state) + - Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side. - Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns). - Custom field views must obey state binding modifiers. @@ -326,6 +335,7 @@ ## 8) Form patterns (validation, reactivity, state) - https://filamentphp.com/docs/5.x/forms/custom-fields ## 9) Table & action patterns + - Tables: always define a meaningful empty state (and empty-state actions where appropriate). - Actions: - Execution actions use `->action(...)`. @@ -338,6 +348,7 @@ ## 9) Table & action patterns - https://filamentphp.com/docs/5.x/actions/modals ## 10) Authorization & security + - Enforce panel access in non-local environments as documented. - UI visibility is not security; enforce policies/access checks in addition to hiding UI. - Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization. @@ -347,6 +358,7 @@ ## 10) Authorization & security - https://filamentphp.com/docs/5.x/resources/deleting-records ## 11) Notifications & UX feedback + - Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious. - Treat polling as a cost; set intervals intentionally where polling is used. @@ -355,6 +367,7 @@ ## 11) Notifications & UX feedback - https://filamentphp.com/docs/5.x/widgets/stats-overview ## 12) Performance defaults + - Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies. - Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing. @@ -364,6 +377,7 @@ ## 12) Performance defaults - https://filamentphp.com/docs/5.x/advanced/render-hooks ## 13) Testing requirements + - Test pages/relation managers/widgets as Livewire components. - Test actions using Filament’s action testing guidance. - Do not mount non-Livewire classes in Livewire tests. @@ -373,6 +387,7 @@ ## 13) Testing requirements - https://filamentphp.com/docs/5.x/testing/testing-actions ## 14) Forbidden patterns + - Mixing Filament v3/v4 APIs into v5 code. - Any mention of Livewire v3 for Filament v5. - Registering panel providers in `bootstrap/app.php` on Laravel 11+. @@ -387,6 +402,7 @@ ## 14) Forbidden patterns - https://filamentphp.com/docs/5.x/advanced/assets ## 15) Agent output contract + For any implementation request, the agent must explicitly state: 1) Livewire v4.0+ compliance. 2) Provider registration location (Laravel 11+: `bootstrap/providers.php`). @@ -407,6 +423,7 @@ ## 15) Agent output contract # SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES) ## Version Safety + - [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere). - Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire” - [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs). @@ -414,6 +431,7 @@ ## Version Safety - Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements” ## Panel & Navigation + - [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`). - Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel” - [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`). @@ -428,6 +446,7 @@ ## Panel & Navigation - Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction” ## Resource Structure + - [ ] `$recordTitleAttribute` is set for any resource intended for global search. - Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles” - [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it. @@ -436,18 +455,21 @@ ## Resource Structure - Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results” ## Infolists & Relations + - [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction. - Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job” - [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification. - Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading” ## Forms + - [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily. - Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur” - [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers). - Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers” ## Tables & Actions + - [ ] Tables define a meaningful empty state (and empty-state actions where appropriate). - Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions” - [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`. @@ -456,6 +478,7 @@ ## Tables & Actions - Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals” ## Authorization & Security + - [ ] Panel access is enforced for non-local environments as documented. - Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel” - [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries. @@ -463,24 +486,28 @@ ## Authorization & Security - Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization” ## UX & Notifications + - [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious. - Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction” - [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load. - Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)” ## Performance + - [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate. - Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript” - [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default). - Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes” ## Testing + - [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes. - Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?” - [ ] Actions that mutate data are covered using Filament’s action testing guidance. - Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions” ## Deployment / Ops + - [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” @@ -488,12 +515,13 @@ ## Deployment / Ops # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.4.15 +- php - 8.4.1 - filament/filament (FILAMENT) - v5 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 @@ -506,56 +534,73 @@ ## Foundational Context - phpunit/phpunit (PHPUNIT) - v12 - tailwindcss (TAILWINDCSS) - v4 +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. +- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. + ## Conventions + - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture + - Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling + - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. - ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + === boost rules === -## Laravel Boost +# Laravel Boost + - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. ## Artisan + - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. ## URLs + - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. ## Tinker / Debugging + - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - Use the `database-query` tool when you only need to read from the database. +- Use the `database-schema` tool to inspect table structure before writing migrations or models. ## Reading Browser Logs With the `browser-logs` Tool + - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - Only recent browser logs will be useful - ignore old logs. ## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches. + +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. ### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". @@ -565,38 +610,44 @@ ### Available Search Syntax === php rules === -## PHP +# PHP -- Always use curly braces for control structures, even if it has one line. +- Always use curly braces for control structures, even for single-line bodies. + +## Constructors -### Constructors - Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } + - `public function __construct(public GitHub $github) { }` - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. -### Type Declarations +## Type Declarations + - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. - + +```php protected function isAccessible(User $user, ?string $path = null): bool { ... } - - -## Comments -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on. - -## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. +``` ## Enums + - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +## Comments + +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. + +## PHPDoc Blocks + +- Add useful array shape type definitions when appropriate. + === sail rules === -## Laravel Sail +# Laravel Sail - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. @@ -610,20 +661,21 @@ ## Laravel Sail === tests rules === -## Test Enforcement +# Test Enforcement - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way - Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database +## Database + - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Use Eloquent models and relationships before suggesting raw database queries. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. @@ -631,43 +683,53 @@ ### Database - Use Laravel's query builder for very complex database operations. ### Model Creation + - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. ### APIs & Eloquent Resources + - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Controllers & Validation +## Controllers & Validation + - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Check sibling Form Requests to see if the application uses array or string based validation rules. -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +## Authentication & Authorization -### Authentication & Authorization - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). -### URL Generation +## URL Generation + - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### Testing +## Testing + - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error +## Vite Error + - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version-specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure +## Laravel 12 Structure + - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. - Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. @@ -675,224 +737,39 @@ ### Laravel 12 Structure - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. -### Database +## Database + - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models + - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. -=== livewire/core rules === - -## Livewire - -- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests. -- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components. -- State should live on the server, with the UI reflecting it. -- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions. - -## Livewire Best Practices -- Livewire components require a single root element. -- Use `wire:loading` and `wire:dirty` for delightful loading states. -- Add `wire:key` in loops: - - ```blade - @foreach ($items as $item) -
- {{ $item->name }} -
- @endforeach - ``` - -- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - - - public function mount(User $user) { $this->user = $user; } - public function updatedSearch() { $this->resetPage(); } - - -## Testing Livewire - - - Livewire::test(Counter::class) - ->assertSet('count', 0) - ->call('increment') - ->assertSet('count', 1) - ->assertSee(1) - ->assertStatus(200); - - - - $this->get('/posts/create') - ->assertSeeLivewire(CreatePost::class); - - === pint/core rules === -## Laravel Pint Code Formatter +# Laravel Pint Code Formatter -- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. +- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues. === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. -### Pest Tests -- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `vendor/bin/sail artisan test --compact`. -- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - - -=== pest/v4 rules === - -## Pest 4 - -- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. -- Browser testing is incredibly powerful and useful for this project. -- Browser tests should live in `tests/Browser/`. -- Use the `search-docs` tool for detailed guidance on utilizing these features. - -### Browser Testing -- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. -- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. -- If requested, test on multiple browsers (Chrome, Firefox, Safari). -- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging when appropriate. - -### Example Tests - - -it('may reset the password', function () { - Notification::fake(); - - $this->actingAs(User::factory()->create()); - - $page = visit('/sign-in'); // Visit on a real browser... - - $page->assertSee('Sign In') - ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!') - - Notification::assertSent(ResetPassword::class); -}); - - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); - +- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`. +- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. +- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. === tailwindcss/core rules === -## Tailwind CSS +# Tailwind CSS -- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own. -- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.). -- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically. -- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - -### Spacing -- When listing items, use gap utilities for spacing; don't use margins. - - -
-
Superior
-
Michigan
-
Erie
-
-
- -### Dark Mode -- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - -=== tailwindcss/v4 rules === - -## Tailwind CSS 4 - -- Always use Tailwind CSS v4; do not use the deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. -- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. - - -@theme { - --color-brand: oklch(0.72 0.11 178); -} - - -- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - - - - @tailwind base; - - @tailwind components; - - @tailwind utilities; - + @import "tailwindcss"; - - -### Replaced Utilities -- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement. -- Opacity values are still numeric. - -| Deprecated | Replacement | -|------------+--------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | +- Always use existing Tailwind conventions; check project patterns before adding new ones. +- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. +- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. ## Recent Changes diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php new file mode 100644 index 0000000..7bde82e --- /dev/null +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -0,0 +1,298 @@ +|null */ + public ?array $severityCounts = null; + + public ?string $lastComparedAt = null; + + public static function canAccess(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + + public function mount(): void + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + $this->state = 'no_tenant'; + $this->message = 'No tenant selected.'; + + return; + } + + $assignment = BaselineTenantAssignment::query() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment) { + $this->state = 'no_assignment'; + $this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.'; + + return; + } + + $profile = $assignment->baselineProfile; + + if ($profile === null) { + $this->state = 'no_assignment'; + $this->message = 'The assigned baseline profile no longer exists.'; + + return; + } + + $this->profileName = (string) $profile->name; + $this->profileId = (int) $profile->getKey(); + $this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; + + if ($this->snapshotId === null) { + $this->state = 'no_snapshot'; + $this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.'; + + return; + } + + $latestRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'baseline_compare') + ->latest('id') + ->first(); + + if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { + $this->state = 'comparing'; + $this->operationRunId = (int) $latestRun->getKey(); + $this->message = 'A baseline comparison is currently in progress.'; + + return; + } + + if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) { + $this->lastComparedAt = $latestRun->finished_at->diffForHumans(); + } + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $findingsQuery = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey); + + $totalFindings = (int) (clone $findingsQuery)->count(); + + if ($totalFindings > 0) { + $this->state = 'ready'; + $this->findingsCount = $totalFindings; + $this->severityCounts = [ + 'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(), + 'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(), + 'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(), + ]; + + if ($latestRun instanceof OperationRun) { + $this->operationRunId = (int) $latestRun->getKey(); + } + + return; + } + + if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') { + $this->state = 'ready'; + $this->findingsCount = 0; + $this->operationRunId = (int) $latestRun->getKey(); + $this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.'; + + return; + } + + $this->state = 'idle'; + $this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.'; + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.'); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + $this->compareNowAction(), + ]; + } + + private function compareNowAction(): Action + { + return Action::make('compareNow') + ->label('Compare Now') + ->icon('heroicon-o-play') + ->requiresConfirmation() + ->modalHeading('Start baseline comparison') + ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') + ->visible(fn (): bool => $this->canCompare()) + ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true)) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + Notification::make()->title('Not authenticated')->danger()->send(); + + return; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + Notification::make()->title('No tenant context')->danger()->send(); + + return; + } + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + if (! ($result['ok'] ?? false)) { + Notification::make() + ->title('Cannot start comparison') + ->body('Reason: '.($result['reason_code'] ?? 'unknown')) + ->danger() + ->send(); + + return; + } + + $run = $result['run'] ?? null; + + if ($run instanceof OperationRun) { + $this->operationRunId = (int) $run->getKey(); + } + + $this->state = 'comparing'; + + Notification::make() + ->title('Baseline comparison started') + ->body('A background job will compute drift against the baseline snapshot.') + ->success() + ->send(); + }); + } + + private function canCompare(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC); + } + + public function getFindingsUrl(): ?string + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return FindingResource::getUrl('index', tenant: $tenant); + } + + public function getRunUrl(): ?string + { + if ($this->operationRunId === null) { + return null; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return OperationRunLinks::view($this->operationRunId, $tenant); + } +} diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 2d6ecfe..ac45c7b 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -28,7 +28,7 @@ class DriftLanding extends Page { protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left'; - protected static string|UnitEnum|null $navigationGroup = 'Drift'; + protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static ?string $navigationLabel = 'Drift'; diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php index defca63..bacefac 100644 --- a/app/Filament/Pages/TenantDashboard.php +++ b/app/Filament/Pages/TenantDashboard.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages; +use App\Filament\Widgets\Dashboard\BaselineCompareNow; use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\RecentDriftFindings; @@ -31,6 +32,7 @@ public function getWidgets(): array return [ DashboardKpis::class, NeedsAttention::class, + BaselineCompareNow::class, RecentDriftFindings::class, RecentOperations::class, ]; diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php new file mode 100644 index 0000000..564fb13 --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -0,0 +1,396 @@ +getId() !== 'admin') { + return false; + } + + return parent::shouldRegisterNavigation(); + } + + public static function canViewAny(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = self::resolveWorkspace(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW); + } + + public static function canCreate(): bool + { + return self::hasManageCapability(); + } + + public static function canEdit(Model $record): bool + { + return self::hasManageCapability(); + } + + public static function canDelete(Model $record): bool + { + return self::hasManageCapability(); + } + + public static function canView(Model $record): bool + { + return self::canViewAny(); + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.') + ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.'); + } + + public static function getEloquentQuery(): Builder + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + return parent::getEloquentQuery() + ->with(['activeSnapshot', 'createdByUser']) + ->when( + $workspaceId !== null, + fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId), + ) + ->when( + $workspaceId === null, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ); + } + + public static function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('name') + ->required() + ->maxLength(255), + Textarea::make('description') + ->rows(3) + ->maxLength(1000), + TextInput::make('version_label') + ->label('Version label') + ->maxLength(50), + Select::make('status') + ->required() + ->options([ + BaselineProfile::STATUS_DRAFT => 'Draft', + BaselineProfile::STATUS_ACTIVE => 'Active', + BaselineProfile::STATUS_ARCHIVED => 'Archived', + ]) + ->default(BaselineProfile::STATUS_DRAFT) + ->native(false), + Select::make('scope_jsonb.policy_types') + ->label('Policy type scope') + ->multiple() + ->options(self::policyTypeOptions()) + ->helperText('Leave empty to include all policy types.') + ->native(false), + ]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Section::make('Profile') + ->schema([ + TextEntry::make('name'), + TextEntry::make('status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus)) + ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)), + TextEntry::make('version_label') + ->label('Version') + ->placeholder('—'), + TextEntry::make('description') + ->placeholder('No description') + ->columnSpanFull(), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make('Scope') + ->schema([ + TextEntry::make('scope_jsonb.policy_types') + ->label('Policy type scope') + ->badge() + ->formatStateUsing(function (string $state): string { + $options = self::policyTypeOptions(); + + return $options[$state] ?? $state; + }) + ->placeholder('All policy types'), + ]) + ->columnSpanFull(), + Section::make('Metadata') + ->schema([ + TextEntry::make('createdByUser.name') + ->label('Created by') + ->placeholder('—'), + TextEntry::make('activeSnapshot.captured_at') + ->label('Last snapshot') + ->dateTime() + ->placeholder('No snapshot yet'), + TextEntry::make('created_at') + ->dateTime(), + TextEntry::make('updated_at') + ->dateTime(), + ]) + ->columns(2) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + $workspace = self::resolveWorkspace(); + + return $table + ->defaultSort('name') + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus)) + ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)) + ->sortable(), + TextColumn::make('version_label') + ->label('Version') + ->placeholder('—'), + TextColumn::make('activeSnapshot.captured_at') + ->label('Last snapshot') + ->dateTime() + ->placeholder('No snapshot'), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->actions([ + Action::make('view') + ->label('View') + ->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record])) + ->icon('heroicon-o-eye'), + ActionGroup::make([ + Action::make('edit') + ->label('Edit') + ->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record])) + ->icon('heroicon-o-pencil-square') + ->visible(fn (): bool => self::hasManageCapability()), + self::archiveTableAction($workspace), + ])->label('More'), + ]) + ->bulkActions([ + BulkActionGroup::make([])->label('More'), + ]) + ->emptyStateHeading('No baseline profiles') + ->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.') + ->emptyStateActions([ + Action::make('create') + ->label('Create baseline profile') + ->url(fn (): string => static::getUrl('create')) + ->icon('heroicon-o-plus') + ->visible(fn (): bool => self::hasManageCapability()), + ]); + } + + public static function getRelations(): array + { + return [ + BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBaselineProfiles::route('/'), + 'create' => Pages\CreateBaselineProfile::route('/create'), + 'view' => Pages\ViewBaselineProfile::route('/{record}'), + 'edit' => Pages\EditBaselineProfile::route('/{record}/edit'), + ]; + } + + /** + * @return array + */ + public static function policyTypeOptions(): array + { + return collect(InventoryPolicyTypeMeta::all()) + ->filter(fn (array $row): bool => filled($row['type'] ?? null)) + ->mapWithKeys(fn (array $row): array => [ + (string) $row['type'] => (string) ($row['label'] ?? $row['type']), + ]) + ->sort() + ->all(); + } + + /** + * @param array $metadata + */ + public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void + { + $workspace = $record->workspace; + + if ($workspace === null) { + return; + } + + $actor = auth()->user(); + + app(WorkspaceAuditLogger::class)->log( + workspace: $workspace, + action: $actionId->value, + context: ['metadata' => $metadata], + actor: $actor instanceof User ? $actor : null, + resourceType: 'baseline_profile', + resourceId: (string) $record->getKey(), + ); + } + + private static function resolveWorkspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return null; + } + + return Workspace::query()->whereKey($workspaceId)->first(); + } + + private static function hasManageCapability(): bool + { + $user = auth()->user(); + $workspace = self::resolveWorkspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } + + private static function archiveTableAction(?Workspace $workspace): Action + { + $action = Action::make('archive') + ->label('Archive') + ->icon('heroicon-o-archive-box') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Archive baseline profile') + ->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.') + ->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability()) + ->action(function (BaselineProfile $record): void { + if (! self::hasManageCapability()) { + throw new AuthorizationException; + } + + $record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save(); + + self::audit($record, AuditActionId::BaselineProfileArchived, [ + 'baseline_profile_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + ]); + + Notification::make() + ->title('Baseline profile archived') + ->success() + ->send(); + }); + + if ($workspace instanceof Workspace) { + $action = WorkspaceUiEnforcement::forTableAction($action, $workspace) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->destructive() + ->apply(); + } + + return $action; + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php new file mode 100644 index 0000000..475259b --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php @@ -0,0 +1,56 @@ + $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $data['workspace_id'] = (int) $workspaceId; + + $user = auth()->user(); + $data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null; + + $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; + $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; + + return $data; + } + + protected function afterCreate(): void + { + $record = $this->record; + + if (! $record instanceof BaselineProfile) { + return; + } + + BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [ + 'baseline_profile_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'status' => (string) $record->status, + ]); + + Notification::make() + ->title('Baseline profile created') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php new file mode 100644 index 0000000..49284ec --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php @@ -0,0 +1,48 @@ + $data + * @return array + */ + protected function mutateFormDataBeforeSave(array $data): array + { + $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; + $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; + + return $data; + } + + protected function afterSave(): void + { + $record = $this->record; + + if (! $record instanceof BaselineProfile) { + return; + } + + BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [ + 'baseline_profile_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'status' => (string) $record->status, + ]); + + Notification::make() + ->title('Baseline profile updated') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php new file mode 100644 index 0000000..7abf445 --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php @@ -0,0 +1,23 @@ +label('Create baseline profile') + ->disabled(fn (): bool => ! BaselineProfileResource::canCreate()), + ]; + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php new file mode 100644 index 0000000..1691b05 --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -0,0 +1,142 @@ +captureAction(), + EditAction::make() + ->visible(fn (): bool => $this->hasManageCapability()), + ]; + } + + private function captureAction(): Action + { + return Action::make('capture') + ->label('Capture Snapshot') + ->icon('heroicon-o-camera') + ->color('primary') + ->visible(fn (): bool => $this->hasManageCapability()) + ->disabled(fn (): bool => ! $this->hasManageCapability()) + ->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null) + ->requiresConfirmation() + ->modalHeading('Capture Baseline Snapshot') + ->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.') + ->form([ + Select::make('source_tenant_id') + ->label('Source Tenant') + ->options(fn (): array => $this->getWorkspaceTenantOptions()) + ->required() + ->searchable(), + ]) + ->action(function (array $data): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->hasManageCapability()) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getRecord(); + $sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']); + + if (! $sourceTenant instanceof Tenant) { + Notification::make() + ->title('Source tenant not found') + ->danger() + ->send(); + + return; + } + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $sourceTenant, $user); + + if (! $result['ok']) { + Notification::make() + ->title('Cannot start capture') + ->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown'))) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Capture enqueued') + ->body('Baseline snapshot capture has been started.') + ->success() + ->send(); + }); + } + + /** + * @return array + */ + private function getWorkspaceTenantOptions(): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return []; + } + + return Tenant::query() + ->where('workspace_id', $workspaceId) + ->orderBy('name') + ->pluck('name', 'id') + ->all(); + } + + private function hasManageCapability(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php new file mode 100644 index 0000000..adb5e98 --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php @@ -0,0 +1,246 @@ +satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.'); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable(), + Tables\Columns\TextColumn::make('assignedByUser.name') + ->label('Assigned by') + ->placeholder('—'), + Tables\Columns\TextColumn::make('created_at') + ->label('Assigned at') + ->dateTime() + ->sortable(), + ]) + ->headerActions([ + $this->assignTenantAction(), + ]) + ->actions([ + $this->removeAssignmentAction(), + ]) + ->emptyStateHeading('No tenants assigned') + ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.') + ->emptyStateActions([ + $this->assignTenantAction(), + ]); + } + + private function assignTenantAction(): Action + { + return Action::make('assign') + ->label('Assign Tenant') + ->icon('heroicon-o-plus') + ->visible(fn (): bool => $this->hasManageCapability()) + ->form([ + Select::make('tenant_id') + ->label('Tenant') + ->options(fn (): array => $this->getAvailableTenantOptions()) + ->required() + ->searchable(), + ]) + ->action(function (array $data): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->hasManageCapability()) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + $tenantId = (int) $data['tenant_id']; + + $existing = BaselineTenantAssignment::query() + ->where('workspace_id', $profile->workspace_id) + ->where('tenant_id', $tenantId) + ->first(); + + if ($existing instanceof BaselineTenantAssignment) { + Notification::make() + ->title('Tenant already assigned') + ->body('This tenant already has a baseline assignment in this workspace.') + ->warning() + ->send(); + + return; + } + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $profile->workspace_id, + 'tenant_id' => $tenantId, + 'baseline_profile_id' => (int) $profile->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + $this->auditAssignment($profile, $assignment, $user, 'created'); + + Notification::make() + ->title('Tenant assigned') + ->success() + ->send(); + }); + } + + private function removeAssignmentAction(): Action + { + return Action::make('remove') + ->label('Remove') + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(fn (): bool => $this->hasManageCapability()) + ->requiresConfirmation() + ->modalHeading('Remove tenant assignment') + ->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.') + ->action(function (BaselineTenantAssignment $record): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $this->hasManageCapability()) { + Notification::make() + ->title('Permission denied') + ->danger() + ->send(); + + return; + } + + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + + $this->auditAssignment($profile, $record, $user, 'removed'); + + $record->delete(); + + Notification::make() + ->title('Assignment removed') + ->success() + ->send(); + }); + } + + /** + * @return array + */ + private function getAvailableTenantOptions(): array + { + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + + $assignedTenantIds = BaselineTenantAssignment::query() + ->where('workspace_id', $profile->workspace_id) + ->pluck('tenant_id') + ->all(); + + $query = Tenant::query() + ->where('workspace_id', $profile->workspace_id) + ->orderBy('name'); + + if (! empty($assignedTenantIds)) { + $query->whereNotIn('id', $assignedTenantIds); + } + + return $query->pluck('name', 'id')->all(); + } + + private function auditAssignment( + BaselineProfile $profile, + BaselineTenantAssignment $assignment, + User $user, + string $action, + ): void { + $workspace = Workspace::query()->find($profile->workspace_id); + + if (! $workspace instanceof Workspace) { + return; + } + + $tenant = Tenant::query()->find($assignment->tenant_id); + + $auditLogger = app(WorkspaceAuditLogger::class); + + $auditLogger->log( + workspace: $workspace, + action: 'baseline.assignment.'.$action, + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + 'tenant_id' => (int) $assignment->tenant_id, + 'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—', + ], + actor: $user, + resourceType: 'baseline_profile', + resourceId: (string) $profile->getKey(), + ); + } + + private function hasManageCapability(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } +} diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index e8118c0..6daa396 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -46,7 +46,7 @@ class FindingResource extends Resource protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle'; - protected static string|UnitEnum|null $navigationGroup = 'Drift'; + protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static ?string $navigationLabel = 'Findings'; diff --git a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php new file mode 100644 index 0000000..e5f59f3 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php @@ -0,0 +1,90 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + $empty = [ + 'hasAssignment' => false, + 'profileName' => null, + 'findingsCount' => 0, + 'highCount' => 0, + 'mediumCount' => 0, + 'lowCount' => 0, + 'lastComparedAt' => null, + 'landingUrl' => null, + ]; + + if (! $tenant instanceof Tenant) { + return $empty; + } + + $assignment = BaselineTenantAssignment::query() + ->where('tenant_id', $tenant->getKey()) + ->with('baselineProfile') + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) { + return $empty; + } + + $profile = $assignment->baselineProfile; + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $findingsQuery = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->where('status', Finding::STATUS_NEW); + + $findingsCount = (int) (clone $findingsQuery)->count(); + $highCount = (int) (clone $findingsQuery) + ->where('severity', Finding::SEVERITY_HIGH) + ->count(); + $mediumCount = (int) (clone $findingsQuery) + ->where('severity', Finding::SEVERITY_MEDIUM) + ->count(); + $lowCount = (int) (clone $findingsQuery) + ->where('severity', Finding::SEVERITY_LOW) + ->count(); + + $latestRun = BaselineCompareRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('baseline_profile_id', $profile->getKey()) + ->whereNotNull('finished_at') + ->latest('finished_at') + ->first(); + + return [ + 'hasAssignment' => true, + 'profileName' => (string) $profile->name, + 'findingsCount' => $findingsCount, + 'highCount' => $highCount, + 'mediumCount' => $mediumCount, + 'lowCount' => $lowCount, + 'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(), + 'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant), + ]; + } +} diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php new file mode 100644 index 0000000..7909911 --- /dev/null +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -0,0 +1,285 @@ +operationRun = $run; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + BaselineSnapshotIdentity $identity, + AuditLogger $auditLogger, + OperationRunService $operationRunService, + ): void { + if (! $this->operationRun instanceof OperationRun) { + $this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.')); + + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $profileId = (int) ($context['baseline_profile_id'] ?? 0); + $sourceTenantId = (int) ($context['source_tenant_id'] ?? 0); + + $profile = BaselineProfile::query()->find($profileId); + + if (! $profile instanceof BaselineProfile) { + throw new RuntimeException("BaselineProfile #{$profileId} not found."); + } + + $sourceTenant = Tenant::query()->find($sourceTenantId); + + if (! $sourceTenant instanceof Tenant) { + throw new RuntimeException("Source Tenant #{$sourceTenantId} not found."); + } + + $initiator = $this->operationRun->user_id + ? User::query()->find($this->operationRun->user_id) + : null; + + $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); + + $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator); + + $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity); + + $identityHash = $identity->computeIdentity($snapshotItems); + + $snapshot = $this->findOrCreateSnapshot( + $profile, + $identityHash, + $snapshotItems, + ); + + $wasNewSnapshot = $snapshot->wasRecentlyCreated; + + if ($profile->status === BaselineProfile::STATUS_ACTIVE) { + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + } + + $summaryCounts = [ + 'total' => count($snapshotItems), + 'processed' => count($snapshotItems), + 'succeeded' => count($snapshotItems), + 'failed' => 0, + ]; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $summaryCounts, + ); + + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['result'] = [ + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_identity_hash' => $identityHash, + 'was_new_snapshot' => $wasNewSnapshot, + 'items_captured' => count($snapshotItems), + ]; + $this->operationRun->update(['context' => $updatedContext]); + + $this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems); + } + + /** + * @return array}> + */ + private function collectSnapshotItems( + Tenant $sourceTenant, + BaselineScope $scope, + BaselineSnapshotIdentity $identity, + ): array { + $query = InventoryItem::query() + ->where('tenant_id', $sourceTenant->getKey()); + + if (! $scope->isEmpty()) { + $query->whereIn('policy_type', $scope->policyTypes); + } + + $items = []; + + $query->orderBy('policy_type') + ->orderBy('external_id') + ->chunk(500, function ($inventoryItems) use (&$items, $identity): void { + foreach ($inventoryItems as $inventoryItem) { + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $baselineHash = $identity->hashItemContent($metaJsonb); + + $items[] = [ + 'subject_type' => 'policy', + 'subject_external_id' => (string) $inventoryItem->external_id, + 'policy_type' => (string) $inventoryItem->policy_type, + 'baseline_hash' => $baselineHash, + 'meta_jsonb' => [ + 'display_name' => $inventoryItem->display_name, + 'category' => $inventoryItem->category, + 'platform' => $inventoryItem->platform, + ], + ]; + } + }); + + return $items; + } + + /** + * @param array}> $snapshotItems + */ + private function findOrCreateSnapshot( + BaselineProfile $profile, + string $identityHash, + array $snapshotItems, + ): BaselineSnapshot { + $existing = BaselineSnapshot::query() + ->where('workspace_id', $profile->workspace_id) + ->where('baseline_profile_id', $profile->getKey()) + ->where('snapshot_identity_hash', $identityHash) + ->first(); + + if ($existing instanceof BaselineSnapshot) { + return $existing; + } + + $snapshot = BaselineSnapshot::create([ + 'workspace_id' => (int) $profile->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'snapshot_identity_hash' => $identityHash, + 'captured_at' => now(), + 'summary_jsonb' => [ + 'total_items' => count($snapshotItems), + 'policy_type_counts' => $this->countByPolicyType($snapshotItems), + ], + ]); + + foreach (array_chunk($snapshotItems, 100) as $chunk) { + $rows = array_map( + fn (array $item): array => [ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => $item['subject_type'], + 'subject_external_id' => $item['subject_external_id'], + 'policy_type' => $item['policy_type'], + 'baseline_hash' => $item['baseline_hash'], + 'meta_jsonb' => json_encode($item['meta_jsonb']), + 'created_at' => now(), + 'updated_at' => now(), + ], + $chunk, + ); + + BaselineSnapshotItem::insert($rows); + } + + return $snapshot; + } + + /** + * @param array $items + * @return array + */ + private function countByPolicyType(array $items): array + { + $counts = []; + + foreach ($items as $item) { + $type = (string) $item['policy_type']; + $counts[$type] = ($counts[$type] ?? 0) + 1; + } + + ksort($counts); + + return $counts; + } + + private function auditStarted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.capture.started', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'baseline_profile', + resourceId: (string) $profile->getKey(), + ); + } + + private function auditCompleted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + BaselineSnapshot $snapshot, + ?User $initiator, + array $snapshotItems, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.capture.completed', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash, + 'items_captured' => count($snapshotItems), + 'was_new_snapshot' => $snapshot->wasRecentlyCreated, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + } +} diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php new file mode 100644 index 0000000..2d0cb1b --- /dev/null +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -0,0 +1,394 @@ +operationRun = $run; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + DriftHasher $driftHasher, + BaselineSnapshotIdentity $snapshotIdentity, + AuditLogger $auditLogger, + OperationRunService $operationRunService, + ): void { + if (! $this->operationRun instanceof OperationRun) { + $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.')); + + return; + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $profileId = (int) ($context['baseline_profile_id'] ?? 0); + $snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0); + + $profile = BaselineProfile::query()->find($profileId); + + if (! $profile instanceof BaselineProfile) { + throw new RuntimeException("BaselineProfile #{$profileId} not found."); + } + + $tenant = Tenant::query()->find($this->operationRun->tenant_id); + + if (! $tenant instanceof Tenant) { + throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found."); + } + + $initiator = $this->operationRun->user_id + ? User::query()->find($this->operationRun->user_id) + : null; + + $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); + $scopeKey = 'baseline_profile:' . $profile->getKey(); + + $this->auditStarted($auditLogger, $tenant, $profile, $initiator); + + $baselineItems = $this->loadBaselineItems($snapshotId); + $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity); + + $driftResults = $this->computeDrift($baselineItems, $currentItems); + + $upsertedCount = $this->upsertFindings( + $driftHasher, + $tenant, + $profile, + $scopeKey, + $driftResults, + ); + + $severityBreakdown = $this->countBySeverity($driftResults); + + $summaryCounts = [ + 'total' => count($driftResults), + 'processed' => count($driftResults), + 'succeeded' => $upsertedCount, + 'failed' => count($driftResults) - $upsertedCount, + 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, + 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, + 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, + ]; + + $operationRunService->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: $summaryCounts, + ); + + $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $updatedContext['result'] = [ + 'findings_total' => count($driftResults), + 'findings_upserted' => $upsertedCount, + 'severity_breakdown' => $severityBreakdown, + ]; + $this->operationRun->update(['context' => $updatedContext]); + + $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); + } + + /** + * Load baseline snapshot items keyed by "policy_type|subject_external_id". + * + * @return array}> + */ + private function loadBaselineItems(int $snapshotId): array + { + $items = []; + + BaselineSnapshotItem::query() + ->where('baseline_snapshot_id', $snapshotId) + ->orderBy('id') + ->chunk(500, function ($snapshotItems) use (&$items): void { + foreach ($snapshotItems as $item) { + $key = $item->policy_type . '|' . $item->subject_external_id; + $items[$key] = [ + 'subject_type' => (string) $item->subject_type, + 'subject_external_id' => (string) $item->subject_external_id, + 'policy_type' => (string) $item->policy_type, + 'baseline_hash' => (string) $item->baseline_hash, + 'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [], + ]; + } + }); + + return $items; + } + + /** + * Load current inventory items keyed by "policy_type|external_id". + * + * @return array}> + */ + private function loadCurrentInventory( + Tenant $tenant, + BaselineScope $scope, + BaselineSnapshotIdentity $snapshotIdentity, + ): array { + $query = InventoryItem::query() + ->where('tenant_id', $tenant->getKey()); + + if (! $scope->isEmpty()) { + $query->whereIn('policy_type', $scope->policyTypes); + } + + $items = []; + + $query->orderBy('policy_type') + ->orderBy('external_id') + ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void { + foreach ($inventoryItems as $inventoryItem) { + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $currentHash = $snapshotIdentity->hashItemContent($metaJsonb); + + $key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id; + $items[$key] = [ + 'subject_external_id' => (string) $inventoryItem->external_id, + 'policy_type' => (string) $inventoryItem->policy_type, + 'current_hash' => $currentHash, + 'meta_jsonb' => [ + 'display_name' => $inventoryItem->display_name, + 'category' => $inventoryItem->category, + 'platform' => $inventoryItem->platform, + ], + ]; + } + }); + + return $items; + } + + /** + * Compare baseline items vs current inventory and produce drift results. + * + * @param array}> $baselineItems + * @param array}> $currentItems + * @return array}> + */ + private function computeDrift(array $baselineItems, array $currentItems): array + { + $drift = []; + + foreach ($baselineItems as $key => $baselineItem) { + if (! array_key_exists($key, $currentItems)) { + $drift[] = [ + 'change_type' => 'missing_policy', + 'severity' => Finding::SEVERITY_HIGH, + 'subject_type' => $baselineItem['subject_type'], + 'subject_external_id' => $baselineItem['subject_external_id'], + 'policy_type' => $baselineItem['policy_type'], + 'baseline_hash' => $baselineItem['baseline_hash'], + 'current_hash' => '', + 'evidence' => [ + 'change_type' => 'missing_policy', + 'policy_type' => $baselineItem['policy_type'], + 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, + ], + ]; + + continue; + } + + $currentItem = $currentItems[$key]; + + if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) { + $drift[] = [ + 'change_type' => 'different_version', + 'severity' => Finding::SEVERITY_MEDIUM, + 'subject_type' => $baselineItem['subject_type'], + 'subject_external_id' => $baselineItem['subject_external_id'], + 'policy_type' => $baselineItem['policy_type'], + 'baseline_hash' => $baselineItem['baseline_hash'], + 'current_hash' => $currentItem['current_hash'], + 'evidence' => [ + 'change_type' => 'different_version', + 'policy_type' => $baselineItem['policy_type'], + 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null, + 'baseline_hash' => $baselineItem['baseline_hash'], + 'current_hash' => $currentItem['current_hash'], + ], + ]; + } + } + + foreach ($currentItems as $key => $currentItem) { + if (! array_key_exists($key, $baselineItems)) { + $drift[] = [ + 'change_type' => 'unexpected_policy', + 'severity' => Finding::SEVERITY_LOW, + 'subject_type' => 'policy', + 'subject_external_id' => $currentItem['subject_external_id'], + 'policy_type' => $currentItem['policy_type'], + 'baseline_hash' => '', + 'current_hash' => $currentItem['current_hash'], + 'evidence' => [ + 'change_type' => 'unexpected_policy', + 'policy_type' => $currentItem['policy_type'], + 'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null, + ], + ]; + } + } + + return $drift; + } + + /** + * Upsert drift findings using stable fingerprints. + * + * @param array}> $driftResults + */ + private function upsertFindings( + DriftHasher $driftHasher, + Tenant $tenant, + BaselineProfile $profile, + string $scopeKey, + array $driftResults, + ): int { + $upsertedCount = 0; + $tenantId = (int) $tenant->getKey(); + + foreach ($driftResults as $driftItem) { + $fingerprint = $driftHasher->fingerprint( + tenantId: $tenantId, + scopeKey: $scopeKey, + subjectType: $driftItem['subject_type'], + subjectExternalId: $driftItem['subject_external_id'], + changeType: $driftItem['change_type'], + baselineHash: $driftItem['baseline_hash'], + currentHash: $driftItem['current_hash'], + ); + + Finding::query()->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'fingerprint' => $fingerprint, + ], + [ + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'source' => 'baseline.compare', + 'scope_key' => $scopeKey, + 'subject_type' => $driftItem['subject_type'], + 'subject_external_id' => $driftItem['subject_external_id'], + 'severity' => $driftItem['severity'], + 'status' => Finding::STATUS_NEW, + 'evidence_jsonb' => $driftItem['evidence'], + 'baseline_operation_run_id' => null, + 'current_operation_run_id' => (int) $this->operationRun->getKey(), + ], + ); + + $upsertedCount++; + } + + return $upsertedCount; + } + + /** + * @param array $driftResults + * @return array + */ + private function countBySeverity(array $driftResults): array + { + $counts = []; + + foreach ($driftResults as $item) { + $severity = $item['severity']; + $counts[$severity] = ($counts[$severity] ?? 0) + 1; + } + + return $counts; + } + + private function auditStarted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.compare.started', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'baseline_profile', + resourceId: (string) $profile->getKey(), + ); + } + + private function auditCompleted( + AuditLogger $auditLogger, + Tenant $tenant, + BaselineProfile $profile, + ?User $initiator, + array $summaryCounts, + ): void { + $auditLogger->log( + tenant: $tenant, + action: 'baseline.compare.completed', + context: [ + 'metadata' => [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_profile_name' => (string) $profile->name, + 'findings_total' => $summaryCounts['total'] ?? 0, + 'high' => $summaryCounts['high'] ?? 0, + 'medium' => $summaryCounts['medium'] ?? 0, + 'low' => $summaryCounts['low'] ?? 0, + ], + ], + actorId: $initiator?->id, + actorEmail: $initiator?->email, + actorName: $initiator?->name, + resourceType: 'operation_run', + resourceId: (string) $this->operationRun->getKey(), + ); + } +} 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/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 5196825..4a2d9be 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -12,6 +12,7 @@ use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertRuleResource; +use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; @@ -151,6 +152,7 @@ public function panel(Panel $panel): Panel AlertRuleResource::class, AlertDeliveryResource::class, WorkspaceResource::class, + BaselineProfileResource::class, ]) ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') 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/Services/Baselines/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php new file mode 100644 index 0000000..f596176 --- /dev/null +++ b/app/Services/Baselines/BaselineCaptureService.php @@ -0,0 +1,79 @@ +validatePreconditions($profile, $sourceTenant); + + if ($precondition !== null) { + return ['ok' => false, 'reason_code' => $precondition]; + } + + $effectiveScope = BaselineScope::fromJsonb( + is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, + ); + + $context = [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $sourceTenant->getKey(), + 'effective_scope' => $effectiveScope->toJsonb(), + ]; + + $run = $this->runs->ensureRunWithIdentity( + tenant: $sourceTenant, + type: 'baseline_capture', + identityInputs: [ + 'baseline_profile_id' => (int) $profile->getKey(), + ], + context: $context, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + CaptureBaselineSnapshotJob::dispatch($run); + } + + return ['ok' => true, 'run' => $run]; + } + + private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string + { + if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { + return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE; + } + + if ($sourceTenant->workspace_id === null) { + return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT; + } + + if ((int) $sourceTenant->workspace_id !== (int) $profile->workspace_id) { + return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT; + } + + return null; + } +} diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php new file mode 100644 index 0000000..a4cc940 --- /dev/null +++ b/app/Services/Baselines/BaselineCompareService.php @@ -0,0 +1,97 @@ +where('workspace_id', $tenant->workspace_id) + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if (! $assignment instanceof BaselineTenantAssignment) { + return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT]; + } + + $profile = BaselineProfile::query()->find($assignment->baseline_profile_id); + + if (! $profile instanceof BaselineProfile) { + return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE]; + } + + $precondition = $this->validatePreconditions($profile); + + if ($precondition !== null) { + return ['ok' => false, 'reason_code' => $precondition]; + } + + $snapshotId = (int) $profile->active_snapshot_id; + + $profileScope = BaselineScope::fromJsonb( + is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, + ); + $overrideScope = $assignment->override_scope_jsonb !== null + ? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null) + : null; + + $effectiveScope = BaselineScope::effective($profileScope, $overrideScope); + + $context = [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => $snapshotId, + 'effective_scope' => $effectiveScope->toJsonb(), + ]; + + $run = $this->runs->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: [ + 'baseline_profile_id' => (int) $profile->getKey(), + ], + context: $context, + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + CompareBaselineToTenantJob::dispatch($run); + } + + return ['ok' => true, 'run' => $run]; + } + + private function validatePreconditions(BaselineProfile $profile): ?string + { + if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { + return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; + } + + if ($profile->active_snapshot_id === null) { + return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT; + } + + return null; + } +} diff --git a/app/Services/Baselines/BaselineSnapshotIdentity.php b/app/Services/Baselines/BaselineSnapshotIdentity.php new file mode 100644 index 0000000..c761f92 --- /dev/null +++ b/app/Services/Baselines/BaselineSnapshotIdentity.php @@ -0,0 +1,59 @@ + $items + */ + public function computeIdentity(array $items): string + { + if ($items === []) { + return hash('sha256', '[]'); + } + + $normalized = array_map( + fn (array $item): string => implode('|', [ + trim((string) ($item['subject_type'] ?? '')), + trim((string) ($item['subject_external_id'] ?? '')), + trim((string) ($item['policy_type'] ?? '')), + trim((string) ($item['baseline_hash'] ?? '')), + ]), + $items, + ); + + sort($normalized, SORT_STRING); + + return hash('sha256', implode("\n", $normalized)); + } + + /** + * Compute a stable content hash for a single inventory item's metadata. + * + * Strips volatile OData keys and normalizes for stable comparison. + */ + public function hashItemContent(mixed $metaJsonb): string + { + return $this->hasher->hashNormalized($metaJsonb); + } +} diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index 6a10bfb..fd6d8a6 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -46,4 +46,17 @@ enum AuditActionId: string case WorkspaceSettingUpdated = 'workspace_setting.updated'; case WorkspaceSettingReset = 'workspace_setting.reset'; + + case BaselineProfileCreated = 'baseline_profile.created'; + case BaselineProfileUpdated = 'baseline_profile.updated'; + case BaselineProfileArchived = 'baseline_profile.archived'; + case BaselineCaptureStarted = 'baseline_capture.started'; + case BaselineCaptureCompleted = 'baseline_capture.completed'; + case BaselineCaptureFailed = 'baseline_capture.failed'; + case BaselineCompareStarted = 'baseline_compare.started'; + case BaselineCompareCompleted = 'baseline_compare.completed'; + case BaselineCompareFailed = 'baseline_compare.failed'; + case BaselineAssignmentCreated = 'baseline_assignment.created'; + case BaselineAssignmentUpdated = 'baseline_assignment.updated'; + case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; } 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/boost.json b/boost.json index 0a16d2e..c2de208 100644 --- a/boost.json +++ b/boost.json @@ -5,12 +5,12 @@ "gemini", "opencode" ], - "editors": [ - "codex", - "gemini", - "opencode", - "vscode" - ], - "guidelines": [], - "sail": true + "guidelines": true, + "herd_mcp": false, + "mcp": true, + "sail": true, + "skills": [ + "pest-testing", + "tailwindcss-development" + ] } diff --git a/composer.json b/composer.json index e883eae..8c2ca94 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require-dev": { "barryvdh/laravel-debugbar": "^3.16", "fakerphp/faker": "^1.23", - "laravel/boost": "^1.8", + "laravel/boost": "^2.1", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 38d1177..74a276e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f1060097603f7d29074d1b2a1dd53bf8", + "content-hash": "83257f7b8eac0f483898bdf2d4adfb7e", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -9312,33 +9312,33 @@ }, { "name": "laravel/boost", - "version": "v1.8.10", + "version": "v2.1.2", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32" + "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/aad8b2a423b0a886c2ce7ee92abbfde69992ff32", - "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32", + "url": "https://api.github.com/repos/laravel/boost/zipball/81ecf79e82c979efd92afaeac012605cc7b2f31f", + "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/console": "^11.45.3|^12.41.1", + "illuminate/contracts": "^11.45.3|^12.41.1", + "illuminate/routing": "^11.45.3|^12.41.1", + "illuminate/support": "^11.45.3|^12.41.1", "laravel/mcp": "^0.5.1", - "laravel/prompts": "0.1.25|^0.3.6", + "laravel/prompts": "^0.3.10", "laravel/roster": "^0.2.9", - "php": "^8.1" + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.20.0", + "laravel/pint": "^1.27.0", "mockery/mockery": "^1.6.12", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "orchestra/testbench": "^9.15.0|^10.6", "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" @@ -9374,7 +9374,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-01-14T14:51:16+00:00" + "time": "2026-02-10T17:40:45+00:00" }, { "name": "laravel/mcp", @@ -12482,5 +12482,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } 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/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php new file mode 100644 index 0000000..6edc020 --- /dev/null +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -0,0 +1,188 @@ + + {{-- Row 1: Stats Overview --}} + @if (in_array($state, ['ready', 'idle', 'comparing'])) +
+ {{-- Stat: Assigned Baseline --}} + +
+
Assigned Baseline
+
{{ $profileName ?? '—' }}
+ @if ($snapshotId) + + Snapshot #{{ $snapshotId }} + + @endif +
+
+ + {{-- Stat: Total Findings --}} + +
+
Total Findings
+
+ {{ $findingsCount ?? 0 }} +
+ @if ($state === 'comparing') +
+ + Comparing… +
+ @elseif (($findingsCount ?? 0) === 0 && $state === 'ready') + All clear + @endif +
+
+ + {{-- Stat: Last Compared --}} + +
+
Last Compared
+
+ {{ $lastComparedAt ?? 'Never' }} +
+ @if ($this->getRunUrl()) + + View run + + @endif +
+
+
+ @endif + + {{-- Critical drift banner --}} + @if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0) +
+
+ +
+
+ Critical Drift Detected +
+
+ The current tenant state deviates from baseline {{ $profileName }}. + {{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention. +
+
+
+
+ @endif + + {{-- State: No tenant / no assignment / no snapshot --}} + @if (in_array($state, ['no_tenant', 'no_assignment', 'no_snapshot'])) + +
+ @if ($state === 'no_tenant') + +
No Tenant Selected
+ @elseif ($state === 'no_assignment') + +
No Baseline Assigned
+ @elseif ($state === 'no_snapshot') + +
No Snapshot Available
+ @endif +
{{ $message }}
+
+
+ @endif + + {{-- Severity breakdown + actions --}} + @if ($state === 'ready' && ($findingsCount ?? 0) > 0) + + + {{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }} + + + The tenant configuration drifted from the baseline profile. + + +
+
+ @if (($severityCounts['high'] ?? 0) > 0) + + {{ $severityCounts['high'] }} High + + @endif + + @if (($severityCounts['medium'] ?? 0) > 0) + + {{ $severityCounts['medium'] }} Medium + + @endif + + @if (($severityCounts['low'] ?? 0) > 0) + + {{ $severityCounts['low'] }} Low + + @endif +
+ +
+ @if ($this->getFindingsUrl()) + + View all findings + + @endif + + @if ($this->getRunUrl()) + + Review last run + + @endif +
+
+
+ @endif + + {{-- Ready: no drift --}} + @if ($state === 'ready' && ($findingsCount ?? 0) === 0) + +
+ +
No Drift Detected
+
+ The tenant configuration matches the baseline profile. Everything looks good. +
+ @if ($this->getRunUrl()) + + Review last run + + @endif +
+
+ @endif + + {{-- Idle state --}} + @if ($state === 'idle') + +
+ +
Ready to Compare
+
+ {{ $message }} +
+
+
+ @endif +
diff --git a/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php new file mode 100644 index 0000000..5e4089e --- /dev/null +++ b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php @@ -0,0 +1,68 @@ +
+
+
Baseline Governance
+ @if ($landingUrl) + + Details + + @endif +
+ + @if (! $hasAssignment) +
+ +
+
No Baseline Assigned
+
Assign a baseline profile to start monitoring drift.
+
+
+ @else + {{-- Profile + last compared --}} +
+
+ Baseline: {{ $profileName }} +
+ @if ($lastComparedAt) +
{{ $lastComparedAt }}
+ @endif +
+ + {{-- Findings summary --}} + @if ($findingsCount > 0) + {{-- Critical banner (inline) --}} + @if ($highCount > 0) +
+ + + {{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }} + +
+ @endif + +
+
+ + {{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }} + + + @if ($mediumCount > 0) + + {{ $mediumCount }} medium + + @endif + + @if ($lowCount > 0) + + {{ $lowCount }} low + + @endif +
+
+ @else +
+ + No open drift — baseline compliant +
+ @endif + @endif +
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..9647c63 --- /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. + +- [X] T001 Verify feature docs are in place in specs/101-golden-master-baseline-governance-v1/ +- [X] 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. + +- [X] T003 Add baseline capabilities to app/Support/Auth/Capabilities.php (`workspace_baselines.view`, `workspace_baselines.manage`) +- [X] T004 Add baseline capabilities to app/Services/Auth/WorkspaceRoleCapabilityMap.php (Owner/Manager = manage; Operator/Readonly = view) +- [X] T005 [P] Add operation labels for baseline_capture + baseline_compare in app/Support/OperationCatalog.php +- [X] T006 [P] Extend summary keys to include severity breakdown keys in app/Support/OpsUx/OperationSummaryKeys.php (add `high`, `medium`, `low`) +- [X] T007 Create baseline schema migrations in database/migrations/*_create_baseline_profiles_table.php (workspace-owned) +- [X] T008 Create baseline schema migrations in database/migrations/*_create_baseline_snapshots_table.php (workspace-owned) +- [X] T009 Create baseline schema migrations in database/migrations/*_create_baseline_snapshot_items_table.php (workspace-owned) +- [X] T010 Create baseline schema migrations in database/migrations/*_create_baseline_tenant_assignments_table.php (tenant-owned) +- [X] T011 Create findings source migration in database/migrations/*_add_source_to_findings_table.php +- [X] T012 [P] Add BaselineProfile model in app/Models/BaselineProfile.php (casts for scope_jsonb; status constants/enum) +- [X] T013 [P] Add BaselineSnapshot model in app/Models/BaselineSnapshot.php (casts for summary_jsonb) +- [X] T014 [P] Add BaselineSnapshotItem model in app/Models/BaselineSnapshotItem.php (casts for meta_jsonb) +- [X] T015 [P] Add BaselineTenantAssignment model in app/Models/BaselineTenantAssignment.php (casts for override_scope_jsonb) +- [X] T016 [P] Update Finding model for new column in app/Models/Finding.php (fillable/casts for `source` as needed by existing conventions) +- [X] T017 [P] Add baseline scope value object / helpers in app/Support/Baselines/BaselineScope.php (normalize + intersect profile/override) +- [X] T018 [P] Add baseline reason codes in app/Support/Baselines/BaselineReasonCodes.php (constants for 422 responses) +- [X] 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) +- [X] 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) + +- [X] T019 [P] [US1] Add feature test scaffolding in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php +- [X] T020 [P] [US1] Add 404 vs 403 semantics tests in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php +- [X] 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 + +- [X] T022 [US1] Create Baseline Profile Filament resource in app/Filament/Resources/BaselineProfileResource.php (navigation group = Governance) +- [X] T023 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php +- [X] T024 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php +- [X] T025 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +- [X] T026 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php +- [X] T027 [US1] Implement form/table schema in app/Filament/Resources/BaselineProfileResource.php (scope editor backed by App\Support\Inventory\InventoryPolicyTypeMeta) +- [X] 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 +- [X] T029 [US1] Implement audit logging for baseline profile mutations in app/Filament/Resources/BaselineProfileResource.php using App\Services\Audit\WorkspaceAuditLogger +- [X] 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) + +- [X] 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]) +- [X] 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 + +- [X] 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) +- [X] T034 [US2] Add capture start service in app/Services/Baselines/BaselineCaptureService.php (precondition validation before OperationRun creation; enqueue via OperationRunService) +- [X] T035 [P] [US2] Add snapshot identity helper in app/Services/Baselines/BaselineSnapshotIdentity.php (sha256 over normalized items; reuse DriftHasher::hashNormalized where appropriate) +- [X] T036 [P] [US2] Add capture job in app/Jobs/CaptureBaselineSnapshotJob.php (DB-only reads; creates snapshot + items; updates profile active snapshot when applicable) +- [X] T037 [US2] Add audit logging for capture started/completed/failed in app/Jobs/CaptureBaselineSnapshotJob.php using App\Services\Intune\AuditLogger +- [X] 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) + +- [X] T039 [P] [US3] Add assignment CRUD tests (RBAC + uniqueness) in tests/Feature/Baselines/BaselineAssignmentTest.php +- [X] 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]) +- [X] T041 [P] [US3] Add compare idempotent finding fingerprint tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php +- [X] T042 [P] [US3] Add compare summary_counts severity breakdown tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php + +### Implementation — Assignment (v1) + +- [X] T043 [P] [US3] Add BaselineTenantAssignments relation manager in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +- [X] T044 [US3] Enforce assignment RBAC (404 vs 403) and audits in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php (manage-gated mutations; view-gated visibility) +- [X] T045 [US3] Implement effective scope validation (override narrows only) in app/Services/Baselines/BaselineScope.php + +### Implementation — Tenant landing + dashboard card + +- [X] T046 [P] [US3] Add tenant landing page in app/Filament/Pages/BaselineCompareLanding.php (navigation label “Soll vs Ist”; DB-only) +- [X] T047 [P] [US3] Add landing view in resources/views/filament/pages/baseline-compare-landing.blade.php (shows assignment state, last run link, findings link) +- [X] T048 [P] [US3] Add tenant dashboard card widget in app/Filament/Widgets/Dashboard/BaselineCompareNow.php +- [X] T049 [US3] Wire the widget into app/Filament/Pages/TenantDashboard.php (add widget class to getWidgets()) + +### Implementation — Compare engine + job + +- [X] T050 [US3] Add compare start service in app/Services/Baselines/BaselineCompareService.php (preconditions; enqueue OperationRun; freeze snapshot_id in run context) +- [X] T051 [P] [US3] Add compare job in app/Jobs/CompareBaselineToTenantJob.php (DB-only; compute drift items; upsert findings with `source='baseline.compare'`) +- [X] T052 [US3] Implement fingerprinting + idempotent upsert in app/Jobs/CompareBaselineToTenantJob.php (use App\Services\Drift\DriftHasher::fingerprint) +- [X] T053 [US3] Implement severity mapping (missing=high, different=medium, unexpected=low) in app/Jobs/CompareBaselineToTenantJob.php +- [X] T054 [US3] Persist run summary_counts with totals + severity breakdown in app/Jobs/CompareBaselineToTenantJob.php (requires T006) +- [X] 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 + +- [X] T056 [P] Add operation duration hinting for new operation types in app/Support/OperationCatalog.php (expectedDurationSeconds for baseline_capture/baseline_compare) +- [X] T057 Ensure all destructive actions have confirmation in app/Filament/Resources/BaselineProfileResource.php and app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +- [X] T058 Run formatting on touched files via `vendor/bin/sail bin pint --dirty` +- [X] T059 Run focused test suite via `vendor/bin/sail artisan test --compact tests/Feature/Baselines` +- [X] 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 diff --git a/tests/Feature/Baselines/BaselineAssignmentTest.php b/tests/Feature/Baselines/BaselineAssignmentTest.php new file mode 100644 index 0000000..64df738 --- /dev/null +++ b/tests/Feature/Baselines/BaselineAssignmentTest.php @@ -0,0 +1,126 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + expect($assignment)->toBeInstanceOf(BaselineTenantAssignment::class); + expect($assignment->workspace_id)->toBe((int) $tenant->workspace_id); + expect($assignment->tenant_id)->toBe((int) $tenant->getKey()); + expect($assignment->baseline_profile_id)->toBe((int) $profile->getKey()); + expect($assignment->assigned_by_user_id)->toBe((int) $user->getKey()); +}); + +it('prevents duplicate assignments for the same workspace+tenant', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile1 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + $profile2 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile1->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + // Attempting to assign the same tenant in the same workspace should fail + // due to the unique constraint on (workspace_id, tenant_id) + expect(fn () => BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile2->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]))->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('allows the same tenant to be assigned in different workspaces', function () { + [$user1, $tenant1] = createUserWithTenant(role: 'owner'); + [$user2, $tenant2] = createUserWithTenant(role: 'owner'); + + $profile1 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant1->workspace_id, + ]); + $profile2 = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant2->workspace_id, + ]); + + $a1 = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant1->workspace_id, + 'tenant_id' => (int) $tenant1->getKey(), + 'baseline_profile_id' => (int) $profile1->getKey(), + ]); + $a2 = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant2->workspace_id, + 'tenant_id' => (int) $tenant2->getKey(), + 'baseline_profile_id' => (int) $profile2->getKey(), + ]); + + expect($a1->exists)->toBeTrue(); + expect($a2->exists)->toBeTrue(); +}); + +it('deletes an assignment without deleting related models', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + 'assigned_by_user_id' => (int) $user->getKey(), + ]); + + $assignmentId = $assignment->getKey(); + $assignment->delete(); + + expect(BaselineTenantAssignment::query()->find($assignmentId))->toBeNull(); + expect(BaselineProfile::query()->find($profile->getKey()))->not->toBeNull(); + expect(Tenant::query()->find($tenant->getKey()))->not->toBeNull(); +}); + +it('loads the baseline profile relationship from assignment', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'name' => 'Test Profile', + ]); + + $assignment = BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $loaded = $assignment->baselineProfile; + + expect($loaded)->not->toBeNull(); + expect($loaded->name)->toBe('Test Profile'); +}); diff --git a/tests/Feature/Baselines/BaselineCaptureTest.php b/tests/Feature/Baselines/BaselineCaptureTest.php new file mode 100644 index 0000000..b9f69fc --- /dev/null +++ b/tests/Feature/Baselines/BaselineCaptureTest.php @@ -0,0 +1,349 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + /** @var BaselineCaptureService $service */ + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeTrue(); + expect($result)->toHaveKey('run'); + + /** @var OperationRun $run */ + $run = $result['run']; + expect($run->type)->toBe('baseline_capture'); + expect($run->status)->toBe('queued'); + expect($run->tenant_id)->toBe((int) $tenant->getKey()); + + $context = is_array($run->context) ? $run->context : []; + expect($context['baseline_profile_id'])->toBe((int) $profile->getKey()); + expect($context['source_tenant_id'])->toBe((int) $tenant->getKey()); + + Queue::assertPushed(CaptureBaselineSnapshotJob::class); +}); + +it('rejects capture for a draft profile with reason code', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'status' => BaselineProfile::STATUS_DRAFT, + ]); + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); +}); + +it('rejects capture for an archived profile with reason code', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->archived()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); +}); + +it('rejects capture for a tenant from a different workspace', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + [$otherUser, $otherTenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $service = app(BaselineCaptureService::class); + $result = $service->startCapture($profile, $otherTenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe('baseline.capture.missing_source_tenant'); + + Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); +}); + +// --- T032: Concurrent capture reuses active run [EC-004] --- + +it('reuses an existing active run for the same profile/tenant instead of creating a new one', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $service = app(BaselineCaptureService::class); + + $result1 = $service->startCapture($profile, $tenant, $user); + $result2 = $service->startCapture($profile, $tenant, $user); + + expect($result1['ok'])->toBeTrue(); + expect($result2['ok'])->toBeTrue(); + + expect($result1['run']->getKey())->toBe($result2['run']->getKey()); + expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1); +}); + +// --- Snapshot dedupe + capture job execution --- + +it('creates a snapshot with items when the capture job executes', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + InventoryItem::factory()->count(3)->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'], + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job = new CaptureBaselineSnapshotJob($run); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(3); + expect((int) ($counts['succeeded'] ?? 0))->toBe(3); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->first(); + + expect($snapshot)->not->toBeNull(); + expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3); + + $profile->refresh(); + expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey()); +}); + +it('dedupes snapshots when content is unchanged', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + InventoryItem::factory()->count(2)->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'], + ]); + + $opService = app(OperationRunService::class); + $idService = app(BaselineSnapshotIdentity::class); + $auditLogger = app(AuditLogger::class); + + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job1 = new CaptureBaselineSnapshotJob($run1); + $job1->handle($idService, $auditLogger, $opService); + + $snapshotCountAfterFirst = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->count(); + + expect($snapshotCountAfterFirst)->toBe(1); + + $run2 = OperationRun::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'baseline_capture', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + ]); + + $job2 = new CaptureBaselineSnapshotJob($run2); + $job2->handle($idService, $auditLogger, $opService); + + $snapshotCountAfterSecond = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->count(); + + expect($snapshotCountAfterSecond)->toBe(1); +}); + +// --- EC-005: Empty scope produces empty snapshot without errors --- + +it('captures an empty snapshot when no inventory items match the scope', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']], + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => ['nonExistentPolicyType']], + ], + initiator: $user, + ); + + $job = new CaptureBaselineSnapshotJob($run); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(0); + expect((int) ($counts['failed'] ?? 0))->toBe(0); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->first(); + + expect($snapshot)->not->toBeNull(); + expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0); +}); + +it('captures all inventory items when scope has empty policy_types (all types)', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => []], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'deviceConfiguration', + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'policy_type' => 'compliancePolicy', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_capture', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => ['policy_types' => []], + ], + initiator: $user, + ); + + $job = new CaptureBaselineSnapshotJob($run); + $job->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? 0))->toBe(2); + + $snapshot = BaselineSnapshot::query() + ->where('baseline_profile_id', $profile->getKey()) + ->first(); + + expect($snapshot)->not->toBeNull(); + expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(2); +}); diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php new file mode 100644 index 0000000..bbf8704 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -0,0 +1,414 @@ +active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // Baseline has policyA and policyB + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-a-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'content-a'), + 'meta_jsonb' => ['display_name' => 'Policy A'], + ]); + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-b-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'content-b'), + 'meta_jsonb' => ['display_name' => 'Policy B'], + ]); + + // Tenant has policyA (different content) and policyC (unexpected) + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-a-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['different_content' => true], + 'display_name' => 'Policy A modified', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-c-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['new_policy' => true], + 'display_name' => 'Policy C unexpected', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $scopeKey = 'baseline_profile:' . $profile->getKey(); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->get(); + + // policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings + expect($findings->count())->toBe(3); + + $severities = $findings->pluck('severity')->sort()->values()->all(); + expect($severities)->toContain(Finding::SEVERITY_HIGH); + expect($severities)->toContain(Finding::SEVERITY_MEDIUM); + expect($severities)->toContain(Finding::SEVERITY_LOW); +}); + +it('produces idempotent fingerprints so re-running compare updates existing findings', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-content'), + 'meta_jsonb' => ['display_name' => 'Policy X'], + ]); + + // Tenant does NOT have policy-x → missing_policy finding + $opService = app(OperationRunService::class); + + // First run + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job1 = new CompareBaselineToTenantJob($run1); + $job1->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $scopeKey = 'baseline_profile:' . $profile->getKey(); + $countAfterFirst = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->count(); + + expect($countAfterFirst)->toBe(1); + + // Second run - new OperationRun so we can dispatch again + // Mark first run as completed so ensureRunWithIdentity creates a new one + $run1->update(['status' => 'completed', 'completed_at' => now()]); + + $run2 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job2 = new CompareBaselineToTenantJob($run2); + $job2->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $countAfterSecond = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->count(); + + // Same fingerprint → same finding updated, not duplicated + expect($countAfterSecond)->toBe(1); +}); + +it('creates zero findings when baseline matches tenant inventory exactly', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // Baseline item + $metaContent = ['policy_key' => 'value123']; + $driftHasher = app(DriftHasher::class); + $contentHash = $driftHasher->hashNormalized($metaContent); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'matching-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $contentHash, + 'meta_jsonb' => ['display_name' => 'Matching Policy'], + ]); + + // Tenant inventory with same content → same hash + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'matching-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => $metaContent, + 'display_name' => 'Matching Policy', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); + + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + expect((int) ($counts['total'] ?? -1))->toBe(0); + + $findings = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->count(); + + expect($findings)->toBe(0); +}); + +// --- T042: Summary counts severity breakdown tests --- + +it('writes severity breakdown in summary_counts', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // 2 baseline items: one will be missing (high), one will be different (medium) + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'missing-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'missing-content'), + 'meta_jsonb' => ['display_name' => 'Missing Policy'], + ]); + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'changed-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'original-content'), + 'meta_jsonb' => ['display_name' => 'Changed Policy'], + ]); + + // Tenant only has changed-uuid with different content + extra-uuid (unexpected) + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'changed-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['modified_content' => true], + 'display_name' => 'Changed Policy', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'extra-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['extra_content' => true], + 'display_name' => 'Extra Policy', + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + $counts = is_array($run->summary_counts) ? $run->summary_counts : []; + + expect((int) ($counts['total'] ?? -1))->toBe(3); + expect((int) ($counts['high'] ?? -1))->toBe(1); // missing-uuid + expect((int) ($counts['medium'] ?? -1))->toBe(1); // changed-uuid + expect((int) ($counts['low'] ?? -1))->toBe(1); // extra-uuid +}); + +it('writes result context with findings breakdown', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + // One missing policy + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => 'gone-uuid', + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'gone-content'), + ]); + + $opService = app(OperationRunService::class); + $run = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: 'baseline_compare', + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration']], + ], + initiator: $user, + ); + + $job = new CompareBaselineToTenantJob($run); + $job->handle( + app(DriftHasher::class), + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $run->refresh(); + $context = is_array($run->context) ? $run->context : []; + $result = $context['result'] ?? []; + + expect($result)->toHaveKey('findings_total'); + expect($result)->toHaveKey('findings_upserted'); + expect($result)->toHaveKey('severity_breakdown'); + expect((int) $result['findings_total'])->toBe(1); + expect((int) $result['findings_upserted'])->toBe(1); +}); diff --git a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php new file mode 100644 index 0000000..b8df465 --- /dev/null +++ b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php @@ -0,0 +1,178 @@ +startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); +}); + +it('rejects compare when assigned profile is in draft status', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'status' => BaselineProfile::STATUS_DRAFT, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); +}); + +it('rejects compare when assigned profile is archived [EC-001]', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->archived()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); +}); + +it('rejects compare when profile has no active snapshot', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'active_snapshot_id' => null, + ]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeFalse(); + expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT); + + Queue::assertNotPushed(CompareBaselineToTenantJob::class); +}); + +it('enqueues compare successfully when all preconditions are met', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + $result = $service->startCompare($tenant, $user); + + expect($result['ok'])->toBeTrue(); + expect($result)->toHaveKey('run'); + + /** @var OperationRun $run */ + $run = $result['run']; + expect($run->type)->toBe('baseline_compare'); + expect($run->status)->toBe('queued'); + + $context = is_array($run->context) ? $run->context : []; + expect($context['baseline_profile_id'])->toBe((int) $profile->getKey()); + expect($context['baseline_snapshot_id'])->toBe((int) $snapshot->getKey()); + + Queue::assertPushed(CompareBaselineToTenantJob::class); +}); + +// --- EC-004: Concurrent compare reuses active run --- + +it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + BaselineTenantAssignment::create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $service = app(BaselineCompareService::class); + + $result1 = $service->startCompare($tenant, $user); + $result2 = $service->startCompare($tenant, $user); + + expect($result1['ok'])->toBeTrue(); + expect($result2['ok'])->toBeTrue(); + expect($result1['run']->getKey())->toBe($result2['run']->getKey()); + expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1); +}); diff --git a/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php b/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php new file mode 100644 index 0000000..5817486 --- /dev/null +++ b/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php @@ -0,0 +1,156 @@ +create(); + $workspace = Workspace::factory()->create(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $response = $this->actingAs($user) + ->get(BaselineProfileResource::getUrl(panel: 'admin')); + + expect($response->status())->toBeIn([403, 404, 302], 'Non-members should not get HTTP 200'); + }); + + it('returns 404 for members accessing a profile from another workspace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')) + ->assertNotFound(); + }); + + it('returns 200 for readonly members accessing list page', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl(panel: 'admin')) + ->assertOk(); + }); + + it('returns 403 for members with mocked missing capability on list page', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl(panel: 'admin')) + ->assertForbidden(); + }); + + it('returns 403 for readonly members accessing create page', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('create', panel: 'admin')) + ->assertForbidden(); + }); + + it('returns 200 for owner members accessing create page', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('create', panel: 'admin')) + ->assertOk(); + }); + + it('returns 404 for members accessing profile from another workspace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')) + ->assertNotFound(); + }); + + it('returns 403 for readonly members accessing edit page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin')) + ->assertForbidden(); + }); + + it('returns 200 for owner members accessing edit page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin')) + ->assertOk(); + }); +}); + +describe('BaselineProfile static authorization methods', function () { + it('canViewAny returns false for non-members', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + expect(BaselineProfileResource::canViewAny())->toBeFalse(); + }); + + it('canViewAny returns true for members', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + + expect(BaselineProfileResource::canViewAny())->toBeTrue(); + }); + + it('canCreate returns true for managers and false for readonly', function (): void { + [$owner] = createUserWithTenant(role: 'owner'); + $this->actingAs($owner); + expect(BaselineProfileResource::canCreate())->toBeTrue(); + + [$readonly] = createUserWithTenant(role: 'readonly'); + $this->actingAs($readonly); + expect(BaselineProfileResource::canCreate())->toBeFalse(); + }); +}); diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 5039e17..8189b42 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\OperationRunResource; @@ -73,6 +74,24 @@ } }); +it('discovers the baseline profile resource and validates its declaration', function (): void { + $components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents()) + ->keyBy('className'); + + $baselineResource = $components->get(BaselineProfileResource::class); + + expect($baselineResource)->not->toBeNull('BaselineProfileResource should be discovered by action surface discovery'); + expect($baselineResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue(); + + $declaration = BaselineProfileResource::actionSurfaceDeclaration(); + $profiles = new ActionSurfaceProfileDefinition; + + foreach ($profiles->requiredSlots($declaration->profile) as $slot) { + expect($declaration->slot($slot)) + ->not->toBeNull("Missing required slot {$slot->value} in BaselineProfileResource declaration"); + } +}); + it('ensures representative declarations satisfy required slots', function (): void { $profiles = new ActionSurfaceProfileDefinition; @@ -80,6 +99,7 @@ PolicyResource::class => PolicyResource::actionSurfaceDeclaration(), OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(), VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(), + BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(), ]; foreach ($declarations as $className => $declaration) {