feat: Phase 2 foundational - capabilities, migrations, models, factories, badges, support classes
T003-T018b: Add workspace_baselines.view/manage capabilities, role mappings, baseline_capture/baseline_compare operation labels, severity summary keys, 5 migrations, 4 models, 4 factories, BaselineScope, BaselineReasonCodes, BaselineProfileStatus badge domain + mapper.
This commit is contained in:
parent
d49d33ac27
commit
74ab2d1404
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||
|
||||
50
app/Models/BaselineProfile.php
Normal file
50
app/Models/BaselineProfile.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Models/BaselineSnapshot.php
Normal file
35
app/Models/BaselineSnapshot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Models/BaselineSnapshotItem.php
Normal file
23
app/Models/BaselineSnapshotItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
40
app/Models/BaselineTenantAssignment.php
Normal file
40
app/Models/BaselineTenantAssignment.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
25
app/Support/Badges/Domains/BaselineProfileStatusBadge.php
Normal file
25
app/Support/Badges/Domains/BaselineProfileStatusBadge.php
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/Support/Baselines/BaselineReasonCodes.php
Normal file
24
app/Support/Baselines/BaselineReasonCodes.php
Normal 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';
|
||||
}
|
||||
113
app/Support/Baselines/BaselineScope.php
Normal file
113
app/Support/Baselines/BaselineScope.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -24,6 +24,9 @@ public static function all(): array
|
||||
'deleted',
|
||||
'items',
|
||||
'tenants',
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
61
database/factories/BaselineProfileFactory.php
Normal file
61
database/factories/BaselineProfileFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
database/factories/BaselineSnapshotFactory.php
Normal file
30
database/factories/BaselineSnapshotFactory.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
database/factories/BaselineSnapshotItemFactory.php
Normal file
30
database/factories/BaselineSnapshotItemFactory.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
database/factories/BaselineTenantAssignmentFactory.php
Normal file
31
database/factories/BaselineTenantAssignmentFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
# Specification Quality Checklist: Golden Master / Baseline Governance v1 (R1.1–R1.4)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-19
|
||||
**Last Validated**: 2026-02-19 (post-analysis remediation)
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Post-Analysis Remediation (2026-02-19)
|
||||
|
||||
- [x] D1: Duplicate FR-003 removed (kept complete version with override clause)
|
||||
- [x] I1: Status lifecycle resolved — `draft ↔ active → archived` (deactivate = return to draft); spec.md + data-model.md aligned
|
||||
- [x] I3: "Delete" removed from UI Action Matrix — v1 is archive-only, no hard-delete
|
||||
- [x] C1: BadgeDomain task (T018a) added for BaselineProfileStatus badge compliance (BADGE-001)
|
||||
- [x] C2: Factory tasks (T018b) added for all 4 new models
|
||||
- [x] U1: `findings.source` default resolved — nullable, NULL default, legacy findings unaffected
|
||||
- [x] U2: Empty-scope edge case (EC-005) covered in T031 test description
|
||||
- [x] U3: Concurrent operation dedup (EC-004) covered in T032 + T040 test descriptions
|
||||
- [x] U4: T033 amended with explicit manage-capability gating via WorkspaceUiEnforcement
|
||||
- [x] U5: `scope_jsonb` schema defined in data-model.md (`{ "policy_types": [...] }`)
|
||||
- [x] E1: SC-001/SC-002 performance spot-check added to T060
|
||||
|
||||
## Notes
|
||||
|
||||
- All 12 analysis findings have been remediated
|
||||
- Task count updated: 60 → 62 (T018a, T018b added in Phase 2)
|
||||
@ -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
|
||||
142
specs/101-golden-master-baseline-governance-v1/data-model.md
Normal file
142
specs/101-golden-master-baseline-governance-v1/data-model.md
Normal 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
|
||||
166
specs/101-golden-master-baseline-governance-v1/plan.md
Normal file
166
specs/101-golden-master-baseline-governance-v1/plan.md
Normal file
@ -0,0 +1,166 @@
|
||||
#+#+#+#+
|
||||
# Implementation Plan: Golden Master / Baseline Governance v1 (R1.1–R1.4)
|
||||
|
||||
**Branch**: `101-golden-master-baseline-governance-v1` | **Date**: 2026-02-19 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a workspace-owned **Golden Master baseline** that can be captured from a tenant into an immutable snapshot, assigned to tenants, and compared (“Soll vs Ist”) to generate drift findings + a compact operational summary.
|
||||
|
||||
This feature reuses existing patterns:
|
||||
- tenant-scoped `OperationRun` for capture/compare observability + idempotency
|
||||
- `findings` for drift tracking with sha256 fingerprints
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Framework**: Laravel 12
|
||||
**Admin UI**: Filament v5 (requires Livewire v4.0+)
|
||||
**Storage**: PostgreSQL (Sail locally); SQLite is used in some tests
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
|
||||
**Target Platform**: Docker via Laravel Sail (macOS dev)
|
||||
**Project Type**: Laravel monolith (server-rendered Filament + Livewire)
|
||||
**Performance Goals**:
|
||||
- Compare completes within ~2 minutes for typical tenants (≤ 500 in-scope policies)
|
||||
**Constraints**:
|
||||
- Baseline/monitoring pages must be DB-only at render time (no outbound HTTP)
|
||||
- Operation start surfaces enqueue-only (no remote work inline)
|
||||
**Scale/Scope**:
|
||||
- Multi-tenant, workspace isolation is an authorization boundary
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: compare operates on “last observed” inventory/policy versions; baseline snapshot is an explicit immutable capture.
|
||||
- Read/write separation: capture/compare are operational jobs; any mutations are confirmed, audited, and tested.
|
||||
- Graph contract path: no Graph calls on UI surfaces; any Graph usage (if introduced) must go through `GraphClientInterface` and `config/graph_contracts.php`.
|
||||
- Deterministic capabilities: new baseline capabilities are added to the canonical registry and role maps.
|
||||
- RBAC-UX semantics: non-member is 404; member but missing capability is 403.
|
||||
- Run observability: capture/compare are always `OperationRun`-tracked; start surfaces only create/reuse runs and enqueue work.
|
||||
- Data minimization: workspace-owned baseline snapshots are minimized and must not store tenant secrets.
|
||||
- Badge semantics (BADGE-001): finding severity/status uses existing `BadgeCatalog` mapping.
|
||||
- Filament Action Surface Contract: resources/pages created or modified must define Header/Row/Bulk/Empty-State + inspect affordance.
|
||||
|
||||
✅ Phase 0 and Phase 1 design choices satisfy the constitution. No violations are required.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/101-golden-master-baseline-governance-v1/
|
||||
├── plan.md
|
||||
├── spec.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── contracts/
|
||||
└── baseline-governance.openapi.yaml
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Implement baseline governance as standard Laravel models, migrations, services, jobs, and Filament resources/pages under the existing `app/` conventions.
|
||||
|
||||
## Phase 0 — Research (completed)
|
||||
|
||||
Outputs:
|
||||
- [research.md](research.md)
|
||||
|
||||
Key decisions captured:
|
||||
- Workspace-owned, data-minimized baseline snapshots (reusable across tenants)
|
||||
- `OperationRun` types `baseline_capture` and `baseline_compare` using `ensureRunWithIdentity()` for idempotent starts
|
||||
- Precondition failures return 422 with stable `reason_code` and do not create runs
|
||||
- Findings reuse `findings` with sha256 fingerprints; Phase 2 adds `findings.source` to satisfy `source = baseline.compare`
|
||||
|
||||
## Phase 1 — Design & Contracts (completed)
|
||||
|
||||
Outputs:
|
||||
- [data-model.md](data-model.md)
|
||||
- [contracts/baseline-governance.openapi.yaml](contracts/baseline-governance.openapi.yaml)
|
||||
- [quickstart.md](quickstart.md)
|
||||
|
||||
### UI Surfaces (Filament v5)
|
||||
|
||||
- Workspace-context: Governance → Baselines
|
||||
- Resource for baseline profile CRUD
|
||||
- Capture action (manage capability)
|
||||
- Tenant assignment management (manage capability)
|
||||
- Tenant-context: “Soll vs Ist” landing page
|
||||
- Compare-now action (view capability)
|
||||
- Links to latest compare run + findings
|
||||
|
||||
### Authorization & capabilities
|
||||
|
||||
- Add new workspace capabilities:
|
||||
- `workspace_baselines.view`
|
||||
- `workspace_baselines.manage`
|
||||
- Enforce membership as 404; capability denial as 403.
|
||||
|
||||
### Operation types
|
||||
|
||||
- `baseline_capture` (tenant = source tenant)
|
||||
- `baseline_compare` (tenant = target/current tenant)
|
||||
|
||||
### Agent context update (completed)
|
||||
|
||||
The agent context update script was run during planning. It should be re-run after this plan update if the automation depends on parsed plan values.
|
||||
|
||||
## Phase 2 — Execution Plan (to be translated into tasks.md)
|
||||
|
||||
1) **Migrations + models**
|
||||
- Add baseline tables (`baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`)
|
||||
- Add `findings.source` column + index to meet FR-015
|
||||
|
||||
2) **Services**
|
||||
- Baseline scope resolution (profile scope ∩ assignment override)
|
||||
- Snapshot identity calculation and dedupe strategy
|
||||
- Compare engine to produce drift items and severity mapping (FR-009)
|
||||
|
||||
3) **Jobs (queued)**
|
||||
- `CaptureBaselineSnapshotJob` (creates snapshot, items, updates active snapshot when applicable)
|
||||
- `CompareBaselineToTenantJob` (creates/updates findings + writes run summary_counts)
|
||||
|
||||
4) **Filament UI**
|
||||
- Baseline profiles resource (list/view/edit) with action surfaces per spec matrix
|
||||
- Tenant assignment UI surface (v1: single baseline per tenant)
|
||||
- “Soll vs Ist” tenant landing page and dashboard card action
|
||||
|
||||
5) **RBAC + policies**
|
||||
- Capability registry + role maps + UI enforcement
|
||||
- Regression tests for 404 vs 403 semantics
|
||||
|
||||
6) **Tests (Pest)**
|
||||
- CRUD authorization + action surface expectations
|
||||
- Capture snapshot dedupe
|
||||
- Compare finding fingerprint idempotency
|
||||
- Precondition failures return 422 and create no `OperationRun`
|
||||
|
||||
7) **Formatting**
|
||||
- Run `vendor/bin/sail bin pint --dirty`
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
None.
|
||||
60
specs/101-golden-master-baseline-governance-v1/quickstart.md
Normal file
60
specs/101-golden-master-baseline-governance-v1/quickstart.md
Normal 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 aren’t visible): `vendor/bin/sail npm run dev`
|
||||
|
||||
## Happy path walkthrough
|
||||
|
||||
### 1) Create a baseline profile
|
||||
- Navigate to Admin → Governance → Baselines
|
||||
- Create a profile with:
|
||||
- name
|
||||
- status = draft
|
||||
- scope filter (policy types/domains)
|
||||
|
||||
### 2) Capture from a source tenant
|
||||
- From the Baseline Profile view page, trigger “Capture from tenant”
|
||||
- Select a source tenant
|
||||
- Confirm the action
|
||||
- You should see a queued notification with “View run” that links to Monitoring → Operations
|
||||
|
||||
Expected:
|
||||
- An `OperationRun` of type `baseline_capture` is created (or reused if one is already queued/running)
|
||||
- On success, an immutable `baseline_snapshot` is created and the profile’s `active_snapshot_id` is updated (when profile is active)
|
||||
|
||||
### 3) Assign baseline to a tenant
|
||||
- Navigate to the tenant context (Admin → choose tenant)
|
||||
- Assign the baseline profile to the tenant (v1: exactly one baseline per tenant)
|
||||
- Optionally define an override filter that narrows scope
|
||||
|
||||
### 4) Compare now (Soll vs Ist)
|
||||
- Navigate to the “Soll vs Ist” landing page for the tenant
|
||||
- Click “Compare now”
|
||||
|
||||
Expected:
|
||||
- An `OperationRun` of type `baseline_compare` is created/reused
|
||||
- Findings are created/updated with stable fingerprints
|
||||
- The compare run summary is persisted (totals + severity breakdown)
|
||||
|
||||
## Precondition failure checks
|
||||
|
||||
These should return **HTTP 422** with `reason_code`, and must **not** create an `OperationRun`:
|
||||
- compare with no assignment: `baseline.compare.no_assignment`
|
||||
- compare when profile not active: `baseline.compare.profile_not_active`
|
||||
- compare when no active snapshot: `baseline.compare.no_active_snapshot`
|
||||
- capture with missing source tenant: `baseline.capture.missing_source_tenant`
|
||||
|
||||
## Test focus (when implementation lands)
|
||||
- BaselineProfile CRUD + RBAC (404 vs 403)
|
||||
- Capture idempotency (dedupe snapshot identity)
|
||||
- Compare idempotency (dedupe finding fingerprint)
|
||||
- Action surfaces comply with the Filament Action Surface Contract
|
||||
101
specs/101-golden-master-baseline-governance-v1/research.md
Normal file
101
specs/101-golden-master-baseline-governance-v1/research.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Phase 0 — Research
|
||||
|
||||
This document records the key technical decisions for **101 — Golden Master / Baseline Governance v1 (R1.1–R1.4)**, grounded in existing TenantPilot patterns.
|
||||
|
||||
## Existing System Constraints (confirmed in repo)
|
||||
|
||||
### Operation runs are tenant-scoped and deduped at DB level
|
||||
- `OperationRunService::ensureRunWithIdentity()` requires a `Tenant` with a non-null `workspace_id`, and always creates runs with `workspace_id` + `tenant_id`.
|
||||
- Active-run idempotency is enforced via the `operation_runs_active_unique` partial unique index (queued/running).
|
||||
|
||||
**Implication**: Baseline capture/compare must always be executed as **tenant-owned `OperationRun` records**, even though baseline profiles are workspace-owned.
|
||||
|
||||
### Findings fingerprinting expects sha256 (64 chars)
|
||||
- `findings.fingerprint` is `string(64)` and unique by `(tenant_id, fingerprint)`.
|
||||
- `DriftHasher` already implements a stable sha256 fingerprint scheme.
|
||||
|
||||
**Implication**: Any baseline-compare “finding key” should be hashed before storage, and must be stable across repeated compares.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-001 — Baseline snapshot storage is workspace-owned (and data-minimized)
|
||||
**Decision**: Store `baseline_snapshots` and `baseline_snapshot_items` as **workspace-owned** tables (`workspace_id` NOT NULL, **no `tenant_id`**) so a golden master snapshot can be used across tenants without requiring access to the source tenant.
|
||||
|
||||
**Rationale**:
|
||||
- The product intent is a workspace-level standard (“Golden Master”) reusable across multiple tenants.
|
||||
- Treat the snapshot as a **standard artifact**, not tenant evidence, and enforce strict data-minimization so we do not leak tenant-specific content.
|
||||
|
||||
**Guardrails**:
|
||||
- Snapshot items store only policy identity + stable content hashes and minimal display metadata.
|
||||
- Any tenant identifiers (e.g., “captured from tenant”) live in the **capture `OperationRun.context`** and audit logs, not on workspace-owned snapshot rows.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Tenant-owned baseline snapshot (include `tenant_id`): rejected because it would require cross-tenant reads of tenant-owned records to compare other tenants, which would either violate tenant isolation or force “must be member of the source tenant” semantics.
|
||||
|
||||
### D-002 — OperationRun types and identity inputs
|
||||
**Decision**:
|
||||
- Introduce `OperationRun.type` values:
|
||||
- `baseline_capture`
|
||||
- `baseline_compare`
|
||||
- Use `OperationRunService::ensureRunWithIdentity()` for idempotent start surfaces.
|
||||
|
||||
**Identity inputs**:
|
||||
- `baseline_capture`: identity inputs include `baseline_profile_id`.
|
||||
- `baseline_compare`: identity inputs include `baseline_profile_id`.
|
||||
|
||||
**Rationale**:
|
||||
- Guarantees one active run per tenant+baseline profile (matches partial unique index behavior).
|
||||
- Keeps identity stable even if the active snapshot is switched mid-flight; the run context should freeze `baseline_snapshot_id` at enqueue time for determinism.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Include `baseline_snapshot_id` in identity: rejected for v1 because we primarily want “single active compare per tenant/profile”, not “single active compare per snapshot”.
|
||||
|
||||
### D-003 — Precondition failures return 422 and do not create OperationRuns
|
||||
**Decision**: Enforce FR-014 exactly:
|
||||
- The start surface validates preconditions **before** calling `OperationRunService`.
|
||||
- If unmet, return **HTTP 422** with a stable `reason_code` and **do not** create an `OperationRun`.
|
||||
|
||||
**Rationale**:
|
||||
- Aligns with spec clarifications and avoids polluting Monitoring → Operations with non-startable attempts.
|
||||
|
||||
### D-004 — Findings storage uses existing `findings` table; add a source discriminator
|
||||
**Decision**:
|
||||
- Store baseline-compare drift as `Finding::FINDING_TYPE_DRIFT`.
|
||||
- Persist `source = baseline.compare` per FR-015.
|
||||
|
||||
**Implementation note**:
|
||||
- The current `findings` schema does not have a `source` column.
|
||||
- In Phase 2 implementation we should add `findings.source` (string, **nullable**, default `NULL`) with an index `(tenant_id, source)` and use `source='baseline.compare'`.
|
||||
- Existing findings receive `NULL` — legacy drift-generate findings are queried with `whereNull('source')` or unconditionally.
|
||||
- A future backfill migration may set `source='drift.generate'` for historical findings if needed for reporting.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store `source` only in `evidence_jsonb`: workable, but makes filtering and long-term reporting harder and is less explicit.
|
||||
- Non-null default `'drift.generate'`: rejected because retroactively tagging all existing findings requires careful validation and is a separate concern.
|
||||
|
||||
### D-005 — Baseline compare scope_key strategy
|
||||
**Decision**: Use `findings.scope_key = 'baseline_profile:' . baseline_profile_id` for baseline-compare findings.
|
||||
|
||||
**Rationale**:
|
||||
- Keeps a stable grouping key for tenant UI (“Soll vs Ist” for the assigned baseline).
|
||||
- Avoids over-coupling to inventory selection hashes in v1.
|
||||
|
||||
### D-006 — Authorization model (404 vs 403)
|
||||
**Decision**:
|
||||
- Membership is enforced as deny-as-not-found (404) via existing membership checks.
|
||||
- Capability denials are 403 after membership is established.
|
||||
|
||||
**Capabilities**:
|
||||
- Add workspace capabilities:
|
||||
- `workspace_baselines.view`
|
||||
- `workspace_baselines.manage`
|
||||
|
||||
**Rationale**:
|
||||
- Matches the feature spec’s two-capability requirement.
|
||||
- Keeps baseline governance controlled at workspace plane, while still enforcing tenant membership for tenant-context pages/actions.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a tenant-plane capability for compare start: rejected for v1 to keep to the two-capability spec and avoid introducing a second permission axis for the same action.
|
||||
|
||||
## Open Questions (none blocking Phase 1)
|
||||
- None.
|
||||
167
specs/101-golden-master-baseline-governance-v1/spec.md
Normal file
167
specs/101-golden-master-baseline-governance-v1/spec.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Feature Specification: Golden Master / Baseline Governance v1 (R1.1–R1.4)
|
||||
|
||||
**Feature Branch**: `101-golden-master-baseline-governance-v1`
|
||||
**Created**: 2026-02-19
|
||||
**Status**: Draft
|
||||
**Input**: Introduce a workspace-owned “Golden Master” baseline that can be captured from a tenant, compared against the current tenant state, and surfaced in the UI as “Soll vs Ist” with drift findings and an operational summary.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**: Admin Governance “Baselines” area; tenant-facing dashboard card; drift comparison landing (“Soll vs Ist”)
|
||||
- **Data Ownership**: Baselines and snapshots are workspace-owned; tenant assignments are tenant-owned (workspace_id + tenant_id); drift findings are tenant-owned (workspace_id + tenant_id)
|
||||
- **RBAC**: workspace membership required for any visibility; capability gating for view vs manage (see Requirements)
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-19
|
||||
|
||||
- Q: Which v1 drift severity mapping should we use? → A: Fixed mapping: missing_policy=high, different_version=medium, unexpected_policy=low.
|
||||
- Q: Which snapshot should compare runs use in v1? → A: Always use BaselineProfile.active_snapshot; if missing, block compare with a clear reason.
|
||||
- Q: How should compare/capture handle precondition failures? → A: UI disables where possible AND server blocks start with 422 + stable reason_code; no OperationRun is created for failed preconditions.
|
||||
- Q: Where should drift findings be stored in v1? → A: Use existing findings storage with source=baseline.compare; fingerprint/idempotency lives there.
|
||||
- Q: How should tenant override filters combine with the profile filter? → A: Override narrows scope; effective filter = profile ∩ override.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Create and manage a baseline profile (Priority: P1)
|
||||
|
||||
As a workspace governance owner/manager, I can define what “good” looks like by creating a Baseline Profile, controlling its status (draft/active/archived), and scoping which policy domains are included.
|
||||
|
||||
**Why this priority**: Without a baseline profile, there is nothing to capture or compare against.
|
||||
|
||||
**Independent Test**: A user with baseline manage rights can create/edit/archive a baseline profile and see it listed; a read-only user can list/view but cannot mutate.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am a workspace member with baseline manage capability, **When** I create a baseline profile with a name, optional description, optional version label, and a policy-domain filter, **Then** the profile is persisted and visible in the Baselines list.
|
||||
2. **Given** I am a workspace member with baseline view-only capability, **When** I open a baseline profile, **Then** I can view its details but cannot edit/archive/delete it.
|
||||
3. **Given** I am not a member of the workspace, **When** I attempt to access baseline pages, **Then** I receive a not-found response (deny-as-not-found).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Capture an immutable baseline snapshot from a tenant (Priority: P2)
|
||||
|
||||
As a baseline manager, I can capture a snapshot of the current tenant configuration that the baseline profile covers, producing an immutable “baseline snapshot” that can later be compared.
|
||||
|
||||
**Why this priority**: The baseline must be based on a real, point-in-time state to be meaningful and auditable.
|
||||
|
||||
**Independent Test**: Capturing twice with unchanged tenant state reuses the same snapshot identity and does not create duplicates.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline profile exists and I have baseline manage capability, **When** I trigger “Capture from tenant” and choose a source tenant, **Then** a new capture operation is created and eventually produces an immutable snapshot.
|
||||
2. **Given** a capture was already completed for the same baseline profile and the tenant’s relevant policies are unchanged, **When** I capture again, **Then** the system reuses the existing snapshot (idempotent/deduped).
|
||||
3. **Given** the baseline profile is active, **When** a capture completes successfully, **Then** the profile’s “active snapshot” points to the captured snapshot.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Compare baseline vs current tenant to detect drift (Priority: P3)
|
||||
|
||||
As an operator/manager, I can run “Compare now” for a tenant, and the system produces drift findings and a summary that can be used for assurance and triage.
|
||||
|
||||
**Why this priority**: Drift detection is the core governance signal; it makes the baseline actionable.
|
||||
|
||||
**Independent Test**: A compare run produces findings for missing policies, different versions, and unexpected policies, and stores a compact summary.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant is assigned to an active baseline profile with an active snapshot, **When** I run “Compare now”, **Then** a compare operation runs and produces drift findings and a drift summary.
|
||||
2. **Given** the same drift item is detected in repeated compares, **When** compares are run multiple times, **Then** the same finding is updated (idempotent fingerprint) rather than duplicated.
|
||||
3. **Given** I am a workspace member without baseline view capability, **When** I try to start a compare, **Then** the request is forbidden.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Baseline profile is draft or archived: compare is blocked; users are told what must be changed (e.g., “activate baseline”).
|
||||
- Tenant has no baseline assignment: compare button is disabled and the UI explains why.
|
||||
- Baseline profile has no active snapshot yet: compare is blocked with a clear reason.
|
||||
- Concurrent operation starts: the system prevents multiple “active” captures/compares for the same scope.
|
||||
- Baseline filter yields no relevant policies: capture creates an empty snapshot and compare returns “no items checked”, without errors.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Baseline Profiles)**: The system MUST allow workspace baseline managers to create, edit, activate, deactivate (return to draft), and archive baseline profiles. Status transitions: draft ↔ active → archived (archived is terminal in v1).
|
||||
- **FR-002 (Scope Control)**: Each baseline profile MUST define which policy domains/types are included in the baseline.
|
||||
- **FR-003 (Tenant Assignment)**: The system MUST support assigning exactly one baseline profile per tenant per workspace (v1), with an optional per-tenant override of the profile’s scope; in v1 the override may only narrow scope (effective filter = profile ∩ override).
|
||||
- **FR-004 (Capture as Operation)**: Capturing a baseline snapshot MUST be tracked as an observable operation with a clear lifecycle (started/completed/failed).
|
||||
- **FR-005 (Immutable Snapshots)**: Baseline snapshots and their snapshot items MUST be immutable once created.
|
||||
- **FR-006 (Capture Idempotency)**: Captures MUST be deduplicated so that repeated captures with the same effective content reuse the existing snapshot identity.
|
||||
- **FR-007 (Compare as Operation)**: Comparing a tenant against its baseline MUST be tracked as an observable operation with a clear lifecycle.
|
||||
- **FR-008 (Drift Findings)**: Compare MUST produce drift findings using at least these drift types: missing baseline policy, different version, and unexpected policy within the baseline scope.
|
||||
- **FR-009 (Severity Rules)**: Drift findings MUST be assigned severities using centrally defined rules so that severity is consistent and testable; v1 fixed mapping is: missing baseline policy = high, different version = medium, unexpected policy = low.
|
||||
- **FR-010 (Finding Idempotency)**: Drift findings MUST be deduplicated with a stable fingerprint so repeated compares update existing open findings instead of creating duplicates.
|
||||
- **FR-011 (Summary Output)**: Each compare operation MUST persist a summary containing totals and severity breakdowns suitable for dashboards.
|
||||
- **FR-012 (UI “Soll vs Ist”)**: The UI MUST allow selecting a baseline profile and viewing the latest compare runs and drift findings for a tenant.
|
||||
- **FR-013 (Compare Snapshot Selection)**: Compare runs MUST always use the baseline profile’s active snapshot; if no active snapshot exists, compare MUST be blocked with a clear reason.
|
||||
- **FR-014 (Precondition Failure Contract)**: When capture/compare cannot start due to unmet preconditions, the UI MUST disable the action where possible, and the server MUST reject the request with HTTP 422 containing a stable `reason_code`; in this case, the system MUST NOT create an OperationRun.
|
||||
- **FR-015 (Findings Storage)**: Drift findings produced by compare MUST be persisted using the existing findings system with `source = baseline.compare`.
|
||||
|
||||
### Precondition `reason_code` (v1)
|
||||
|
||||
- `baseline.compare.no_assignment`
|
||||
- `baseline.compare.profile_not_active`
|
||||
- `baseline.compare.no_active_snapshot`
|
||||
- `baseline.capture.missing_source_tenant`
|
||||
|
||||
### Constitution Alignment: Safety, Isolation, Observability
|
||||
|
||||
- **Ops/Observability**: Capture and compare MUST be observable and auditable operations, and surfaced in the existing operations monitoring experience.
|
||||
- **DB-only UI**: Baseline pages and drift pages MUST NOT require outbound network calls during page render or user clicks that only start/view operations; external calls (if any) must happen in background work.
|
||||
- **Tenant Isolation / RBAC semantics**:
|
||||
- non-member of workspace or tenant scope → deny-as-not-found (404)
|
||||
- member but missing capability → forbidden (403)
|
||||
- **Least privilege**: Two capabilities MUST exist and be enforced:
|
||||
- baseline view: can view baselines and start compare operations
|
||||
- baseline manage: can manage baselines, assignments, and capture snapshots
|
||||
- **Auditability**: Baseline-related operations MUST emit audit log entries for started/completed/failed events.
|
||||
- **Safe logging**: Failures MUST not include secrets or sensitive tenant data; failure reasons must be stable and suitable for support triage.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline Profiles | Admin → Governance → Baselines | Create baseline profile | View/Edit page available | View, Edit | Archive (grouped) | Create baseline profile | Capture from tenant; Activate/Deactivate; Assign to tenants | Save, Cancel | Yes | Destructive actions require confirmation; mutations are manage-gated; hard-delete is out of scope for v1 (archive only) |
|
||||
| Drift Landing (“Soll vs Ist”) | Tenant view | Run compare now | Links to last compare operation and findings list | — | — | — | — | — | Yes | Starting compare is view-gated; results visible only to entitled users |
|
||||
| Tenant Dashboard Card | Tenant dashboard | Run compare now | Click to drift landing and/or last compare operation | — | — | — | — | — | Yes | Button disabled with explanation when no assignment or no active snapshot |
|
||||
|
||||
## Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Baseline Profile**: A workspace-owned definition of what should be in-scope for governance, with a lifecycle status (draft/active/archived).
|
||||
- **Tenant Assignment**: A workspace-managed mapping that declares which baseline applies to a tenant, optionally overriding scope.
|
||||
- **Baseline Snapshot**: An immutable point-in-time record of the baseline’s in-scope policy references captured from a tenant.
|
||||
- **Snapshot Item**: A single baseline entry representing one in-scope policy reference in the snapshot.
|
||||
- **Drift Finding**: A record representing an observed deviation between baseline and tenant state, deduplicated by a stable fingerprint.
|
||||
- **Drift Finding Source**: Drift findings produced by this feature use `source = baseline.compare`.
|
||||
- **Operation Summary**: A compact, persisted summary of a capture/compare run suitable for dashboard display.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A baseline manager can create a baseline profile and perform an initial capture in under 5 minutes.
|
||||
- **SC-002**: For a typical tenant (≤ 500 in-scope policies), a compare run completes and surfaces a summary within 2 minutes.
|
||||
- **SC-003**: Re-running compare within 24 hours for unchanged drift does not create duplicate findings (0 duplicate drift fingerprints).
|
||||
- **SC-004**: Unauthorized users (non-members) receive no baseline visibility (deny-as-not-found) and members without capability cannot mutate (forbidden).
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Evidence packs / stored reports for audit exports
|
||||
- Advanced findings workflow (exceptions, auto-closing, recurrence handling)
|
||||
- Cross-tenant portfolio comparisons
|
||||
- Baseline inheritance across organizations (e.g., MSP → customer)
|
||||
- Assignment/scope-tag baselines beyond the policy domains/types included in the profile scope
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Tenants already have an inventory of policy versions that can be referenced for capture and compare.
|
||||
- An operations monitoring experience exists where capture/compare runs can be viewed.
|
||||
- A drift findings system exists that can store and display findings and severities.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Inventory + policy version history is available and trustworthy.
|
||||
- Operation run tracking and monitoring is available.
|
||||
- RBAC + UI enforcement semantics are already established (404 for non-member, 403 for missing capability).
|
||||
- Alerts are optional for v1; the feature remains valuable without alert integrations.
|
||||
209
specs/101-golden-master-baseline-governance-v1/tasks.md
Normal file
209
specs/101-golden-master-baseline-governance-v1/tasks.md
Normal 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.
|
||||
|
||||
- [ ] T001 Verify feature docs are in place in specs/101-golden-master-baseline-governance-v1/
|
||||
- [ ] T002 Verify local dev prerequisites via quickstart in specs/101-golden-master-baseline-governance-v1/quickstart.md
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core primitives used across all user stories (schema, capabilities, operation UX).
|
||||
|
||||
**Checkpoint**: After this phase, US1/US2/US3 work can proceed.
|
||||
|
||||
- [ ] T003 Add baseline capabilities to app/Support/Auth/Capabilities.php (`workspace_baselines.view`, `workspace_baselines.manage`)
|
||||
- [ ] T004 Add baseline capabilities to app/Services/Auth/WorkspaceRoleCapabilityMap.php (Owner/Manager = manage; Operator/Readonly = view)
|
||||
- [ ] T005 [P] Add operation labels for baseline_capture + baseline_compare in app/Support/OperationCatalog.php
|
||||
- [ ] T006 [P] Extend summary keys to include severity breakdown keys in app/Support/OpsUx/OperationSummaryKeys.php (add `high`, `medium`, `low`)
|
||||
- [ ] T007 Create baseline schema migrations in database/migrations/*_create_baseline_profiles_table.php (workspace-owned)
|
||||
- [ ] T008 Create baseline schema migrations in database/migrations/*_create_baseline_snapshots_table.php (workspace-owned)
|
||||
- [ ] T009 Create baseline schema migrations in database/migrations/*_create_baseline_snapshot_items_table.php (workspace-owned)
|
||||
- [ ] T010 Create baseline schema migrations in database/migrations/*_create_baseline_tenant_assignments_table.php (tenant-owned)
|
||||
- [ ] T011 Create findings source migration in database/migrations/*_add_source_to_findings_table.php
|
||||
- [ ] T012 [P] Add BaselineProfile model in app/Models/BaselineProfile.php (casts for scope_jsonb; status constants/enum)
|
||||
- [ ] T013 [P] Add BaselineSnapshot model in app/Models/BaselineSnapshot.php (casts for summary_jsonb)
|
||||
- [ ] T014 [P] Add BaselineSnapshotItem model in app/Models/BaselineSnapshotItem.php (casts for meta_jsonb)
|
||||
- [ ] T015 [P] Add BaselineTenantAssignment model in app/Models/BaselineTenantAssignment.php (casts for override_scope_jsonb)
|
||||
- [ ] T016 [P] Update Finding model for new column in app/Models/Finding.php (fillable/casts for `source` as needed by existing conventions)
|
||||
- [ ] T017 [P] Add baseline scope value object / helpers in app/Support/Baselines/BaselineScope.php (normalize + intersect profile/override)
|
||||
- [ ] T018 [P] Add baseline reason codes in app/Support/Baselines/BaselineReasonCodes.php (constants for 422 responses)
|
||||
- [ ] T018a [P] Add BadgeDomain::BaselineProfileStatus case to app/Support/Badges/BadgeDomain.php + create domain mapper class in app/Support/Badges/Domains/BaselineProfileStatusBadges.php + register in BadgeCatalog (BADGE-001 compliance for draft/active/archived)
|
||||
- [ ] T018b [P] Add model factories: database/factories/BaselineProfileFactory.php, BaselineSnapshotFactory.php, BaselineSnapshotItemFactory.php, BaselineTenantAssignmentFactory.php (required for Pest tests in Phase 3–5)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Baseline Profile CRUD (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Workspace managers define baseline profiles (scope + lifecycle) with correct RBAC and action surfaces.
|
||||
|
||||
**Independent Test**:
|
||||
- As a member with `workspace_baselines.manage`, create/edit/archive a profile.
|
||||
- As a member with `workspace_baselines.view`, list/view but cannot mutate.
|
||||
- As a non-member, baseline pages/actions deny-as-not-found (404).
|
||||
|
||||
### Tests (required in this repo)
|
||||
|
||||
- [ ] T019 [P] [US1] Add feature test scaffolding in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
|
||||
- [ ] T020 [P] [US1] Add 404 vs 403 semantics tests in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
|
||||
- [ ] T021 [P] [US1] Add Action Surface contract coverage for the new resource in tests/Feature/Guards/ActionSurfaceValidatorTest.php (resource discovered or explicitly whitelisted per existing test conventions)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] T022 [US1] Create Baseline Profile Filament resource in app/Filament/Resources/BaselineProfileResource.php (navigation group = Governance)
|
||||
- [ ] T023 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php
|
||||
- [ ] T024 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php
|
||||
- [ ] T025 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
||||
- [ ] T026 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php
|
||||
- [ ] T027 [US1] Implement form/table schema in app/Filament/Resources/BaselineProfileResource.php (scope editor backed by App\Support\Inventory\InventoryPolicyTypeMeta)
|
||||
- [ ] T028 [US1] Implement RBAC (404 vs 403) and UI enforcement in app/Filament/Resources/BaselineProfileResource.php using App\Services\Auth\WorkspaceCapabilityResolver + App\Support\Rbac\WorkspaceUiEnforcement
|
||||
- [ ] T029 [US1] Implement audit logging for baseline profile mutations in app/Filament/Resources/BaselineProfileResource.php using App\Services\Audit\WorkspaceAuditLogger
|
||||
- [ ] T030 [US1] Ensure the resource is safe for global search (either keep View/Edit pages enabled OR disable global search explicitly) in app/Filament/Resources/BaselineProfileResource.php
|
||||
|
||||
**Checkpoint**: Baseline profiles CRUD works and is independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Capture Immutable Baseline Snapshot (Priority: P2)
|
||||
|
||||
**Goal**: Managers can enqueue capture from a tenant to create (or reuse) an immutable, workspace-owned snapshot.
|
||||
|
||||
**Independent Test**:
|
||||
- Trigger capture twice for unchanged effective content → the same `snapshot_identity_hash` is reused (no duplicates).
|
||||
- If profile is active, capture success updates `active_snapshot_id`.
|
||||
|
||||
### Tests (required in this repo)
|
||||
|
||||
- [ ] T031 [P] [US2] Add capture enqueue + precondition tests in tests/Feature/Baselines/BaselineCaptureTest.php (include: empty-scope capture produces empty snapshot without errors [EC-005])
|
||||
- [ ] T032 [P] [US2] Add snapshot dedupe test in tests/Feature/Baselines/BaselineCaptureTest.php (include: concurrent capture request for same scope reuses active run [EC-004])
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] T033 [P] [US2] Add capture action UI to app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php (Action::make()->action()->requiresConfirmation(); select source tenant; gated by `workspace_baselines.manage` via WorkspaceUiEnforcement — disabled with tooltip for view-only members, server-side 403 on execution)
|
||||
- [ ] T034 [US2] Add capture start service in app/Services/Baselines/BaselineCaptureService.php (precondition validation before OperationRun creation; enqueue via OperationRunService)
|
||||
- [ ] T035 [P] [US2] Add snapshot identity helper in app/Services/Baselines/BaselineSnapshotIdentity.php (sha256 over normalized items; reuse DriftHasher::hashNormalized where appropriate)
|
||||
- [ ] T036 [P] [US2] Add capture job in app/Jobs/CaptureBaselineSnapshotJob.php (DB-only reads; creates snapshot + items; updates profile active snapshot when applicable)
|
||||
- [ ] T037 [US2] Add audit logging for capture started/completed/failed in app/Jobs/CaptureBaselineSnapshotJob.php using App\Services\Intune\AuditLogger
|
||||
- [ ] T038 [US2] Persist run context + summary_counts for capture in app/Jobs/CaptureBaselineSnapshotJob.php (use only allowed summary keys)
|
||||
|
||||
**Checkpoint**: Capture creates immutable snapshots and dedupes repeated capture.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Assign + Compare (“Soll vs Ist”) (Priority: P3)
|
||||
|
||||
**Goal**: Operators can assign a baseline to a tenant (v1: exactly one), then enqueue compare-now to generate drift findings + summary.
|
||||
|
||||
**Independent Test**:
|
||||
- With assignment + active snapshot: compare enqueues an operation and produces findings + summary.
|
||||
- Re-running compare updates existing findings (same fingerprint) rather than duplicating.
|
||||
|
||||
### Tests (required in this repo)
|
||||
|
||||
- [ ] T039 [P] [US3] Add assignment CRUD tests (RBAC + uniqueness) in tests/Feature/Baselines/BaselineAssignmentTest.php
|
||||
- [ ] T040 [P] [US3] Add compare precondition 422 tests (no OperationRun created) in tests/Feature/Baselines/BaselineComparePreconditionsTest.php (include: concurrent compare reuses active run [EC-004]; draft/archived profile blocks compare [EC-001])
|
||||
- [ ] T041 [P] [US3] Add compare idempotent finding fingerprint tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
- [ ] T042 [P] [US3] Add compare summary_counts severity breakdown tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
|
||||
### Implementation — Assignment (v1)
|
||||
|
||||
- [ ] T043 [P] [US3] Add BaselineTenantAssignments relation manager in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
|
||||
- [ ] T044 [US3] Enforce assignment RBAC (404 vs 403) and audits in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php (manage-gated mutations; view-gated visibility)
|
||||
- [ ] T045 [US3] Implement effective scope validation (override narrows only) in app/Services/Baselines/BaselineScope.php
|
||||
|
||||
### Implementation — Tenant landing + dashboard card
|
||||
|
||||
- [ ] T046 [P] [US3] Add tenant landing page in app/Filament/Pages/BaselineCompareLanding.php (navigation label “Soll vs Ist”; DB-only)
|
||||
- [ ] T047 [P] [US3] Add landing view in resources/views/filament/pages/baseline-compare-landing.blade.php (shows assignment state, last run link, findings link)
|
||||
- [ ] T048 [P] [US3] Add tenant dashboard card widget in app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||
- [ ] T049 [US3] Wire the widget into app/Filament/Pages/TenantDashboard.php (add widget class to getWidgets())
|
||||
|
||||
### Implementation — Compare engine + job
|
||||
|
||||
- [ ] T050 [US3] Add compare start service in app/Services/Baselines/BaselineCompareService.php (preconditions; enqueue OperationRun; freeze snapshot_id in run context)
|
||||
- [ ] T051 [P] [US3] Add compare job in app/Jobs/CompareBaselineToTenantJob.php (DB-only; compute drift items; upsert findings with `source='baseline.compare'`)
|
||||
- [ ] T052 [US3] Implement fingerprinting + idempotent upsert in app/Jobs/CompareBaselineToTenantJob.php (use App\Services\Drift\DriftHasher::fingerprint)
|
||||
- [ ] T053 [US3] Implement severity mapping (missing=high, different=medium, unexpected=low) in app/Jobs/CompareBaselineToTenantJob.php
|
||||
- [ ] T054 [US3] Persist run summary_counts with totals + severity breakdown in app/Jobs/CompareBaselineToTenantJob.php (requires T006)
|
||||
- [ ] T055 [US3] Add audit logging for compare started/completed/failed in app/Jobs/CompareBaselineToTenantJob.php using App\Services\Intune\AuditLogger
|
||||
|
||||
**Checkpoint**: Tenant “Soll vs Ist” UI works end-to-end and compare generates deduped findings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [ ] T056 [P] Add operation duration hinting for new operation types in app/Support/OperationCatalog.php (expectedDurationSeconds for baseline_capture/baseline_compare)
|
||||
- [ ] T057 Ensure all destructive actions have confirmation in app/Filament/Resources/BaselineProfileResource.php and app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
|
||||
- [ ] T058 Run formatting on touched files via `vendor/bin/sail bin pint --dirty`
|
||||
- [ ] T059 Run focused test suite via `vendor/bin/sail artisan test --compact tests/Feature/Baselines`
|
||||
- [ ] T060 Run the quickstart walkthrough in specs/101-golden-master-baseline-governance-v1/quickstart.md and adjust any mismatches; spot-check SC-001 (create+capture < 5 min) and SC-002 (compare < 2 min for ≤ 500 policies)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Phase 1 (Setup) → Phase 2 (Foundational) → US phases
|
||||
- Phase 2 blocks US1/US2/US3.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)** is the MVP and enables profile CRUD.
|
||||
- **US2 (P2)** depends on US1 (needs profiles) and Phase 2 schema/services.
|
||||
- **US3 (P3)** depends on US1 (needs profiles) and Phase 2 schema; compare also depends on US2 having produced an active snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1 parallelizable work
|
||||
|
||||
- T023, T024, T025, T026 (resource pages) can be implemented in parallel.
|
||||
- T019 and T020 (authorization tests) can be implemented in parallel with the resource skeleton.
|
||||
|
||||
### US2 parallelizable work
|
||||
|
||||
- T035 (snapshot identity helper) and T036 (capture job) can be implemented in parallel once the schema exists.
|
||||
- T031–T032 tests can be written before the job/service implementation.
|
||||
|
||||
### US3 parallelizable work
|
||||
|
||||
- T046–T048 (landing page + view + widget) can be done in parallel with T050–T054 (compare service/job).
|
||||
- T039–T042 tests can be written before implementation.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 only)
|
||||
|
||||
1. Complete Phase 1 + Phase 2
|
||||
2. Deliver Phase 3 (US1)
|
||||
3. Run: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
|
||||
|
||||
### Incremental delivery
|
||||
|
||||
- Add US2 (capture) → validate dedupe + active snapshot updates
|
||||
- Add US3 (assign + compare) → validate 422 contract + idempotent findings + summary output
|
||||
Loading…
Reference in New Issue
Block a user