Baseline governance UX polish + view Infolist (#123)

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 <ahmed.darrazi@live.de>
Reviewed-on: #123
This commit is contained in:
ahmido 2026-02-19 23:56:09 +00:00
parent d49d33ac27
commit a30be84084
71 changed files with 6331 additions and 520 deletions

View File

@ -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
<!-- Basic Pest Test Example -->
```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()`:
<!-- Pest Response Assertion -->
```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.):
<!-- Pest Dataset Example -->
```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.
<!-- Pest Browser Test Example -->
```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:
<!-- Pest Smoke Testing Example -->
```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):
<!-- Architecture Test Example -->
```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

View File

@ -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-First Config -->
```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:
<!-- v4 Import Syntax -->
```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:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## 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:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## 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

4
.codex/config.toml Normal file
View File

@ -0,0 +1,4 @@
[mcp_servers.laravel-boost]
command = "vendor/bin/sail"
args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"

View File

@ -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`
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

167
.github/skills/pest-testing/SKILL.md vendored Normal file
View File

@ -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
<!-- Basic Pest Test Example -->
```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()`:
<!-- Pest Response Assertion -->
```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.):
<!-- Pest Dataset Example -->
```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.
<!-- Pest Browser Test Example -->
```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:
<!-- Pest Smoke Testing Example -->
```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):
<!-- Architecture Test Example -->
```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

View File

@ -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-First Config -->
```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:
<!-- v4 Import Syntax -->
```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:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## 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:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## 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

View File

@ -1,22 +1,19 @@
<!--
Sync Impact Report
- Version change: 1.8.1 → 1.8.2
- Version change: 1.8.2 → 1.9.0
- Modified principles:
- RBAC Context — Planes, Roles, and Auditability (clarified admin vs tenant-context vs workspace-context)
- Tenant Isolation is Non-negotiable (added scope + ownership rules)
- RBAC-UX-007 — Global search must be tenant-safe (added workspace-context rules)
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added required spec scope fields)
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (tightened UX requirements; added layout/view/empty-state rules)
- Added sections:
- Scope & Ownership Clarification (SCOPE-001)
- Spec Scope Fields (SCOPE-002)
- Filament UI — Layout & Information Architecture Standards (UX-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present in this repo)
- Follow-up TODOs: None
- Follow-up TODOs:
- Add CI regression guards for “no naked forms” + “view must use infolist” (heuristic scan) in test suite.
-->
# 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

View File

@ -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)

View File

@ -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.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.

View File

@ -34,6 +34,14 @@ # Tasks: [FEATURE NAME]
- adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
- capping header actions to max 1 primary + 1 secondary (rest grouped),
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.

357
Agents.md
View File

@ -389,6 +389,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.
@ -398,6 +399,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.
@ -413,6 +415,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`.
@ -421,6 +424,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.
@ -434,6 +438,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.
@ -447,6 +452,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.
@ -459,6 +465,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.
@ -467,6 +474,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.
@ -477,6 +485,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.
@ -486,6 +495,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(...)`.
@ -498,6 +508,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.
@ -507,6 +518,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 arent instantly obvious.
- Treat polling as a cost; set intervals intentionally where polling is used.
@ -515,6 +527,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.
@ -524,6 +537,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 Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
@ -533,6 +547,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+.
@ -547,6 +562,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`).
@ -567,6 +583,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).
@ -574,6 +591,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('')`).
@ -588,6 +606,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.
@ -596,18 +615,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 theres 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()`.
@ -616,6 +638,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.
@ -623,24 +646,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 arent 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 Filaments 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”
@ -648,12 +675,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
@ -666,56 +694,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".
@ -725,38 +770,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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- `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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== 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:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### 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.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### 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.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== 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
<code-snippet name="Pest Browser Test Example" lang="php">
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);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
- 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.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### 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.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### 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.
</laravel-boost-guidelines>
## Active Technologies

357
GEMINI.md
View File

@ -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 arent 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 Filaments 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 theres 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 arent 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 Filaments 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- `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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== 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:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### 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.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### 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.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== 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
<code-snippet name="Pest Browser Test Example" lang="php">
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);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
- 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.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### 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.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### 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.
</laravel-boost-guidelines>
## Recent Changes

View File

@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BaselineCompareLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baseline Compare';
protected static ?int $navigationSort = 10;
protected static ?string $title = 'Baseline Compare';
protected string $view = 'filament.pages.baseline-compare-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $profileName = null;
public ?int $profileId = null;
public ?int $snapshotId = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
/** @var array<string, int>|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<Action>
*/
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);
}
}

View File

@ -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';

View File

@ -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,
];

View File

@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class BaselineProfileResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = BaselineProfile::class;
protected static ?string $slug = 'baseline-profiles';
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baselines';
protected static ?int $navigationSort = 1;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->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<string, string>
*/
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<string, mixed> $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;
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateBaselineProfile extends CreateRecord
{
protected static string $resource = BaselineProfileResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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();
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditBaselineProfile extends EditRecord
{
protected static string $resource = BaselineProfileResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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();
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListBaselineProfiles extends ListRecords
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
];
}
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineCaptureService;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
class ViewBaselineProfile extends ViewRecord
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
$this->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<int, string>
*/
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);
}
}

View File

@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class BaselineTenantAssignmentsRelationManager extends RelationManager
{
protected static string $relationship = 'tenantAssignments';
protected static ?string $title = 'Tenant assignments';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->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<int, string>
*/
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);
}
}

View File

@ -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';

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Models\BaselineCompareRun;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class BaselineCompareNow extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
/**
* @return array<string, mixed>
*/
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),
];
}
}

View File

@ -0,0 +1,285 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class CaptureBaselineSnapshotJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public OperationRun $run,
) {
$this->operationRun = $run;
}
/**
* @return array<int, object>
*/
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<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
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<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $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<int, array{policy_type: string}> $items
* @return array<string, int>
*/
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(),
);
}
}

View File

@ -0,0 +1,394 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class CompareBaselineToTenantJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public OperationRun $run,
) {
$this->operationRun = $run;
}
/**
* @return array<int, object>
*/
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<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
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<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}>
*/
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<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
*/
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<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $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<int, array{severity: string}> $driftResults
* @return array<string, int>
*/
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(),
);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BaselineProfile extends Model
{
use HasFactory;
public const string STATUS_DRAFT = 'draft';
public const string STATUS_ACTIVE = 'active';
public const string STATUS_ARCHIVED = 'archived';
protected $guarded = [];
protected $casts = [
'scope_jsonb' => '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);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BaselineSnapshot extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'summary_jsonb' => '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);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BaselineSnapshotItem extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'meta_jsonb' => 'array',
];
public function snapshot(): BelongsTo
{
return $this->belongsTo(BaselineSnapshot::class, 'baseline_snapshot_id');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BaselineTenantAssignment extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
protected $guarded = [];
protected $casts = [
'override_scope_jsonb' => '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');
}
}

View File

@ -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')

View File

@ -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,
],
];

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
final class BaselineCaptureService
{
public function __construct(
private readonly OperationRunService $runs,
) {}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
*/
public function startCapture(
BaselineProfile $profile,
Tenant $sourceTenant,
User $initiator,
): array {
$precondition = $this->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;
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
final class BaselineCompareService
{
public function __construct(
private readonly OperationRunService $runs,
) {}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
*/
public function startCompare(
Tenant $tenant,
User $initiator,
): array {
$assignment = BaselineTenantAssignment::query()
->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;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Services\Drift\DriftHasher;
/**
* Computes the snapshot_identity_hash for baseline snapshot content dedupe.
*
* The identity hash is a sha256 over normalized snapshot items, enabling
* detection of "nothing changed" when capturing the same inventory state.
*/
final class BaselineSnapshotIdentity
{
public function __construct(
private readonly DriftHasher $hasher,
) {}
/**
* Compute identity hash over a set of snapshot items.
*
* Each item is represented as an associative array with:
* - subject_type, subject_external_id, policy_type, baseline_hash
*
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string}> $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);
}
}

View File

@ -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';
}

View File

@ -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';

View File

@ -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,
];
/**

View File

@ -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';
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\BaselineProfile;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineProfileStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
BaselineProfile::STATUS_DRAFT => 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(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
/**
* Stable reason codes for 422 precondition failures.
*
* These codes are returned in the response body when a baseline operation
* cannot start due to unmet preconditions. No OperationRun is created.
*/
final class BaselineReasonCodes
{
public const string CAPTURE_MISSING_SOURCE_TENANT = 'baseline.capture.missing_source_tenant';
public const string CAPTURE_PROFILE_NOT_ACTIVE = 'baseline.capture.profile_not_active';
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
}

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
/**
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
* An empty policy_types array means "all types" (no filter).
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
*/
public function __construct(
public readonly array $policyTypes = [],
) {}
/**
* Create from the scope_jsonb column value.
*
* @param array<string, mixed>|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<string, mixed>
*/
public function toJsonb(): array
{
return [
'policy_types' => $this->policyTypes,
];
}
}

View File

@ -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,
};
}

View File

@ -24,6 +24,9 @@ public static function all(): array
'deleted',
'items',
'tenants',
'high',
'medium',
'low',
];
}
}

View File

@ -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"
]
}

View File

@ -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",

30
composer.lock generated
View File

@ -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"
}

View File

@ -0,0 +1,61 @@
<?php
namespace Database\Factories;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineProfile>
*/
class BaselineProfileFactory extends Factory
{
protected $model = BaselineProfile::class;
/**
* @return array<string, mixed>
*/
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(),
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineSnapshot>
*/
class BaselineSnapshotFactory extends Factory
{
protected $model = BaselineSnapshot::class;
/**
* @return array<string, mixed>
*/
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],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineSnapshotItem>
*/
class BaselineSnapshotItemFactory extends Factory
{
protected $model = BaselineSnapshotItem::class;
/**
* @return array<string, mixed>
*/
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)],
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineTenantAssignment>
*/
class BaselineTenantAssignmentFactory extends Factory
{
protected $model = BaselineTenantAssignment::class;
/**
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_profiles', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_snapshots', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_snapshot_items', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_tenant_assignments', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('findings', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,188 @@
<x-filament::page>
{{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{{-- Stat: Assigned Baseline --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
@if ($snapshotId)
<x-filament::badge color="success" size="sm" class="w-fit">
Snapshot #{{ $snapshotId }}
</x-filament::badge>
@endif
</div>
</x-filament::section>
{{-- Stat: Total Findings --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
{{ $findingsCount ?? 0 }}
</div>
@if ($state === 'comparing')
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
<x-filament::loading-indicator class="h-3 w-3" />
Comparing…
</div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
<span class="text-sm text-success-600 dark:text-success-400">All clear</span>
@endif
</div>
</x-filament::section>
{{-- Stat: Last Compared --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">
{{ $lastComparedAt ?? 'Never' }}
</div>
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View run
</x-filament::link>
@endif
</div>
</x-filament::section>
</div>
@endif
{{-- Critical drift banner --}}
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
<div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
Critical Drift Detected
</div>
<div class="text-sm text-danger-700 dark:text-danger-300">
The current tenant state deviates from baseline <strong>{{ $profileName }}</strong>.
{{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention.
</div>
</div>
</div>
</div>
@endif
{{-- State: No tenant / no assignment / no snapshot --}}
@if (in_array($state, ['no_tenant', 'no_assignment', 'no_snapshot']))
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
@if ($state === 'no_tenant')
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Tenant Selected</div>
@elseif ($state === 'no_assignment')
<x-heroicon-o-link-slash class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Baseline Assigned</div>
@elseif ($state === 'no_snapshot')
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Snapshot Available</div>
@endif
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
</div>
</x-filament::section>
@endif
{{-- Severity breakdown + actions --}}
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
<x-filament::section>
<x-slot name="heading">
{{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
</x-slot>
<x-slot name="description">
The tenant configuration drifted from the baseline profile.
</x-slot>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
@if (($severityCounts['high'] ?? 0) > 0)
<x-filament::badge color="danger">
{{ $severityCounts['high'] }} High
</x-filament::badge>
@endif
@if (($severityCounts['medium'] ?? 0) > 0)
<x-filament::badge color="warning">
{{ $severityCounts['medium'] }} Medium
</x-filament::badge>
@endif
@if (($severityCounts['low'] ?? 0) > 0)
<x-filament::badge color="gray">
{{ $severityCounts['low'] }} Low
</x-filament::badge>
@endif
</div>
<div class="flex items-center gap-3">
@if ($this->getFindingsUrl())
<x-filament::button
:href="$this->getFindingsUrl()"
tag="a"
color="gray"
icon="heroicon-o-eye"
size="sm"
>
View all findings
</x-filament::button>
@endif
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
Review last run
</x-filament::button>
@endif
</div>
</div>
</x-filament::section>
@endif
{{-- Ready: no drift --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Drift Detected</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
The tenant configuration matches the baseline profile. Everything looks good.
</div>
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
Review last run
</x-filament::button>
@endif
</div>
</x-filament::section>
@endif
{{-- Idle state --}}
@if ($state === 'idle')
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">Ready to Compare</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
{{ $message }}
</div>
</div>
</x-filament::section>
@endif
</x-filament::page>

View File

@ -0,0 +1,68 @@
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white">Baseline Governance</div>
@if ($landingUrl)
<x-filament::link :href="$landingUrl" size="sm" icon="heroicon-m-arrow-top-right-on-square" icon-position="after">
Details
</x-filament::link>
@endif
</div>
@if (! $hasAssignment)
<div class="flex items-start gap-3 rounded-lg bg-gray-50 p-4 dark:bg-white/5">
<x-heroicon-o-link-slash class="mt-0.5 h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" />
<div>
<div class="text-sm font-medium text-gray-950 dark:text-white">No Baseline Assigned</div>
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
</div>
</div>
@else
{{-- Profile + last compared --}}
<div class="flex items-center justify-between text-sm">
<div class="text-gray-600 dark:text-gray-300">
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
</div>
@if ($lastComparedAt)
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
@endif
</div>
{{-- Findings summary --}}
@if ($findingsCount > 0)
{{-- Critical banner (inline) --}}
@if ($highCount > 0)
<div class="flex items-center gap-2 rounded-lg border border-danger-300 bg-danger-50 px-3 py-2 dark:border-danger-700 dark:bg-danger-950/50">
<x-heroicon-s-exclamation-triangle class="h-4 w-4 shrink-0 text-danger-600 dark:text-danger-400" />
<span class="text-sm font-medium text-danger-800 dark:text-danger-200">
{{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }}
</span>
</div>
@endif
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if ($mediumCount > 0)
<x-filament::badge color="warning" size="sm">
{{ $mediumCount }} medium
</x-filament::badge>
@endif
@if ($lowCount > 0)
<x-filament::badge color="gray" size="sm">
{{ $lowCount }} low
</x-filament::badge>
@endif
</div>
</div>
@else
<div class="flex items-center gap-2 rounded-lg bg-success-50 px-3 py-2 dark:bg-success-950/50">
<x-heroicon-o-check-circle class="h-4 w-4 shrink-0 text-success-600 dark:text-success-400" />
<span class="text-sm font-medium text-success-700 dark:text-success-300">No open drift baseline compliant</span>
</div>
@endif
@endif
</div>

View File

@ -0,0 +1,50 @@
# Specification Quality Checklist: Golden Master / Baseline Governance v1 (R1.1R1.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)

View File

@ -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

View File

@ -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:<id>'`
- `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

View File

@ -0,0 +1,166 @@
#+#+#+#+
# Implementation Plan: Golden Master / Baseline Governance v1 (R1.1R1.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.

View File

@ -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 arent 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 profiles `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

View File

@ -0,0 +1,101 @@
# Phase 0 — Research
This document records the key technical decisions for **101 — Golden Master / Baseline Governance v1 (R1.1R1.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 specs 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.

View File

@ -0,0 +1,167 @@
# Feature Specification: Golden Master / Baseline Governance v1 (R1.1R1.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 tenants 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 profiles “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 profiles 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 profiles 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 baselines 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.

View File

@ -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 35)
---
## 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.
- T031T032 tests can be written before the job/service implementation.
### US3 parallelizable work
- T046T048 (landing page + view + widget) can be done in parallel with T050T054 (compare service/job).
- T039T042 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

View File

@ -0,0 +1,126 @@
<?php
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
// --- T039: Assignment CRUD tests (RBAC + uniqueness) ---
it('creates a tenant assignment with workspace context', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->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');
});

View File

@ -0,0 +1,349 @@
<?php
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use Illuminate\Support\Facades\Queue;
// --- T031: Capture enqueue + precondition tests ---
it('enqueues capture for an active profile and creates an operation run', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->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);
});

View File

@ -0,0 +1,414 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
// --- T041: Compare idempotent finding fingerprint tests ---
it('creates drift findings when baseline and tenant inventory differ', 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 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);
});

View File

@ -0,0 +1,178 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineReasonCodes;
use Illuminate\Support\Facades\Queue;
// --- T040: Compare precondition 422 tests ---
it('rejects compare when tenant has no baseline assignment', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$service = app(BaselineCompareService::class);
$result = $service->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);
});

View File

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('BaselineProfile RBAC — 404 vs 403 semantics', function () {
it('denies non-members accessing the list page', function (): void {
$user = User::factory()->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();
});
});

View File

@ -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) {