feat(104): Provider Permission Posture (#127)
Implements Spec 104: Provider Permission Posture. What changed - Generates permission posture findings after each tenant permission compare (queued) - Stores immutable posture snapshots as StoredReports (JSONB payload) - Adds global Finding resolved lifecycle (`resolved_at`, `resolved_reason`) with `resolve()` / `reopen()` - Adds alert pipeline event type `permission_missing` (Alerts v1) and Filament option for Alert Rules - Adds retention pruning command + daily schedule for StoredReports - Adds badge mappings for `resolved` finding status and `permission_posture` finding type UX fixes discovered during manual verification - Hide “Diff” section for non-drift findings (only drift findings show diff) - Required Permissions page: “Re-run verification” now links to Tenant view (not onboarding) - Preserve Technical Details `<details>` open state across Livewire re-renders (Alpine state) Verification - Ran `vendor/bin/sail artisan test --compact --filter=PermissionPosture` (50 tests) - Ran `vendor/bin/sail artisan test --compact --filter="FindingResolved|FindingBadge|PermissionMissingAlert"` (20 tests) - Ran `vendor/bin/sail bin pint --dirty` Filament v5 / Livewire v4 compliance - Filament v5 + Livewire v4: no Livewire v3 usage. Panel provider registration (Laravel 11+) - No new panels added. Existing panel providers remain registered via `bootstrap/providers.php`. Global search rule - No changes to global-searchable resources. Destructive actions - No new destructive Filament actions were added in this PR. Assets / deploy notes - No new Filament assets registered. Existing deploy step `php artisan filament:assets` remains unchanged. Test coverage - New/updated Pest feature tests cover generator behavior, job integration, alerting, retention pruning, and resolved lifecycle. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #127
This commit is contained in:
parent
d32b2115a8
commit
ef380b67d1
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -33,6 +33,8 @@ ## Active Technologies
|
||||
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
|
||||
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
|
||||
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -52,8 +54,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 104-provider-permission-posture: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
|
||||
- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class
|
||||
- 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
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
42
app/Console/Commands/PruneStoredReportsCommand.php
Normal file
42
app/Console/Commands/PruneStoredReportsCommand.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneStoredReportsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'stored-reports:prune {--days= : Number of days to retain reports}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete stored reports older than the retention period';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: config('tenantpilot.stored_reports.retention_days', 90));
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Retention days must be at least 1.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = StoredReport::query()
|
||||
->where('created_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Deleted {$deleted} stored report(s) older than {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
@ -169,6 +170,12 @@ private function refreshViewModel(): void
|
||||
|
||||
public function reRunVerificationUrl(): string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
}
|
||||
|
||||
return route('admin.onboarding');
|
||||
}
|
||||
|
||||
|
||||
@ -379,6 +379,7 @@ public static function eventTypeOptions(): array
|
||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -152,6 +152,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Diff')
|
||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||
->schema([
|
||||
ViewEntry::make('settings_diff')
|
||||
->label('')
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Jobs\Alerts;
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Workspace;
|
||||
@ -58,6 +59,7 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
$events = [
|
||||
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||
];
|
||||
|
||||
$createdDeliveries = 0;
|
||||
@ -253,4 +255,42 @@ private function sanitizeErrorMessage(Throwable $exception): string
|
||||
|
||||
return mb_substr($message, 0, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function permissionMissingEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('updated_at', '>', $windowStart)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$events[] = [
|
||||
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'tenant_id' => (int) $finding->tenant_id,
|
||||
'severity' => (string) $finding->severity,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'Missing permission detected',
|
||||
'body' => sprintf(
|
||||
'Permission "%s" is missing for tenant %d (severity: %s).',
|
||||
(string) ($finding->evidence_jsonb['permission_key'] ?? $finding->subject_external_id ?? 'unknown'),
|
||||
(int) $finding->tenant_id,
|
||||
(string) $finding->severity,
|
||||
),
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'permission_key' => (string) ($finding->evidence_jsonb['permission_key'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
}
|
||||
|
||||
93
app/Jobs/GeneratePermissionPostureFindingsJob.php
Normal file
93
app/Jobs/GeneratePermissionPostureFindingsJob.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\PermissionPosture\FindingGeneratorContract;
|
||||
use App\Support\OperationCatalog;
|
||||
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;
|
||||
use Throwable;
|
||||
|
||||
class GeneratePermissionPostureFindingsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly array $permissionComparison,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
FindingGeneratorContract $generator,
|
||||
OperationRunService $operationRuns,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found: '.$this->tenantId);
|
||||
}
|
||||
|
||||
// FR-016: Skip if tenant has no active provider connection
|
||||
if ($tenant->providerConnections()->count() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK,
|
||||
inputs: [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'trigger' => 'health_check',
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $generator->generate($tenant, $this->permissionComparison, $operationRun);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'findings_created' => $result->findingsCreated,
|
||||
'findings_resolved' => $result->findingsResolved,
|
||||
'findings_reopened' => $result->findingsReopened,
|
||||
'findings_unchanged' => $result->findingsUnchanged,
|
||||
'errors_recorded' => $result->errorsRecorded,
|
||||
'posture_score' => $result->postureScore,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'permission_posture_check.failed',
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,8 +16,8 @@
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Verification\TenantPermissionCheckClusters;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@ -219,6 +219,14 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
// Dispatch posture finding generation when permission comparison is available
|
||||
if (($permissionComparison['overall_status'] ?? null) !== 'error') {
|
||||
GeneratePermissionPostureFindingsJob::dispatch(
|
||||
(int) $tenant->getKey(),
|
||||
$permissionComparison,
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->healthy) {
|
||||
$run = $runs->updateRun(
|
||||
$this->operationRun,
|
||||
|
||||
@ -20,6 +20,8 @@ class AlertRule extends Model
|
||||
|
||||
public const string EVENT_SLA_DUE = 'sla_due';
|
||||
|
||||
public const string EVENT_PERMISSION_MISSING = 'permission_missing';
|
||||
|
||||
public const string TENANT_SCOPE_ALL = 'all';
|
||||
|
||||
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
||||
|
||||
@ -16,6 +16,8 @@ class Finding extends Model
|
||||
|
||||
public const string FINDING_TYPE_DRIFT = 'drift';
|
||||
|
||||
public const string FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
||||
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_MEDIUM = 'medium';
|
||||
@ -28,11 +30,14 @@ class Finding extends Model
|
||||
|
||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||
|
||||
public const string STATUS_RESOLVED = 'resolved';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'acknowledged_at' => 'datetime',
|
||||
'evidence_jsonb' => 'array',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
@ -69,4 +74,27 @@ public function acknowledge(User $user): void
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resolve the finding.
|
||||
*/
|
||||
public function resolve(string $reason): void
|
||||
{
|
||||
$this->status = self::STATUS_RESOLVED;
|
||||
$this->resolved_at = now();
|
||||
$this->resolved_reason = $reason;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open a resolved finding.
|
||||
*/
|
||||
public function reopen(array $evidence): void
|
||||
{
|
||||
$this->status = self::STATUS_NEW;
|
||||
$this->resolved_at = null;
|
||||
$this->resolved_reason = null;
|
||||
$this->evidence_jsonb = $evidence;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Models/StoredReport.php
Normal file
45
app/Models/StoredReport.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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 StoredReport extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
public const string REPORT_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'tenant_id',
|
||||
'report_type',
|
||||
'payload',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,8 @@
|
||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
||||
use App\Services\PermissionPosture\FindingGeneratorContract;
|
||||
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
@ -52,6 +54,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||
|
||||
$this->app->singleton(GraphClientInterface::class, function ($app) {
|
||||
$config = $app['config']->get('graph');
|
||||
|
||||
|
||||
16
app/Services/PermissionPosture/FindingGeneratorContract.php
Normal file
16
app/Services/PermissionPosture/FindingGeneratorContract.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PermissionPosture;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
interface FindingGeneratorContract
|
||||
{
|
||||
/**
|
||||
* @param array{overall_status: string, permissions: array<int, array{key: string, type: string, features: array<int, string>, status: string, ...}>, ...} $permissionComparison
|
||||
*/
|
||||
public function generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult;
|
||||
}
|
||||
@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PermissionPosture;
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
|
||||
/**
|
||||
* Generates, auto-resolves, and re-opens permission posture findings
|
||||
* based on the output of TenantPermissionService::compare().
|
||||
*/
|
||||
final class PermissionPostureFindingGenerator implements FindingGeneratorContract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostureScoreCalculator $scoreCalculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{overall_status: string, permissions: array<int, array{key: string, type: string, features: array<int, string>, status: string, ...}>, ...} $permissionComparison
|
||||
*/
|
||||
public function generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult
|
||||
{
|
||||
$permissions = $permissionComparison['permissions'] ?? [];
|
||||
$permissions = is_array($permissions) ? $permissions : [];
|
||||
|
||||
$created = 0;
|
||||
$resolved = 0;
|
||||
$reopened = 0;
|
||||
$unchanged = 0;
|
||||
$errors = 0;
|
||||
$alertEvents = [];
|
||||
$processedPermissionKeys = [];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (! is_array($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $permission['key'] ?? '';
|
||||
$type = $permission['type'] ?? 'application';
|
||||
$status = $permission['status'] ?? 'granted';
|
||||
$features = is_array($permission['features'] ?? null) ? $permission['features'] : [];
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processedPermissionKeys[] = $key;
|
||||
|
||||
if ($status === 'error') {
|
||||
$this->handleErrorPermission($tenant, $key, $type, $features, $operationRun);
|
||||
$errors++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 'missing') {
|
||||
$result = $this->handleMissingPermission($tenant, $key, $type, $features, $operationRun);
|
||||
|
||||
if ($result === 'created') {
|
||||
$created++;
|
||||
$alertEvents[] = $this->buildAlertEvent($tenant, $key, $type, $features);
|
||||
} elseif ($result === 'reopened') {
|
||||
$reopened++;
|
||||
$alertEvents[] = $this->buildAlertEvent($tenant, $key, $type, $features);
|
||||
} else {
|
||||
$unchanged++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// status === 'granted'
|
||||
if ($this->resolveExistingFinding($tenant, $key, 'permission_granted')) {
|
||||
$resolved++;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9: Resolve stale findings for permissions removed from registry
|
||||
$resolved += $this->resolveStaleFindings($tenant, $processedPermissionKeys);
|
||||
|
||||
$postureScore = $this->scoreCalculator->calculate($permissionComparison);
|
||||
|
||||
$report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore);
|
||||
|
||||
return new PostureResult(
|
||||
findingsCreated: $created,
|
||||
findingsResolved: $resolved,
|
||||
findingsReopened: $reopened,
|
||||
findingsUnchanged: $unchanged,
|
||||
errorsRecorded: $errors,
|
||||
postureScore: $postureScore,
|
||||
storedReportId: (int) $report->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getAlertEvents(): array
|
||||
{
|
||||
return $this->alertEvents;
|
||||
}
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $alertEvents = [];
|
||||
|
||||
private function handleMissingPermission(
|
||||
Tenant $tenant,
|
||||
string $key,
|
||||
string $type,
|
||||
array $features,
|
||||
?OperationRun $operationRun,
|
||||
): string {
|
||||
$fingerprint = $this->fingerprint($tenant, $key);
|
||||
$evidence = $this->buildEvidence($key, $type, 'missing', $features);
|
||||
$severity = $this->deriveSeverity(count($features));
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('fingerprint', $fingerprint)
|
||||
->first();
|
||||
|
||||
if ($finding instanceof Finding) {
|
||||
if ($finding->status === Finding::STATUS_RESOLVED) {
|
||||
$finding->reopen($evidence);
|
||||
|
||||
return 'reopened';
|
||||
}
|
||||
|
||||
// Already open (new or acknowledged) — unchanged
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
Finding::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
||||
'source' => 'permission_check',
|
||||
'scope_key' => hash('sha256', 'permission_posture:'.$tenant->getKey()),
|
||||
'fingerprint' => $fingerprint,
|
||||
'subject_type' => 'permission',
|
||||
'subject_external_id' => $key,
|
||||
'severity' => $severity,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'current_operation_run_id' => $operationRun?->getKey(),
|
||||
]);
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
private function handleErrorPermission(
|
||||
Tenant $tenant,
|
||||
string $key,
|
||||
string $type,
|
||||
array $features,
|
||||
?OperationRun $operationRun,
|
||||
): void {
|
||||
$fingerprint = $this->errorFingerprint($tenant, $key);
|
||||
|
||||
$evidence = $this->buildEvidence($key, $type, 'error', $features);
|
||||
$evidence['check_error'] = true;
|
||||
|
||||
$existing = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('fingerprint', $fingerprint)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
$existing->update(['evidence_jsonb' => $evidence]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Finding::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
||||
'source' => 'permission_check',
|
||||
'scope_key' => hash('sha256', 'permission_posture_error:'.$tenant->getKey()),
|
||||
'fingerprint' => $fingerprint,
|
||||
'subject_type' => 'permission',
|
||||
'subject_external_id' => $key,
|
||||
'severity' => Finding::SEVERITY_LOW,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'current_operation_run_id' => $operationRun?->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveExistingFinding(Tenant $tenant, string $key, string $reason): bool
|
||||
{
|
||||
$fingerprint = $this->fingerprint($tenant, $key);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('fingerprint', $fingerprint)
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->first();
|
||||
|
||||
if (! $finding instanceof Finding) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$finding->resolve($reason);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve any open permission_posture findings whose permission_key is not
|
||||
* in the current comparison (handles registry removals).
|
||||
*/
|
||||
private function resolveStaleFindings(Tenant $tenant, array $processedPermissionKeys): int
|
||||
{
|
||||
$staleFindings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->get();
|
||||
|
||||
$resolved = 0;
|
||||
|
||||
foreach ($staleFindings as $finding) {
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
$permissionKey = $evidence['permission_key'] ?? null;
|
||||
|
||||
// Skip error findings (they have check_error=true in evidence)
|
||||
if (($evidence['check_error'] ?? false) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($permissionKey !== null && ! in_array($permissionKey, $processedPermissionKeys, true)) {
|
||||
$finding->resolve('permission_removed_from_registry');
|
||||
$resolved++;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $permissions
|
||||
*/
|
||||
private function createStoredReport(
|
||||
Tenant $tenant,
|
||||
array $permissionComparison,
|
||||
array $permissions,
|
||||
int $postureScore,
|
||||
): StoredReport {
|
||||
$grantedCount = 0;
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (is_array($permission) && ($permission['status'] ?? null) === 'granted') {
|
||||
$grantedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return StoredReport::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'posture_score' => $postureScore,
|
||||
'required_count' => count($permissions),
|
||||
'granted_count' => $grantedCount,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
'permissions' => array_map(
|
||||
static fn (array $p): array => [
|
||||
'key' => $p['key'] ?? '',
|
||||
'type' => $p['type'] ?? 'application',
|
||||
'status' => $p['status'] ?? 'unknown',
|
||||
'features' => is_array($p['features'] ?? null) ? $p['features'] : [],
|
||||
],
|
||||
array_filter($permissions, static fn (mixed $p): bool => is_array($p)),
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildAlertEvent(Tenant $tenant, string $key, string $type, array $features): array
|
||||
{
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => $this->deriveSeverity(count($features)),
|
||||
'fingerprint_key' => 'permission_missing:'.$tenant->getKey().':'.$key,
|
||||
'title' => 'Missing permission: '.$key,
|
||||
'body' => sprintf(
|
||||
'Tenant %s is missing %s. Blocked features: %s.',
|
||||
$tenant->name ?? (string) $tenant->getKey(),
|
||||
$key,
|
||||
implode(', ', $features) ?: 'none',
|
||||
),
|
||||
'metadata' => [
|
||||
'permission_key' => $key,
|
||||
'permission_type' => $type,
|
||||
'blocked_features' => $features,
|
||||
],
|
||||
];
|
||||
|
||||
$this->alertEvents[] = $event;
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEvidence(string $key, string $type, string $actualStatus, array $features): array
|
||||
{
|
||||
return [
|
||||
'permission_key' => $key,
|
||||
'permission_type' => $type,
|
||||
'expected_status' => 'granted',
|
||||
'actual_status' => $actualStatus,
|
||||
'blocked_features' => $features,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function deriveSeverity(int $featureCount): string
|
||||
{
|
||||
return match (true) {
|
||||
$featureCount >= 3 => Finding::SEVERITY_CRITICAL,
|
||||
$featureCount === 2 => Finding::SEVERITY_HIGH,
|
||||
$featureCount === 1 => Finding::SEVERITY_MEDIUM,
|
||||
default => Finding::SEVERITY_LOW,
|
||||
};
|
||||
}
|
||||
|
||||
private function fingerprint(Tenant $tenant, string $permissionKey): string
|
||||
{
|
||||
return substr(hash('sha256', 'permission_posture:'.$tenant->getKey().':'.$permissionKey), 0, 64);
|
||||
}
|
||||
|
||||
private function errorFingerprint(Tenant $tenant, string $permissionKey): string
|
||||
{
|
||||
return substr(hash('sha256', 'permission_posture:'.$tenant->getKey().':'.$permissionKey.':error'), 0, 64);
|
||||
}
|
||||
}
|
||||
21
app/Services/PermissionPosture/PostureResult.php
Normal file
21
app/Services/PermissionPosture/PostureResult.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PermissionPosture;
|
||||
|
||||
/**
|
||||
* Value object summarizing the outcome of a posture finding generation run.
|
||||
*/
|
||||
final readonly class PostureResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $findingsCreated,
|
||||
public int $findingsResolved,
|
||||
public int $findingsReopened,
|
||||
public int $findingsUnchanged,
|
||||
public int $errorsRecorded,
|
||||
public int $postureScore,
|
||||
public int $storedReportId,
|
||||
) {}
|
||||
}
|
||||
41
app/Services/PermissionPosture/PostureScoreCalculator.php
Normal file
41
app/Services/PermissionPosture/PostureScoreCalculator.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PermissionPosture;
|
||||
|
||||
/**
|
||||
* Calculates a normalized posture score (0–100) from a permission comparison result.
|
||||
*
|
||||
* Pure function: no side effects, no DB access.
|
||||
*/
|
||||
final class PostureScoreCalculator
|
||||
{
|
||||
/**
|
||||
* @param array{permissions: array<int, array{status: string, ...}>, ...} $permissionComparison
|
||||
*/
|
||||
public function calculate(array $permissionComparison): int
|
||||
{
|
||||
$permissions = $permissionComparison['permissions'] ?? [];
|
||||
|
||||
if (! is_array($permissions)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$requiredCount = count($permissions);
|
||||
|
||||
if ($requiredCount === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$grantedCount = 0;
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (is_array($permission) && ($permission['status'] ?? null) === 'granted') {
|
||||
$grantedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) round($grantedCount / $requiredCount * 100);
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
|
||||
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -32,4 +32,5 @@ enum BadgeDomain: string
|
||||
case AlertDeliveryStatus = 'alert_delivery_status';
|
||||
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ public function spec(mixed $value): BadgeSpec
|
||||
return match ($state) {
|
||||
Finding::STATUS_NEW => new BadgeSpec('New', 'warning', 'heroicon-m-clock'),
|
||||
Finding::STATUS_ACKNOWLEDGED => new BadgeSpec('Acknowledged', 'gray', 'heroicon-m-check-circle'),
|
||||
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'success', 'heroicon-o-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
24
app/Support/Badges/Domains/FindingTypeBadge.php
Normal file
24
app/Support/Badges/Domains/FindingTypeBadge.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class FindingTypeBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
Finding::FINDING_TYPE_DRIFT => new BadgeSpec('Drift', 'info', 'heroicon-m-arrow-path'),
|
||||
Finding::FINDING_TYPE_PERMISSION_POSTURE => new BadgeSpec('Permission posture', 'warning', 'heroicon-m-shield-exclamation'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
|
||||
final class OperationCatalog
|
||||
{
|
||||
public const string TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@ -47,6 +49,7 @@ public static function labels(): array
|
||||
'alerts.deliver' => 'Alerts delivery',
|
||||
'baseline_capture' => 'Baseline capture',
|
||||
'baseline_compare' => 'Baseline compare',
|
||||
'permission_posture_check' => 'Permission posture check',
|
||||
];
|
||||
}
|
||||
|
||||
@ -76,6 +79,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'alerts.evaluate', 'alerts.deliver' => 120,
|
||||
'baseline_capture' => 120,
|
||||
'baseline_compare' => 120,
|
||||
'permission_posture_check' => 30,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,6 +27,12 @@ public static function all(): array
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
'findings_created',
|
||||
'findings_resolved',
|
||||
'findings_reopened',
|
||||
'findings_unchanged',
|
||||
'errors_recorded',
|
||||
'posture_score',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -349,6 +349,10 @@
|
||||
'http_timeout_seconds' => (int) env('TENANTPILOT_ALERTS_HTTP_TIMEOUT_SECONDS', 10),
|
||||
],
|
||||
|
||||
'stored_reports' => [
|
||||
'retention_days' => (int) env('TENANTPILOT_STORED_REPORTS_RETENTION_DAYS', 90),
|
||||
],
|
||||
|
||||
'display' => [
|
||||
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
|
||||
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
||||
|
||||
@ -34,4 +34,38 @@ public function definition(): array
|
||||
'evidence_jsonb' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* State for permission posture findings.
|
||||
*/
|
||||
public function permissionPosture(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
||||
'source' => 'permission_check',
|
||||
'subject_type' => 'permission',
|
||||
'subject_external_id' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'evidence_jsonb' => [
|
||||
'permission_key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||
'permission_type' => 'application',
|
||||
'expected_status' => 'granted',
|
||||
'actual_status' => 'missing',
|
||||
'blocked_features' => ['policy-sync', 'backup'],
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* State for resolved findings.
|
||||
*/
|
||||
public function resolved(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => 'permission_granted',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
48
database/factories/StoredReportFactory.php
Normal file
48
database/factories/StoredReportFactory.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<StoredReport>
|
||||
*/
|
||||
class StoredReportFactory extends Factory
|
||||
{
|
||||
protected $model = StoredReport::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'posture_score' => 86,
|
||||
'required_count' => 14,
|
||||
'granted_count' => 12,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
'permissions' => [
|
||||
[
|
||||
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||
'type' => 'application',
|
||||
'status' => 'granted',
|
||||
'features' => ['policy-sync', 'backup', 'restore'],
|
||||
],
|
||||
[
|
||||
'key' => 'DeviceManagementApps.ReadWrite.All',
|
||||
'type' => 'application',
|
||||
'status' => 'missing',
|
||||
'features' => ['policy-sync', 'backup'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stored_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('report_type');
|
||||
$table->jsonb('payload');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'tenant_id', 'report_type']);
|
||||
$table->index(['tenant_id', 'created_at']);
|
||||
});
|
||||
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
DB::statement('CREATE INDEX stored_reports_payload_gin ON stored_reports USING GIN (payload)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('stored_reports');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('findings', function (Blueprint $table) {
|
||||
$table->timestampTz('resolved_at')->nullable()->after('acknowledged_by_user_id');
|
||||
$table->string('resolved_reason', 255)->nullable()->after('resolved_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('findings', function (Blueprint $table) {
|
||||
$table->dropColumn(['resolved_at', 'resolved_reason']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -457,7 +457,13 @@ class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Technical details">
|
||||
<details data-testid="technical-details" class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<details
|
||||
data-testid="technical-details"
|
||||
x-data="{ open: false }"
|
||||
x-bind:open="open"
|
||||
x-on:toggle="open = $el.open"
|
||||
class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<summary class="cursor-pointer list-none text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Expand technical details
|
||||
</summary>
|
||||
|
||||
@ -26,3 +26,8 @@
|
||||
->everyThirtyMinutes()
|
||||
->name(ReconcileAdapterRunsJob::class)
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('stored-reports:prune')
|
||||
->daily()
|
||||
->name('stored-reports:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
@ -184,7 +184,7 @@ ### Functional Requirements
|
||||
### Canonical allowed summary keys (single source of truth)
|
||||
|
||||
The following keys are the ONLY allowed summary keys for Ops-UX rendering:
|
||||
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants`
|
||||
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants, high, medium, low, findings_created, findings_resolved, findings_reopened, findings_unchanged, errors_recorded, posture_score`
|
||||
|
||||
All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).
|
||||
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
# Specification Quality Checklist: Provider Permission Posture
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-26
|
||||
**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
|
||||
|
||||
## Constitution Alignment
|
||||
|
||||
- [x] Constitution alignment (required) -- contract registry, safety gates, tenant isolation, run observability, tests
|
||||
- [x] Constitution alignment (RBAC-UX) -- authorization planes, 404/403 semantics, capability registry
|
||||
- [x] Constitution alignment (OPS-EX-AUTH-001) -- not applicable, documented
|
||||
- [x] Constitution alignment (BADGE-001) -- new badge values documented, centralized map extended
|
||||
- [x] Constitution alignment (Filament Action Surfaces) -- exemption documented (no new surfaces)
|
||||
- [x] Constitution alignment (UX-001) -- exemption documented (no new screens)
|
||||
|
||||
## Notes
|
||||
|
||||
- All checklist items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- No [NEEDS CLARIFICATION] markers; all decisions were made with informed defaults based on codebase research (Q1-Q6) and architectural decision validation from prior conversation.
|
||||
- Key informed defaults documented in Assumptions section: TenantPermissionService as data source, DriftFindingGenerator pattern for idempotent upsert, Alerts v1 generic framework for new event types.
|
||||
@ -0,0 +1,149 @@
|
||||
# Internal Contracts: Provider Permission Posture (Spec 104)
|
||||
|
||||
**Date**: 2026-02-21 | **Branch**: `104-provider-permission-posture`
|
||||
|
||||
This feature does NOT expose external HTTP APIs. All contracts are internal service interfaces.
|
||||
|
||||
---
|
||||
|
||||
## Contract 1: PermissionPostureFindingGenerator
|
||||
|
||||
**Service**: `App\Services\PermissionPosture\PermissionPostureFindingGenerator`
|
||||
|
||||
### `generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult`
|
||||
|
||||
**Input**:
|
||||
- `$tenant`: The tenant being evaluated
|
||||
- `$permissionComparison`: Output of `TenantPermissionService::compare()` — see schema below
|
||||
- `$operationRun`: Optional OperationRun for tracking (created by caller if not provided)
|
||||
|
||||
**Input schema** (`$permissionComparison`):
|
||||
```php
|
||||
array{
|
||||
overall_status: 'granted'|'missing'|'error',
|
||||
permissions: array<int, array{
|
||||
key: string,
|
||||
type: string,
|
||||
description: ?string,
|
||||
features: array<int, string>,
|
||||
status: 'granted'|'missing'|'error',
|
||||
details: ?array,
|
||||
}>,
|
||||
last_refreshed_at: ?string,
|
||||
}
|
||||
```
|
||||
|
||||
**Output** (`PostureResult` value object):
|
||||
```php
|
||||
PostureResult {
|
||||
int $findingsCreated,
|
||||
int $findingsResolved,
|
||||
int $findingsReopened,
|
||||
int $findingsUnchanged,
|
||||
int $errorsRecorded,
|
||||
int $postureScore,
|
||||
int $storedReportId,
|
||||
}
|
||||
```
|
||||
|
||||
**Side effects**:
|
||||
1. Creates/updates/resolves `Finding` records (type: `permission_posture`)
|
||||
2. Creates a `StoredReport` (type: `permission_posture`)
|
||||
3. Dispatches alert events for new/reopened findings via the alert pipeline
|
||||
|
||||
**Idempotency**: Fingerprint-based. Same input produces same findings (no duplicates).
|
||||
|
||||
---
|
||||
|
||||
## Contract 2: PostureScoreCalculator
|
||||
|
||||
**Service**: `App\Services\PermissionPosture\PostureScoreCalculator`
|
||||
|
||||
### `calculate(array $permissionComparison): int`
|
||||
|
||||
**Input**: `$permissionComparison` — same schema as Contract 1 input.
|
||||
|
||||
**Output**: Integer 0–100 representing `round(granted_count / required_count * 100)`. Returns 100 if `required_count` is 0.
|
||||
|
||||
**Pure function**: No side effects, no DB access.
|
||||
|
||||
---
|
||||
|
||||
## Contract 3: GeneratePermissionPostureFindingsJob
|
||||
|
||||
**Job**: `App\Jobs\GeneratePermissionPostureFindingsJob`
|
||||
|
||||
### Constructor
|
||||
|
||||
```php
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly array $permissionComparison,
|
||||
)
|
||||
```
|
||||
|
||||
### `handle(PermissionPostureFindingGenerator $generator): void`
|
||||
|
||||
**Flow**:
|
||||
1. Load Tenant (fail if not found)
|
||||
2. Skip if tenant has no active provider connection (FR-016)
|
||||
3. Create OperationRun (`type = permission_posture_check`)
|
||||
4. Call `$generator->generate($tenant, $permissionComparison, $run)`
|
||||
5. Record summary counts on OperationRun
|
||||
6. Complete OperationRun
|
||||
|
||||
**Queue**: `default` (same queue as health checks)
|
||||
|
||||
**Dispatch point**: `ProviderConnectionHealthCheckJob::handle()` after `TenantPermissionService::compare()` returns, when `$permissionComparison['overall_status'] !== 'error'`.
|
||||
|
||||
---
|
||||
|
||||
## Contract 4: Alert Event Schema (EVENT_PERMISSION_MISSING)
|
||||
|
||||
**Event array** (passed to `AlertDispatchService::dispatchEvent()`):
|
||||
|
||||
```php
|
||||
[
|
||||
'event_type' => 'permission_missing',
|
||||
'tenant_id' => int,
|
||||
'severity' => 'low'|'medium'|'high'|'critical',
|
||||
'fingerprint_key' => "permission_missing:{tenant_id}:{permission_key}",
|
||||
'title' => "Missing permission: {permission_key}",
|
||||
'body' => "Tenant {tenant_name} is missing {permission_key}. Blocked features: {feature_list}.",
|
||||
'metadata' => [
|
||||
'permission_key' => string,
|
||||
'permission_type' => string,
|
||||
'blocked_features' => array<string>,
|
||||
'posture_score' => int,
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
**Produced by**: `PermissionPostureFindingGenerator` for each newly created or re-opened finding.
|
||||
**Not produced for**: Auto-resolved findings, existing unchanged findings, error findings.
|
||||
|
||||
---
|
||||
|
||||
## Contract 5: EvaluateAlertsJob Extension
|
||||
|
||||
### New method: `permissionMissingEvents(): array`
|
||||
|
||||
**Query**: `Finding` where:
|
||||
- `finding_type = 'permission_posture'`
|
||||
- `status IN ('new')` (only new/re-opened, not acknowledged or resolved)
|
||||
- `severity` >= minimum severity threshold from alert rule
|
||||
- `created_at` or `updated_at` >= window start
|
||||
|
||||
**Output**: Array of event arrays matching Contract 4 schema.
|
||||
|
||||
---
|
||||
|
||||
## Contract 6: StoredReport Retention
|
||||
|
||||
### `PruneStoredReportsCommand` (Artisan command)
|
||||
|
||||
**Signature**: `stored-reports:prune {--days=90}`
|
||||
|
||||
**Behavior**: Deletes `stored_reports` where `created_at < now() - days`. Outputs count of deleted records.
|
||||
|
||||
**Schedule**: Daily via `routes/console.php`.
|
||||
245
specs/104-provider-permission-posture/data-model.md
Normal file
245
specs/104-provider-permission-posture/data-model.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Data Model: Provider Permission Posture (Spec 104)
|
||||
|
||||
**Date**: 2026-02-21 | **Branch**: `104-provider-permission-posture`
|
||||
|
||||
## New Table: `stored_reports`
|
||||
|
||||
| Column | Type | Nullable | Default | Notes |
|
||||
|--------|------|----------|---------|-------|
|
||||
| `id` | bigint (PK) | NO | auto | |
|
||||
| `workspace_id` | bigint (FK → workspaces) | NO | — | SCOPE-001: tenant-owned table |
|
||||
| `tenant_id` | bigint (FK → tenants) | NO | — | SCOPE-001: tenant-owned table |
|
||||
| `report_type` | string | NO | — | Polymorphic type discriminator (e.g., `permission_posture`) |
|
||||
| `payload` | jsonb | NO | — | Full report data, structure depends on `report_type` |
|
||||
| `created_at` | timestampTz | NO | — | |
|
||||
| `updated_at` | timestampTz | NO | — | |
|
||||
|
||||
**Indexes**:
|
||||
- `[workspace_id, tenant_id, report_type]` — composite for filtered queries
|
||||
- `[tenant_id, created_at]` — for temporal ordering per tenant
|
||||
- GIN on `payload` — for future JSONB querying (e.g., filter by posture_score)
|
||||
|
||||
**Relationships**:
|
||||
- `workspace()` → BelongsTo Workspace
|
||||
- `tenant()` → BelongsTo Tenant
|
||||
|
||||
**Traits**: `DerivesWorkspaceIdFromTenant`, `HasFactory`
|
||||
|
||||
---
|
||||
|
||||
## Modified Table: `findings` (migration)
|
||||
|
||||
| Column | Type | Nullable | Default | Notes |
|
||||
|--------|------|----------|---------|-------|
|
||||
| `resolved_at` | timestampTz | YES | null | When the finding was resolved |
|
||||
| `resolved_reason` | string(255) | YES | null | Why resolved (e.g., `permission_granted`, `registry_removed`) |
|
||||
|
||||
**No new indexes** — queries filter by `status` (already indexed as `[tenant_id, status]`).
|
||||
|
||||
---
|
||||
|
||||
## Modified Model: `Finding`
|
||||
|
||||
### New Constants
|
||||
|
||||
```php
|
||||
const FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
||||
const STATUS_RESOLVED = 'resolved';
|
||||
```
|
||||
|
||||
### New Casts
|
||||
|
||||
```php
|
||||
'resolved_at' => 'datetime',
|
||||
```
|
||||
|
||||
### New Method
|
||||
|
||||
```php
|
||||
/**
|
||||
* Auto-resolve the finding.
|
||||
*/
|
||||
public function resolve(string $reason): void
|
||||
{
|
||||
$this->status = self::STATUS_RESOLVED;
|
||||
$this->resolved_at = now();
|
||||
$this->resolved_reason = $reason;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open a resolved finding.
|
||||
*/
|
||||
public function reopen(array $evidence): void
|
||||
{
|
||||
$this->status = self::STATUS_NEW;
|
||||
$this->resolved_at = null;
|
||||
$this->resolved_reason = null;
|
||||
$this->evidence_jsonb = $evidence;
|
||||
$this->save();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modified Model: `AlertRule`
|
||||
|
||||
### New Constant
|
||||
|
||||
```php
|
||||
const EVENT_PERMISSION_MISSING = 'permission_missing';
|
||||
```
|
||||
|
||||
No schema change — `event_type` is already a string column.
|
||||
|
||||
---
|
||||
|
||||
## Modified Model: `OperationCatalog`
|
||||
|
||||
### New Constant
|
||||
|
||||
```php
|
||||
const TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Model: `StoredReport`
|
||||
|
||||
```php
|
||||
class StoredReport extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
const REPORT_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'tenant_id',
|
||||
'report_type',
|
||||
'payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo { ... }
|
||||
public function tenant(): BelongsTo { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Factory: `StoredReportFactory`
|
||||
|
||||
```php
|
||||
// Default state
|
||||
[
|
||||
'workspace_id' => via DerivesWorkspaceIdFromTenant,
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [...default posture payload...],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Posture Report Payload Schema (`report_type=permission_posture`)
|
||||
|
||||
```json
|
||||
{
|
||||
"posture_score": 86,
|
||||
"required_count": 14,
|
||||
"granted_count": 12,
|
||||
"checked_at": "2026-02-21T14:30:00Z",
|
||||
"permissions": [
|
||||
{
|
||||
"key": "DeviceManagementConfiguration.ReadWrite.All",
|
||||
"type": "application",
|
||||
"status": "granted",
|
||||
"features": ["policy-sync", "backup", "restore"]
|
||||
},
|
||||
{
|
||||
"key": "DeviceManagementApps.ReadWrite.All",
|
||||
"type": "application",
|
||||
"status": "missing",
|
||||
"features": ["policy-sync", "backup"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Finding Evidence Schema (`finding_type=permission_posture`)
|
||||
|
||||
```json
|
||||
{
|
||||
"permission_key": "DeviceManagementApps.ReadWrite.All",
|
||||
"permission_type": "application",
|
||||
"expected_status": "granted",
|
||||
"actual_status": "missing",
|
||||
"blocked_features": ["policy-sync", "backup"],
|
||||
"checked_at": "2026-02-21T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fingerprint Formula
|
||||
|
||||
```
|
||||
sha256("permission_posture:{tenant_id}:{permission_key}")
|
||||
```
|
||||
|
||||
Truncated to 64 chars (matching `fingerprint` column size).
|
||||
|
||||
---
|
||||
|
||||
## State Machine: Finding Lifecycle (permission_posture)
|
||||
|
||||
```
|
||||
permission missing
|
||||
↓
|
||||
┌────────── [new] ──────────┐
|
||||
│ │ │
|
||||
│ user acks │ permission granted
|
||||
│ ↓ ↓
|
||||
│ [acknowledged] → [resolved]
|
||||
│ │
|
||||
│ │ permission revoked again
|
||||
│ ↓
|
||||
└─────────── [new] ←────────┘
|
||||
(re-opened)
|
||||
```
|
||||
|
||||
- `new` → `acknowledged`: Manual user action (existing `acknowledge()` method)
|
||||
- `new` → `resolved`: Auto-resolve when permission is granted
|
||||
- `acknowledged` → `resolved`: Auto-resolve when permission is granted (acknowledgement metadata preserved)
|
||||
- `resolved` → `new`: Re-open when permission becomes missing again (cleared resolved fields)
|
||||
|
||||
---
|
||||
|
||||
## Entity Relationship Summary
|
||||
|
||||
```
|
||||
Workspace ──┬── StoredReport (new, 1:N)
|
||||
│ └── Tenant (FK)
|
||||
│
|
||||
├── AlertRule (existing, extended with EVENT_PERMISSION_MISSING)
|
||||
│ └── AlertDestination (M:N pivot)
|
||||
│
|
||||
└── Tenant ──┬── Finding (existing, extended)
|
||||
│ ├── finding_type: permission_posture (new)
|
||||
│ ├── source: permission_check (new usage)
|
||||
│ └── status: resolved (new global status)
|
||||
│
|
||||
├── TenantPermission (existing, read-only input)
|
||||
│
|
||||
└── OperationRun (existing, type: permission_posture_check)
|
||||
```
|
||||
196
specs/104-provider-permission-posture/plan.md
Normal file
196
specs/104-provider-permission-posture/plan.md
Normal file
@ -0,0 +1,196 @@
|
||||
# Implementation Plan: Provider Permission Posture
|
||||
|
||||
**Branch**: `104-provider-permission-posture` | **Date**: 2026-02-21 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/104-provider-permission-posture/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a Provider Permission Posture system that automatically generates findings for missing Intune permissions, persists posture snapshots as stored reports, integrates with the existing alerts pipeline, and extends the Finding model with a global `resolved` status. The posture generator is dispatched event-driven after each `TenantPermissionService::compare()` call, uses fingerprint-based idempotent upsert (matching the DriftFindingGenerator pattern), and derives severity from the feature-impact count in `config/intune_permissions.php`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4
|
||||
**Storage**: PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`)
|
||||
**Target Platform**: Linux server (Docker/Sail locally, Dokploy for staging/production)
|
||||
**Project Type**: Web application (Laravel monolith)
|
||||
**Performance Goals**: Posture generation completes in <5s per tenant (14 permissions); alert delivery within 2 min of finding creation
|
||||
**Constraints**: No external API calls from posture generator (reads TenantPermissionService output); all work is queued
|
||||
**Scale/Scope**: Up to ~50 tenants per workspace, 14 required permissions per tenant, ~700 findings max in steady state
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- [x] **Inventory-first**: Posture findings represent "last observed" permission state from `TenantPermissionService::compare()`. No snapshots/backups involved -- this is analysis on existing inventory data.
|
||||
- [x] **Read/write separation**: Posture generation is read-only analysis (no Intune writes). StoredReports are immutable snapshots. No preview/dry-run needed (no destructive operations).
|
||||
- [x] **Graph contract path**: No new Graph calls. Reads output of existing `TenantPermissionService::compare()` which already goes through `GraphClientInterface`.
|
||||
- [x] **Deterministic capabilities**: Severity is derived deterministically from `config/intune_permissions.php` feature count (FR-005). Testable via snapshot/golden tests. No RBAC capabilities added -- uses existing `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE`.
|
||||
- [x] **RBAC-UX**: No new Filament pages/resources. Posture findings appear in existing Findings resource (tenant-scoped). Non-member -> 404. Member without FINDINGS_VIEW -> 403. No new capabilities needed.
|
||||
- [x] **Workspace isolation**: StoredReports include `workspace_id` (NOT NULL). Findings derive workspace via `DerivesWorkspaceIdFromTenant`. Non-member workspace access -> 404.
|
||||
- [x] **Tenant isolation**: All findings, stored reports, and operation runs are scoped via `tenant_id` (NOT NULL). Cross-tenant access impossible at query level.
|
||||
- [x] **Run observability**: Posture generation tracked as `OperationRun` with `type=permission_posture_check`. Start/complete/outcome/counts recorded.
|
||||
- [x] **Automation**: Job dispatched per-tenant (no batch lock needed). Fingerprint-based upsert handles concurrency. Queue handles backpressure.
|
||||
- [x] **Data minimization**: Evidence contains only permission metadata (key, type, features, status). No secrets/tokens/PII.
|
||||
- [x] **Badge semantics (BADGE-001)**: `finding_type=permission_posture` and `status=resolved` added to centralized badge mappers. Tests included.
|
||||
- [x] **Filament UI Action Surface Contract**: NO new Resources/Pages/RelationManagers. Exemption documented in spec.
|
||||
- [x] **UX-001 (Layout & IA)**: No new screens. Exemption documented in spec.
|
||||
- [x] **SCOPE-001 ownership**: StoredReports are tenant-owned (`workspace_id` + `tenant_id` NOT NULL). Findings are tenant-owned (existing). AlertRule is workspace-owned (existing, no change).
|
||||
|
||||
**Post-Phase-1 re-check**: All items pass. No violations found.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/104-provider-permission-posture/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0: research decisions
|
||||
├── data-model.md # Phase 1: entity/table design
|
||||
├── quickstart.md # Phase 1: implementation guide
|
||||
├── contracts/
|
||||
│ └── internal-services.md # Phase 1: service contracts
|
||||
├── checklists/
|
||||
│ └── requirements.md # Quality checklist
|
||||
└── tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Models/
|
||||
│ ├── Finding.php # MODIFIED: +STATUS_RESOLVED, +FINDING_TYPE_PERMISSION_POSTURE, +resolve(), +reopen()
|
||||
│ ├── StoredReport.php # NEW: generic stored report model
|
||||
│ └── AlertRule.php # MODIFIED: +EVENT_PERMISSION_MISSING constant
|
||||
├── Services/
|
||||
│ └── PermissionPosture/
|
||||
│ ├── PostureResult.php # NEW: value object (findingsCreated, resolved, reopened, etc.)
|
||||
│ ├── PostureScoreCalculator.php # NEW: pure function, score = round(granted/required * 100)
|
||||
│ └── PermissionPostureFindingGenerator.php # NEW: core generator (findings + report + alert events)
|
||||
├── Jobs/
|
||||
│ ├── GeneratePermissionPostureFindingsJob.php # NEW: queued per-tenant job
|
||||
│ ├── ProviderConnectionHealthCheckJob.php # MODIFIED: dispatch posture job after compare()
|
||||
│ └── Alerts/
|
||||
│ └── EvaluateAlertsJob.php # MODIFIED: +permissionMissingEvents() method
|
||||
├── Support/
|
||||
│ ├── OperationCatalog.php # MODIFIED: +TYPE_PERMISSION_POSTURE_CHECK
|
||||
│ └── Badges/Domains/
|
||||
│ ├── FindingStatusBadge.php # MODIFIED: +resolved badge mapping
|
||||
│ └── FindingTypeBadge.php # NEW: finding type badge map (permission_posture, drift)
|
||||
├── Filament/Resources/
|
||||
│ └── AlertRuleResource.php # MODIFIED: +EVENT_PERMISSION_MISSING in eventTypeOptions()
|
||||
└── Console/Commands/
|
||||
└── PruneStoredReportsCommand.php # NEW: retention cleanup
|
||||
|
||||
database/
|
||||
├── migrations/
|
||||
│ ├── XXXX_create_stored_reports_table.php # NEW
|
||||
│ └── XXXX_add_resolved_to_findings_table.php # NEW
|
||||
└── factories/
|
||||
├── StoredReportFactory.php # NEW
|
||||
└── FindingFactory.php # MODIFIED: +permissionPosture(), +resolved() states
|
||||
|
||||
tests/Feature/
|
||||
├── PermissionPosture/
|
||||
│ ├── PostureScoreCalculatorTest.php # NEW
|
||||
│ ├── PermissionPostureFindingGeneratorTest.php # NEW
|
||||
│ ├── GeneratePermissionPostureFindingsJobTest.php # NEW
|
||||
│ ├── StoredReportModelTest.php # NEW
|
||||
│ ├── PruneStoredReportsCommandTest.php # NEW: retention pruning tests
|
||||
│ └── PermissionPostureIntegrationTest.php # NEW: end-to-end flow test
|
||||
├── Alerts/
|
||||
│ └── PermissionMissingAlertTest.php # NEW
|
||||
├── Support/Badges/
|
||||
│ └── FindingBadgeTest.php # NEW: resolved + permission_posture badge tests
|
||||
└── Models/
|
||||
└── FindingResolvedTest.php # NEW: resolved lifecycle tests
|
||||
|
||||
routes/
|
||||
└── console.php # MODIFIED: schedule prune command
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith structure. New services go under `app/Services/PermissionPosture/`. Tests mirror the service structure under `tests/Feature/PermissionPosture/`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No constitution violations. No complexity justifications needed.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A -- Foundation (StoredReports + Finding Model Extensions)
|
||||
|
||||
**Goal**: Establish the data layer that all other phases depend on.
|
||||
|
||||
**Deliverables**:
|
||||
1. Migration: `create_stored_reports_table` (see data-model.md)
|
||||
2. Migration: `add_resolved_to_findings_table` (add `resolved_at`, `resolved_reason` columns)
|
||||
3. Model: `StoredReport` with factory
|
||||
4. Finding model: Add `STATUS_RESOLVED`, `FINDING_TYPE_PERMISSION_POSTURE`, `resolve()`, `reopen()` methods, `resolved_at` cast
|
||||
5. FindingFactory: Add `permissionPosture()` and `resolved()` states
|
||||
6. Badge: Add `resolved` mapping to `FindingStatusBadge`
|
||||
7. OperationCatalog: Add `TYPE_PERMISSION_POSTURE_CHECK`
|
||||
8. AlertRule: Add `EVENT_PERMISSION_MISSING` constant (pulled from Phase D into Phase A for early availability; tasks.md T008)
|
||||
9. Badge: Create `FindingTypeBadge` with `drift` and `permission_posture` mappings per BADGE-001
|
||||
10. Tests: StoredReport model CRUD, Finding resolve/reopen lifecycle, badge rendering
|
||||
|
||||
**Dependencies**: None (foundation layer).
|
||||
|
||||
### Phase B -- Core Generator
|
||||
|
||||
**Goal**: Implement the posture finding generator that produces findings, stored reports, and computes posture scores.
|
||||
|
||||
**Deliverables**:
|
||||
1. Service: `PostureScoreCalculator` (pure function)
|
||||
2. Service: `PermissionPostureFindingGenerator` (findings + report creation + alert event production)
|
||||
3. Tests: Score calculation (edge cases: 0 permissions, all granted, all missing), finding generation (create, auto-resolve, re-open, idempotency, error handling, severity derivation)
|
||||
|
||||
**Dependencies**: Phase A (models, migrations, constants).
|
||||
|
||||
### Phase C -- Job + Health Check Hook
|
||||
|
||||
**Goal**: Wire the generator into the existing health check pipeline as a queued job.
|
||||
|
||||
**Deliverables**:
|
||||
1. Job: `GeneratePermissionPostureFindingsJob` (load tenant, create OperationRun, call generator, record outcome)
|
||||
2. Hook: Modify `ProviderConnectionHealthCheckJob` to dispatch posture job after `compare()` returns (when `overall_status !== 'error'`)
|
||||
3. Tests: Job dispatch integration, skip-if-no-connection, OperationRun tracking, error handling
|
||||
|
||||
**Dependencies**: Phase B (generator service).
|
||||
|
||||
### Phase D -- Alerts Integration
|
||||
|
||||
**Goal**: Connect posture findings to the existing alert pipeline.
|
||||
|
||||
**Deliverables**:
|
||||
1. AlertRule constant: Already delivered in Phase A (T008) — no work here
|
||||
2. EvaluateAlertsJob: Add `permissionMissingEvents()` method
|
||||
3. UI: Add `EVENT_PERMISSION_MISSING` to alert rule event type dropdown (existing form, just a new option)
|
||||
4. Tests: Alert event production, severity filtering, cooldown/dedupe, alert rule matching
|
||||
|
||||
**Dependencies**: Phase B (generator produces alert events), Phase A (AlertRule constant).
|
||||
|
||||
### Phase E -- Retention + Polish
|
||||
|
||||
**Goal**: Implement stored report cleanup and finalize integration.
|
||||
|
||||
**Deliverables**:
|
||||
1. Artisan command: `PruneStoredReportsCommand` (`stored-reports:prune --days=90`)
|
||||
2. Schedule: Register in `routes/console.php` (daily)
|
||||
3. FindingFactory: Ensure `source` field is populated in `permissionPosture()` state
|
||||
4. Integration test: End-to-end flow (health check -> compare -> posture job -> findings + report + alert event)
|
||||
5. Tests: Retention pruning, schedule registration
|
||||
|
||||
**Dependencies**: Phases A-D complete.
|
||||
|
||||
## Filament v5 Agent Output Contract
|
||||
|
||||
1. **Livewire v4.0+ compliance**: Yes -- no new Livewire components introduced. Existing Findings resource already complies.
|
||||
2. **Provider registration**: No new providers. Existing `AdminPanelProvider` in `bootstrap/providers.php` unchanged.
|
||||
3. **Global search**: No new globally searchable resources. Existing Finding resource global search behavior unchanged.
|
||||
4. **Destructive actions**: None introduced. Posture findings are system-generated, not user-deletable.
|
||||
5. **Asset strategy**: No new frontend assets. Badge mapping is PHP-only. No `filament:assets` changes needed.
|
||||
6. **Testing plan**: Pest feature tests for generator (create/resolve/reopen/idempotency), score calculator, job dispatch, alert integration, badge rendering, retention command. All mounted as service/job tests, not Livewire component tests (no new components).
|
||||
89
specs/104-provider-permission-posture/quickstart.md
Normal file
89
specs/104-provider-permission-posture/quickstart.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Quickstart: Provider Permission Posture (Spec 104)
|
||||
|
||||
**Branch**: `104-provider-permission-posture`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Laravel Sail running (`vendor/bin/sail up -d`)
|
||||
- Database migrated (`vendor/bin/sail artisan migrate`)
|
||||
- At least one tenant with a provider connection configured
|
||||
|
||||
## New Files Created (Implementation Order)
|
||||
|
||||
### Phase A — Foundation (StoredReports + Finding model extensions)
|
||||
|
||||
```
|
||||
database/migrations/XXXX_create_stored_reports_table.php
|
||||
database/migrations/XXXX_add_resolved_to_findings_table.php
|
||||
app/Models/StoredReport.php
|
||||
database/factories/StoredReportFactory.php
|
||||
```
|
||||
|
||||
### Phase B — Core Generator
|
||||
|
||||
```
|
||||
app/Services/PermissionPosture/PostureScoreCalculator.php
|
||||
app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
|
||||
```
|
||||
|
||||
### Phase C — Job + Health Check Hook
|
||||
|
||||
```
|
||||
app/Jobs/GeneratePermissionPostureFindingsJob.php
|
||||
```
|
||||
|
||||
Modified: `app/Jobs/ProviderConnectionHealthCheckJob.php` (dispatch hook)
|
||||
|
||||
### Phase D — Alerts Integration
|
||||
|
||||
Modified: `app/Models/AlertRule.php` (new EVENT constant)
|
||||
Modified: `app/Jobs/Alerts/EvaluateAlertsJob.php` (new event method)
|
||||
|
||||
### Phase E — Badge + UI Integration
|
||||
|
||||
Modified: `app/Support/Badges/Domains/FindingStatusBadge.php` (resolved badge)
|
||||
Modified: `app/Support/OperationCatalog.php` (new type constant)
|
||||
|
||||
### Phase F — Retention
|
||||
|
||||
```
|
||||
app/Console/Commands/PruneStoredReportsCommand.php
|
||||
```
|
||||
|
||||
Modified: `routes/console.php` (schedule)
|
||||
|
||||
## Modified Files Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `app/Models/Finding.php` | Add `STATUS_RESOLVED`, `FINDING_TYPE_PERMISSION_POSTURE`, `resolve()`, `reopen()` methods, `resolved_at` cast |
|
||||
| `app/Models/AlertRule.php` | Add `EVENT_PERMISSION_MISSING` constant |
|
||||
| `app/Support/OperationCatalog.php` | Add `TYPE_PERMISSION_POSTURE_CHECK` constant |
|
||||
| `app/Support/Badges/Domains/FindingStatusBadge.php` | Add resolved badge mapping |
|
||||
| `app/Jobs/ProviderConnectionHealthCheckJob.php` | Dispatch posture job after compare() |
|
||||
| `app/Jobs/Alerts/EvaluateAlertsJob.php` | Add `permissionMissingEvents()` method |
|
||||
| `database/factories/FindingFactory.php` | Add `permissionPosture()` and `resolved()` states |
|
||||
| `routes/console.php` | Schedule `stored-reports:prune` daily |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All Spec 104 tests
|
||||
vendor/bin/sail artisan test --compact --filter=PermissionPosture
|
||||
|
||||
# Specific test files
|
||||
vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/GeneratePermissionPostureFindingsJobTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/StoredReportTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Alerts/PermissionMissingAlertTest.php
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Trigger**: Event-driven from health check job (not scheduled independently)
|
||||
2. **`resolved` status**: Global to all finding types (migration adds columns to findings table)
|
||||
3. **Re-open**: Resolved findings are re-opened (not archived+recreated)
|
||||
4. **Auto-resolve scope**: Both `new` and `acknowledged` findings auto-resolve when permission is granted
|
||||
5. **StoredReport**: Generic, reusable table (not permission-posture-specific)
|
||||
6. **Severity**: Derived from feature count in config registry (deterministic)
|
||||
99
specs/104-provider-permission-posture/research.md
Normal file
99
specs/104-provider-permission-posture/research.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Research: Provider Permission Posture (Spec 104)
|
||||
|
||||
**Date**: 2026-02-21 | **Branch**: `104-provider-permission-posture`
|
||||
|
||||
## R1 — Posture Job Trigger Mechanism
|
||||
|
||||
**Decision**: Event-driven dispatch after `TenantPermissionService::compare()` completes.
|
||||
|
||||
**Rationale**: The `ProviderConnectionHealthCheckJob` already calls `compare()` at L116–L131 and has the full `$permissionComparison` result array. Dispatching `GeneratePermissionPostureFindingsJob` immediately after `compare()` within the health check job:
|
||||
- Guarantees posture data freshness (always in sync with latest permission state)
|
||||
- Requires no new scheduling infrastructure
|
||||
- Follows the project's existing pattern of chaining work within health check jobs
|
||||
|
||||
**Alternatives considered**:
|
||||
- Independent schedule: Rejected because posture data would lag permission checks
|
||||
- Manual trigger: Rejected because it defeats the automation goal
|
||||
- Laravel model events on TenantPermission: Rejected because `compare()` batch-upserts multiple records — a model event per-permission creates N dispatches instead of 1
|
||||
|
||||
**Hook point**: `ProviderConnectionHealthCheckJob::handle()`, after `compare()` returns at ~L131, before `TenantPermissionCheckClusters::buildChecks()`.
|
||||
|
||||
## R2 — Finding `resolved` Status (Global Scope)
|
||||
|
||||
**Decision**: `resolved` is a global Finding status, available for all finding types.
|
||||
|
||||
**Rationale**: The `findings.status` column is a generic string — no enum constraint. Adding `STATUS_RESOLVED = 'resolved'` to the Finding model and the `FindingStatusBadge` mapper makes the lifecycle universal. This avoids fragmenting status semantics per finding type.
|
||||
|
||||
**Migration requirements**:
|
||||
- Add `resolved_at` (timestampTz, nullable) to `findings` table
|
||||
- Add `resolved_reason` (string, nullable) to `findings` table
|
||||
- Add `STATUS_RESOLVED = 'resolved'` constant to `Finding` model
|
||||
- Add resolved badge mapping to `FindingStatusBadge`
|
||||
- No index needed on `resolved_at` (queries filter by `status` which is already indexed)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Scoped to permission_posture only: Rejected because it fragments the status model unnecessarily
|
||||
- Separate resolved_findings table: Rejected (over-engineering for a status change)
|
||||
|
||||
## R3 — Finding Re-open Behavior
|
||||
|
||||
**Decision**: Re-open existing finding by resetting status to `new` and clearing `resolved_at`/`resolved_reason`.
|
||||
|
||||
**Rationale**: The `[tenant_id, fingerprint]` unique constraint means only one finding per permission per tenant can exist. Re-opening preserves the original `created_at` and audit trail. The `DriftFindingGenerator` pattern at L95–L142 also uses `firstOrNew` and updates in-place.
|
||||
|
||||
**Implementation**: When `firstOrNew` finds a resolved finding, set `status = new`, `resolved_at = null`, `resolved_reason = null`, update `evidence_jsonb` with current state.
|
||||
|
||||
## R4 — Auto-resolve Scope (all statuses)
|
||||
|
||||
**Decision**: Auto-resolve applies to both `new` and `acknowledged` findings.
|
||||
|
||||
**Rationale**: A finding represents a factual state ("permission X is missing"). When the fact changes, the finding should be resolved regardless of human acknowledgement. Acknowledgement metadata (`acknowledged_at`, `acknowledged_by`) is preserved for audit — only `status`, `resolved_at`, `resolved_reason` change.
|
||||
|
||||
## R5 — StoredReport Table Design
|
||||
|
||||
**Decision**: New generic `stored_reports` table following constitution SCOPE-001 ownership rules.
|
||||
|
||||
**Rationale**: The constitution explicitly lists `StoredReports/Exports` as tenant-owned. The table must include `workspace_id` (NOT NULL) and `tenant_id` (NOT NULL) per SCOPE-001 database convention for tenant-owned tables. A polymorphic `report_type` string enables reuse by future domains without schema changes.
|
||||
|
||||
**Schema decision**: JSONB `payload` column with GIN index for future querying. No separate columns for individual payload fields — the payload structure is type-dependent.
|
||||
|
||||
## R6 — Alert Integration Pattern
|
||||
|
||||
**Decision**: Add `EVENT_PERMISSION_MISSING` constant to `AlertRule` and a `permissionPostureEvents()` method to `EvaluateAlertsJob`.
|
||||
|
||||
**Rationale**: The existing alert pipeline follows a clear pattern:
|
||||
1. `EvaluateAlertsJob::handle()` collects events from dedicated methods
|
||||
2. Each method queries recent data within a time window
|
||||
3. Events are dispatched to `AlertDispatchService::dispatchEvent()`
|
||||
4. No structural changes needed — just a new event type + query method
|
||||
|
||||
**Implementation**:
|
||||
- `AlertRule::EVENT_PERMISSION_MISSING = 'permission_missing'`
|
||||
- New method `EvaluateAlertsJob::permissionMissingEvents()` queries open `permission_posture` findings with severity >= minimum
|
||||
- Event fingerprint: `permission_missing:{tenant_id}:{permission_key}` for cooldown dedup
|
||||
|
||||
## R7 — Operation Run Integration
|
||||
|
||||
**Decision**: Track posture generation as `OperationRun` with `type = 'permission_posture_check'`.
|
||||
|
||||
**Rationale**: Constitution requires OperationRun for queued jobs. `OperationCatalog` already has ~20 type constants. Adding one more follows the existing pattern.
|
||||
|
||||
**Implementation**: Add `TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check'` to `OperationCatalog`. The job creates/starts the run, processes all permissions for a tenant, records counts, and completes.
|
||||
|
||||
## R8 — Severity Derivation from Feature Impact
|
||||
|
||||
**Decision**: Use the `features` array from `config/intune_permissions.php` to determine severity.
|
||||
|
||||
**Rationale**: Each permission entry has a `features` array listing which product features depend on it. Counting blocked features maps directly to severity tiers defined in FR-005.
|
||||
|
||||
**Implementation**: `count($permission['features'])` → 0=low, 1=medium, 2=high, 3+=critical. This is deterministic and changes automatically when the registry is updated.
|
||||
|
||||
## R9 — Retention Mechanism for StoredReports
|
||||
|
||||
**Decision**: Scheduled artisan command that prunes reports older than configurable threshold.
|
||||
|
||||
**Rationale**: Standard Laravel pattern for data lifecycle management. A simple query `StoredReport::where('created_at', '<', now()->subDays($days))->delete()` in a scheduled command. Default: 90 days via `config/tenantpilot.php`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Database TTL/partitioning: Over-engineering for the expected volume
|
||||
- Soft delete: Unnecessary — reports are immutable snapshots, not user-managed records
|
||||
198
specs/104-provider-permission-posture/spec.md
Normal file
198
specs/104-provider-permission-posture/spec.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Feature Specification: Provider Permission Posture
|
||||
|
||||
**Feature Branch**: `104-provider-permission-posture`
|
||||
**Created**: 2026-02-21
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Provider Permission Posture - StoredReports foundation, Permission Posture Findings generation, and Alerts integration for measured app permissions"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant (per-tenant posture assessment) + workspace (alerts extend workspace-level infrastructure)
|
||||
- **Primary Routes**:
|
||||
- No new Filament pages in this spec; findings and stored reports are backend data consumed by existing Findings/Alerts UI
|
||||
- Existing: Monitoring > Findings (extended with `finding_type=permission_posture`)
|
||||
- Existing: Monitoring > Alerts (extended with new `EVENT_PERMISSION_MISSING` event type)
|
||||
- **Data Ownership**:
|
||||
- `stored_reports` -- tenant-owned, generic report storage (new table, workspace_id + tenant_id NOT NULL per SCOPE-001)
|
||||
- `findings` -- tenant-owned, extended with `finding_type=permission_posture` (existing table)
|
||||
- `tenant_permissions` -- tenant-owned, source of truth for measured permission state (existing table)
|
||||
- `alert_rules` -- workspace-owned, extended with `EVENT_PERMISSION_MISSING` trigger (existing table)
|
||||
- **RBAC**:
|
||||
- Workspace membership is required for any access (non-members receive 404)
|
||||
- Viewing posture findings uses existing `FINDINGS_VIEW` capability
|
||||
- Acknowledging posture findings uses existing `FINDINGS_MANAGE` capability
|
||||
- Alert rules for permission events use existing `ALERTS_VIEW` / `ALERTS_MANAGE` capabilities
|
||||
- No new RBAC capabilities are introduced
|
||||
|
||||
## Assumptions and Dependencies
|
||||
|
||||
- **TenantPermissionService** already performs live permission checks via Microsoft Graph and persists results to `tenant_permissions`. That flow is unchanged; this spec reads its output.
|
||||
- **DriftFindingGenerator** establishes the fingerprint-based idempotent upsert pattern for findings. The new `PermissionPostureFindingGenerator` follows the same pattern.
|
||||
- **Alerts v1** (Spec 099) provides the generic `AlertDispatchService` and `EvaluateAlertsJob` framework. This spec adds a new event type; no structural changes to alerting.
|
||||
- **`config/intune_permissions.php`** is the single registry of required permissions (14 entries, all `type: application`). The posture generator reads this registry to know what to expect.
|
||||
- **`findings.source`** column was added in a recent migration but is not currently populated. This spec uses it to tag posture findings (`source=permission_check`).
|
||||
|
||||
## User Scenarios and Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Generate permission posture findings (Priority: P1)
|
||||
|
||||
As a workspace operator, after my tenant's permissions have been checked, I want the system to automatically generate findings for any missing or degraded permissions so that I can see permission gaps alongside drift findings.
|
||||
|
||||
**Why this priority**: This is the core value. Without posture findings, nothing downstream (alerts, reports) has data to work with.
|
||||
|
||||
**Independent Test**: Run the posture finding generator for a tenant with 2 missing permissions; confirm 2 findings of type `permission_posture` exist with severity, fingerprint, and evidence populated.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has 14 required permissions and 12 are granted, **When** the posture finding generator runs, **Then** 2 findings are created with `finding_type=permission_posture`, `status=new`, and `severity` based on the number of features blocked.
|
||||
2. **Given** a tenant had a missing permission that is now granted, **When** the posture finding generator runs again, **Then** the previously-created finding for that permission is auto-resolved (status changes to `resolved`, `resolved_at` and `resolved_reason` recorded).
|
||||
3. **Given** a tenant has all required permissions granted, **When** the posture finding generator runs, **Then** no new findings are created; any previously open posture findings are auto-resolved.
|
||||
4. **Given** an operator has acknowledged a posture finding and the permission is later re-granted, **When** the posture finding generator runs, **Then** the finding is auto-resolved (acknowledged status does not block auto-resolve); acknowledgement metadata is preserved.
|
||||
5. **Given** the posture generator runs twice for the same permission state, **When** the same missing permissions persist, **Then** no duplicate findings are created (fingerprint-based idempotency).
|
||||
6. **Given** a permission finding was auto-resolved (permission was granted) and then the permission is revoked again, **When** the posture generator runs, **Then** the existing finding is re-opened (status set to `new`, `resolved_at`/`resolved_reason` cleared, evidence updated).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Persist posture snapshot as a stored report (Priority: P2)
|
||||
|
||||
As a workspace operator, I want each permission check to produce a durable posture snapshot (stored report) so that I can track permission health over time and answer "what was the posture at time T?"
|
||||
|
||||
**Why this priority**: Stored reports provide temporal context and form the foundation for future dashboards and trend analysis.
|
||||
|
||||
**Independent Test**: Run the posture check for a tenant; confirm a stored report record exists with the correct report type, payload schema, and associated tenant.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant permission check completes, **When** the stored report is created, **Then** it contains the full posture payload (required permissions, granted statuses, computed score, timestamp) and is associated with the tenant and workspace.
|
||||
2. **Given** multiple posture checks run over time, **When** I query stored reports for a tenant, **Then** I can see the history of snapshots ordered by creation date.
|
||||
3. **Given** the stored reports table has a polymorphic type field, **When** other spec domains later produce reports, **Then** they can use the same table without schema changes.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Alert on missing permissions (Priority: P3)
|
||||
|
||||
As a workspace manager, I want to be notified via existing alert channels (Teams/email) when a tenant is missing critical permissions, so I can take corrective action before operations fail.
|
||||
|
||||
**Why this priority**: Alerts close the feedback loop. Operators learn about permission gaps without polling the UI.
|
||||
|
||||
**Independent Test**: Create an alert rule for `EVENT_PERMISSION_MISSING`, run the posture generator for a tenant with a missing high-impact permission, and confirm a delivery is queued for the matching rule.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an alert rule exists for `EVENT_PERMISSION_MISSING` with minimum severity = high, **When** a posture finding of severity high is created, **Then** the alert dispatch service queues a delivery for each enabled destination on that rule.
|
||||
2. **Given** an alert rule exists for `EVENT_PERMISSION_MISSING` with minimum severity = critical, **When** a posture finding of severity high (not critical) is created, **Then** no delivery is queued for that rule.
|
||||
3. **Given** the same permission is still missing across two posture runs, **When** the alert is evaluated, **Then** cooldown/dedupe logic from Alerts v1 prevents duplicate notifications (fingerprint-based suppression).
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Posture score calculation (Priority: P2)
|
||||
|
||||
As a workspace operator, I want a normalized posture score (0-100) for each tenant that summarizes how many required permissions are granted versus missing, so I can quickly compare tenant health at a glance.
|
||||
|
||||
**Why this priority**: A single numeric score enables sorting, filtering, and future dashboard widgets.
|
||||
|
||||
**Independent Test**: Check a tenant with 12/14 permissions granted; confirm score = 86 (round(12/14 * 100)). Check a tenant with 14/14; confirm score = 100.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has N of M required permissions granted, **When** the posture score is calculated, **Then** the score equals `round(N / M * 100)`.
|
||||
2. **Given** the registry has 0 required permissions (edge case), **When** the score is calculated, **Then** the score is 100 (no requirements = fully compliant).
|
||||
3. **Given** the posture score is stored in the report payload, **When** users query stored reports, **Then** they can sort/filter tenants by posture score.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Graph API unreachable during posture check**: If TenantPermissionService returns an error state for a permission, the posture generator does NOT create a missing-permission finding for that key. Instead, it creates a finding with `finding_type=permission_posture` and `check_error=true` in evidence (per FR-015), using a distinct error fingerprint.
|
||||
- **Registry changes (permissions added/removed)**: If a new permission is added to `config/intune_permissions.php`, the next posture run automatically detects it as missing (for tenants that don't have it). If a permission is removed from the registry, existing findings for that key are auto-resolved on the next run.
|
||||
- **Tenant with no provider connection**: The posture generator skips tenants without a configured provider connection; no findings or reports are created.
|
||||
- **Concurrent posture runs**: Fingerprint-based upsert ensures idempotency. Two concurrent runs for the same tenant produce the same set of findings without duplicates.
|
||||
- **Large workspace with many tenants**: The posture check is dispatched per-tenant as a queued job (event-driven after `compare()`), not as a blocking batch operation.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces long-running/queued work (posture generator job).
|
||||
- **Contract registry**: No new Graph contracts; uses existing TenantPermissionService read path.
|
||||
- **Safety gates**: Posture generation is a read-only analysis; no write operations to Intune. No confirmation needed.
|
||||
- **Tenant isolation**: Findings and stored reports are always scoped to a specific tenant (via `tenant_id` FK). Workspace-level queries filter by tenant entitlement.
|
||||
- **Run observability**: The posture generator runs as a queued job. Its execution is tracked by the existing `OperationRun` mechanism with `type=permission_posture_check`. Status, outcome, started_at, completed_at are recorded.
|
||||
- **Tests**: Unit tests for finding generation logic, score calculation, auto-resolve, and alert event production.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature does not introduce new authorization behavior.
|
||||
- **Authorization planes**: Tenant-context (`/admin/t/{tenant}/...`) for viewing tenant posture findings; workspace-context for alerts.
|
||||
- **404 vs 403**: Non-member / not entitled to workspace or tenant scope results in 404. Member missing `FINDINGS_VIEW` results in 403.
|
||||
- **Server-side enforcement**: Existing FindingPolicy and AlertRule policies apply. No new policies needed.
|
||||
- **Capability registry**: Uses `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE` -- all existing.
|
||||
- **Global search**: No new globally searchable resources.
|
||||
- **Destructive actions**: None. Posture findings are system-generated, not user-deletable.
|
||||
- **Authorization tests**: **Exemption** — no new policies, gates, or authorization surfaces are introduced. Posture findings use existing `FindingPolicy` and `FINDINGS_VIEW`/`FINDINGS_MANAGE` capabilities. Existing authorization tests in Findings resource cover this. If future specs add posture-specific authorization surfaces, add dedicated tests at that time.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable -- no OIDC/SAML login flows involved.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature introduces new badge values:
|
||||
- `finding_type=permission_posture` -- new finding type value. Badge rendering uses the centralized finding-type badge map; the new type is added there with icon and color.
|
||||
- `severity` values: Uses existing `low`, `medium`, `high`, `critical` -- no new severity values.
|
||||
- `status` values: Uses existing `new`, `acknowledged` + new `resolved` status for auto-closed findings. The `resolved` status is added to the centralized badge map.
|
||||
- Tests cover rendering of `permission_posture` type badge and `resolved` status badge.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** No new Filament Resources/Pages/RelationManagers are introduced. Posture findings appear in the existing Findings list with the new `finding_type` filter value. **Exemption**: No UI Action Matrix needed -- no new action surfaces.
|
||||
|
||||
**Constitution alignment (UX-001 -- Layout and Information Architecture):** No new Filament screens. Posture findings use the existing Findings UI which already complies with UX-001. **Exemption**: No layout changes to audit.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Stored Reports Table)**: The system MUST provide a generic `stored_reports` table with a polymorphic `report_type` field, a JSONB `payload` column, and foreign keys to `tenant_id` and `workspace_id`. This table is reusable by future spec domains.
|
||||
- **FR-002 (Posture Payload Schema)**: Each stored posture report MUST contain: `report_type=permission_posture`, a payload with `required_permissions` (from registry), `granted_statuses` (per-key status at check time), `posture_score` (integer 0-100), and `checked_at` timestamp.
|
||||
- **FR-003 (Posture Score Calculation)**: The system MUST calculate posture score as `round(granted_count / required_count * 100)`. If `required_count` is 0, score MUST be 100.
|
||||
- **FR-004 (Posture Finding Generation)**: For each permission that is `status=missing` after a tenant permission check, the system MUST create or update a finding with `finding_type=permission_posture`, a deterministic fingerprint based on `tenant_id + permission_key`, and severity derived from the number of features that depend on that permission. Posture findings MUST set `subject_type='permission'` and `subject_external_id` to the permission key for uniform entity referencing.
|
||||
- **FR-005 (Severity Derivation)**: Finding severity MUST be derived from feature impact:
|
||||
- Permission blocks 3+ features results in `critical`
|
||||
- Permission blocks 2 features results in `high`
|
||||
- Permission blocks 1 feature results in `medium`
|
||||
- Permission blocks 0 features results in `low`
|
||||
- **FR-006 (Finding Evidence)**: Each posture finding MUST store evidence in `evidence_jsonb` containing at minimum: `permission_key`, `permission_type`, `expected_status`, `actual_status`, `blocked_features` (list), and `checked_at`.
|
||||
- **FR-007 (Finding Source Tag)**: Posture findings MUST set `source=permission_check` on the `findings` table to distinguish them from drift findings.
|
||||
- **FR-008 (Auto-Resolve)**: When a previously-missing permission is now granted, the system MUST auto-resolve the corresponding finding by changing its status to `resolved` and recording `resolved_at` and `resolved_reason=permission_granted`. Auto-resolve applies regardless of current status: both `new` and `acknowledged` findings transition to `resolved`. Acknowledgement metadata (`acknowledged_at`, `acknowledged_by`) is preserved on the record for audit. The `resolved` status, `resolved_at`, and `resolved_reason` columns are global additions to the `findings` table (usable by all finding types, not just `permission_posture`).
|
||||
- **FR-009 (Idempotent Upsert)**: The posture generator MUST use fingerprint-based upsert (`firstOrNew` on `tenant_id + fingerprint`) to prevent duplicate findings for the same permission on the same tenant. If a resolved finding is matched (permission became missing again), the system MUST re-open it: set status to `new`, clear `resolved_at` and `resolved_reason`, and update `evidence_jsonb` to reflect the current state.
|
||||
- **FR-010 (Alert Event)**: When posture findings are created or re-opened, the system MUST produce `EVENT_PERMISSION_MISSING` events for the alert dispatch pipeline. Events MUST include tenant_id, permission_key, severity, and a deterministic fingerprint for cooldown/dedupe. Resolved or unchanged findings do NOT produce alert events.
|
||||
- **FR-011 (Alert Rule Integration)**: The `EVENT_PERMISSION_MISSING` event type MUST be available as an option when creating/editing alert rules in the existing Alerts UI. No new alert pages are needed.
|
||||
- **FR-012 (Queued Execution)**: Posture generation MUST execute as a queued job (per-tenant), dispatched automatically after each `TenantPermissionService::compare()` completes (event-driven). No independent schedule or manual trigger is required.
|
||||
- **FR-013 (Operation Run Tracking)**: Each posture generation execution MUST be tracked as an `OperationRun` with `type=permission_posture_check`, recording start, completion, outcome (success/failure), and error details.
|
||||
- **FR-014 (Tenant Isolation)**: Findings, stored reports, and operation runs MUST be scoped to a specific tenant via `tenant_id`. Cross-tenant data access MUST be impossible at the query level.
|
||||
- **FR-015 (Error Handling)**: If the permission check returns an error state for a specific permission key, the system MUST NOT create a `missing` finding for that key. Instead, it MUST create a finding with `finding_type=permission_posture` and error evidence in `evidence_jsonb` containing: `check_error=true`, `error_message` (string), `permission_key`, and `checked_at`. Error findings are NOT required to include `expected_status`, `actual_status`, or `blocked_features` from FR-006. The finding uses fingerprint `sha256("permission_posture:{tenant_id}:{permission_key}:error")` (distinct from the normal missing-permission fingerprint). Severity for error findings defaults to `low`.
|
||||
- **FR-016 (Skip Unconnected Tenants)**: The posture generator MUST skip tenants that have no configured provider connection. No findings or reports are created for unconnected tenants.
|
||||
- **FR-017 (Registry as Source)**: The set of required permissions MUST be read from `config/intune_permissions.php` at runtime. No hardcoded permission lists in the generator.
|
||||
- **FR-018 (Retention)**: Stored reports MUST support configurable retention. Default: 90 days.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
**Exemption**: This spec does NOT add or modify any Filament Resource, RelationManager, or Page.
|
||||
|
||||
Posture findings are rendered via the existing Findings Resource with a new `finding_type` filter value (`permission_posture`). The `EVENT_PERMISSION_MISSING` option is added to the existing Alert Rules form event type dropdown. No new surfaces, actions, or pages are created.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **StoredReport**: A generic, tenant-owned report record (workspace_id + tenant_id NOT NULL per SCOPE-001). Polymorphic `report_type` distinguishes domain (e.g., `permission_posture`). JSONB `payload` holds the full report data. Supports temporal queries (ordered by `created_at`).
|
||||
- **Finding (extended)**: Existing model extended with `finding_type=permission_posture` and `source=permission_check`. Each posture finding has a deterministic fingerprint (`tenant_id + permission_key`), severity derived from feature impact, and structured evidence in `evidence_jsonb`. Posture findings set `subject_type='permission'` and `subject_external_id={permission_key}` for uniform entity referencing across finding types. The `resolved` status (plus `resolved_at` timestampTz and `resolved_reason` string columns) is added globally to the Finding model — all finding types (drift, permission_posture, future) can use this lifecycle state.
|
||||
- **AlertRule (extended)**: Existing model gains `EVENT_PERMISSION_MISSING` as a valid `event_type` constant. No schema change; the constant is added to the model and the UI dropdown.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-21
|
||||
|
||||
- Q: What dispatches the posture generation job? → A: Event-driven — dispatched automatically after each `TenantPermissionService::compare()` completes.
|
||||
- Q: Does `resolved` status apply globally to all finding types or only permission_posture? → A: Global — `resolved` is a valid status for ALL finding types (drift, permission_posture, future types). The `resolved_at` and `resolved_reason` columns are added to the `findings` table as nullable columns usable by any finding type.
|
||||
- Q: When a resolved finding's permission becomes missing again, re-open or create new? → A: Re-open — set status back to `new`, clear `resolved_at`/`resolved_reason`, update evidence to current state. Preserves history and avoids stale resolved records accumulating.
|
||||
- Q: Can an `acknowledged` finding be auto-resolved when the permission is re-granted? → A: Yes — auto-resolve applies regardless of current status (`new` → `resolved` and `acknowledged` → `resolved`). The finding represents a factual state; when the fact changes, the finding is resolved. Acknowledgement history (`acknowledged_at`, `acknowledged_by`) is preserved for audit.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Posture coverage)**: For any tenant with a configured provider connection, 100% of required permissions from the registry are evaluated and their status reflected in the posture report and findings.
|
||||
- **SC-002 (Finding accuracy)**: The number of open `permission_posture` findings for a tenant exactly matches the number of `missing` permissions reported by the tenant permission check (no duplicates, no omissions).
|
||||
- **SC-003 (Auto-resolve latency)**: When a previously-missing permission is granted, the corresponding finding is auto-resolved within the next posture check cycle (no manual intervention needed).
|
||||
- **SC-004 (Score reliability)**: Posture score for a tenant with N of M permissions granted equals `round(N / M * 100)`. Deterministic and reproducible.
|
||||
- **SC-005 (Alert delivery)**: When a posture finding of qualifying severity is created and an active alert rule matches, a delivery is queued within the standard alert processing time (under 2 minutes per Alerts v1 SLA).
|
||||
- **SC-006 (Temporal audit)**: An operator can query stored reports to see a tenant's posture at any point in the last 90 days.
|
||||
- **SC-007 (No duplicates)**: Repeated posture checks for the same permission state on the same tenant produce no duplicate findings (fingerprint idempotency verified).
|
||||
258
specs/104-provider-permission-posture/tasks.md
Normal file
258
specs/104-provider-permission-posture/tasks.md
Normal file
@ -0,0 +1,258 @@
|
||||
# Tasks: Provider Permission Posture
|
||||
|
||||
**Input**: Design documents from `/specs/104-provider-permission-posture/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/internal-services.md, quickstart.md
|
||||
|
||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). All user stories include test tasks.
|
||||
**Operations**: This feature introduces queued work (`GeneratePermissionPostureFindingsJob`). Tasks include `OperationRun` creation and outcome tracking per constitution.
|
||||
**RBAC**: No new capabilities introduced. Uses existing `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE`. No new Gate/Policy needed. **Authorization tests exemption** — no new authorization surfaces or policies; existing FindingPolicy and AlertRulePolicy coverage applies (see spec Constitution alignment RBAC-UX).
|
||||
**Filament UI Action Surfaces**: **Exemption** — no new Filament Resources/Pages/RelationManagers. Only change is adding a new option to `AlertRuleResource::eventTypeOptions()`.
|
||||
**Filament UI UX-001**: **Exemption** — no new screens.
|
||||
**Badges**: Adds `resolved` status to `FindingStatusBadge` and creates `FindingTypeBadge` for `permission_posture` per BADGE-001. Tests included.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: No project setup needed — existing Laravel project with all framework dependencies.
|
||||
|
||||
*Phase skipped.*
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundation (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Migrations, models, constants, and badge mappings that ALL user stories depend on.
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
### Migrations
|
||||
|
||||
- [X] T001 Create migration for `stored_reports` table via `vendor/bin/sail artisan make:migration create_stored_reports_table` — columns: id (PK), workspace_id (FK→workspaces, NOT NULL), tenant_id (FK→tenants, NOT NULL), report_type (string, NOT NULL), payload (jsonb, NOT NULL), timestamps; indexes: composite on [workspace_id, tenant_id, report_type], [tenant_id, created_at], GIN on payload. See `specs/104-provider-permission-posture/data-model.md` for full schema.
|
||||
- [X] T002 [P] Create migration to add `resolved_at` (timestampTz, nullable) and `resolved_reason` (string(255), nullable) to `findings` table via `vendor/bin/sail artisan make:migration add_resolved_to_findings_table`. These are global columns usable by all finding types per spec clarification.
|
||||
|
||||
### Models & Factories
|
||||
|
||||
- [X] T003 [P] Create `StoredReport` model in `app/Models/StoredReport.php` with: `REPORT_TYPE_PERMISSION_POSTURE` constant, `DerivesWorkspaceIdFromTenant` + `HasFactory` traits, fillable `[workspace_id, tenant_id, report_type, payload]`, `casts()` method with `payload → array`, `workspace()` BelongsTo and `tenant()` BelongsTo relationships. See `specs/104-provider-permission-posture/data-model.md` StoredReport model section.
|
||||
- [X] T004 [P] Create `StoredReportFactory` in `database/factories/StoredReportFactory.php` with default state: `tenant_id → Tenant::factory()`, `report_type → StoredReport::REPORT_TYPE_PERMISSION_POSTURE`, `payload` containing sample posture data (posture_score, required_count, granted_count, checked_at, permissions array). See `specs/104-provider-permission-posture/data-model.md` payload schema.
|
||||
- [X] T005 Extend `Finding` model in `app/Models/Finding.php`: add `STATUS_RESOLVED = 'resolved'` constant, `FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture'` constant (error findings use the same type, distinguished by `check_error=true` in evidence per FR-015 — no separate constant needed), `resolved_at` datetime cast in `casts()` method, `resolve(string $reason): void` method (sets status, resolved_at, resolved_reason, saves), `reopen(array $evidence): void` method (sets status=new, clears resolved_at/resolved_reason, updates evidence_jsonb, saves). See `specs/104-provider-permission-posture/data-model.md` Finding model section.
|
||||
- [X] T006 Add `permissionPosture()` and `resolved()` factory states to `database/factories/FindingFactory.php`. `permissionPosture()` sets: `finding_type → Finding::FINDING_TYPE_PERMISSION_POSTURE`, `source → 'permission_check'`, `severity → Finding::SEVERITY_MEDIUM`, sample permission evidence in `evidence_jsonb`. `resolved()` sets: `status → Finding::STATUS_RESOLVED`, `resolved_at → now()`, `resolved_reason → 'permission_granted'`.
|
||||
|
||||
### Constants & Badge Mappings
|
||||
|
||||
- [X] T007 [P] Add `TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check'` constant to `app/Support/OperationCatalog.php`
|
||||
- [X] T008 [P] Add `EVENT_PERMISSION_MISSING = 'permission_missing'` constant to `app/Models/AlertRule.php`
|
||||
- [X] T009 [P] Add `resolved` status badge mapping (color: `success`, icon: `heroicon-o-check-circle`) to `app/Support/Badges/Domains/FindingStatusBadge.php`
|
||||
- [X] T010 [P] Create `FindingTypeBadge` in `app/Support/Badges/Domains/FindingTypeBadge.php` following the pattern of `FindingStatusBadge.php` / `FindingSeverityBadge.php`. Map `drift → info`, `permission_posture → warning`. Register in badge catalog per BADGE-001.
|
||||
|
||||
### Verify & Test Foundation
|
||||
|
||||
- [X] T011 Run migrations via `vendor/bin/sail artisan migrate` and verify `stored_reports` table and `findings` column additions exist
|
||||
- [X] T012 [P] Write StoredReport model CRUD tests in `tests/Feature/PermissionPosture/StoredReportModelTest.php`: create with factory, verify relationships (tenant, workspace), verify payload cast to array, verify report_type constant
|
||||
- [X] T013 [P] Write Finding resolved lifecycle tests in `tests/Feature/Models/FindingResolvedTest.php`: `resolve()` sets status/resolved_at/resolved_reason, `reopen()` resets to new/clears resolved fields/updates evidence, resolve from `acknowledged` preserves acknowledged_at/acknowledged_by
|
||||
- [X] T014 [P] Write badge rendering tests for `resolved` status and `permission_posture` finding type in `tests/Feature/Support/Badges/FindingBadgeTest.php`: resolved status renders success color, permission_posture type renders correct badge
|
||||
|
||||
**Checkpoint**: Foundation ready — all models, migrations, constants, and badges in place. User story implementation can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Generate Permission Posture Findings (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: After a tenant's permissions are checked, automatically generate findings for missing/degraded permissions with severity, fingerprint, and evidence. Auto-resolve when permissions are granted. Re-open when revoked again.
|
||||
|
||||
**Independent Test**: Run the posture finding generator for a tenant with 2 missing permissions; confirm 2 findings of type `permission_posture` exist with severity, fingerprint, and evidence populated.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T015 [P] [US1] Create `PostureResult` value object in `app/Services/PermissionPosture/PostureResult.php` with readonly properties: `findingsCreated`, `findingsResolved`, `findingsReopened`, `findingsUnchanged`, `errorsRecorded`, `postureScore`, `storedReportId`. See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 1 output.
|
||||
- [X] T016 [P] [US1] Create `PostureScoreCalculator` service in `app/Services/PermissionPosture/PostureScoreCalculator.php` with `calculate(array $permissionComparison): int` — returns `round(granted / required * 100)`, returns 100 when required_count is 0. Pure function, no DB access. See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 2.
|
||||
- [X] T017 [US1] Create `PermissionPostureFindingGenerator` service in `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` with `generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult`. Implementation must: (1) iterate permissions from comparison, (2) for `status=missing`: create/reopen findings via fingerprint upsert (`firstOrNew` on `[tenant_id, fingerprint]`), (3) for `status=granted`: auto-resolve existing open findings, (4) for `status=error`: create error findings with `check_error=true` in evidence and distinct fingerprint `sha256("permission_posture:{tenant_id}:{permission_key}:error")` per FR-015, (5) compute severity from feature count (3+→critical, 2→high, 1→medium, 0→low per FR-005; error findings default to `low`), (6) set `subject_type='permission'` and `subject_external_id={permission_key}` on all posture findings, (7) create StoredReport with posture payload, (8) produce alert events for new/reopened findings. Fingerprint for missing: `sha256("permission_posture:{tenant_id}:{permission_key}")` truncated to 64 chars. (9) After processing all permissions from comparison, resolve any remaining open `permission_posture` findings for this tenant whose `permission_key` (from evidence) is NOT present in the current comparison — this handles registry removals per edge case "Registry changes". See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 1 and `specs/104-provider-permission-posture/data-model.md`.
|
||||
- [X] T018 [US1] Create `GeneratePermissionPostureFindingsJob` in `app/Jobs/GeneratePermissionPostureFindingsJob.php` with constructor accepting `int $tenantId` and `array $permissionComparison`. `handle()` must: (1) load Tenant or fail, (2) skip if no active provider connection (FR-016), (3) create OperationRun with `type=permission_posture_check`, (4) call `PermissionPostureFindingGenerator::generate()`, (5) record summary counts on OperationRun, (6) complete OperationRun. See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 3.
|
||||
- [X] T019 [US1] Modify `app/Jobs/ProviderConnectionHealthCheckJob.php` to dispatch `GeneratePermissionPostureFindingsJob` after `TenantPermissionService::compare()` returns at ~L131, only when `$permissionComparison['overall_status'] !== 'error'`. Pass tenant ID and full comparison result to the job.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T020 [P] [US1] Write PostureScoreCalculator tests in `tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php`: all granted → 100, 12 of 14 → 86, all missing → 0, 0 required → 100, single permission granted → 100, single permission missing → 0
|
||||
- [X] T021 [US1] Write PermissionPostureFindingGenerator tests in `tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`: (1) creates findings for missing permissions with correct type/severity/fingerprint/evidence/source, (2) auto-resolves finding when permission granted (status→resolved, resolved_at set, resolved_reason='permission_granted'), (3) auto-resolves acknowledged finding (preserves acknowledged_at/acknowledged_by), (4) no duplicates on idempotent run (same missing permissions → same findings), (5) re-opens resolved finding when permission revoked again (status→new, cleared resolved fields, updated evidence), (6) creates error finding for status=error permissions with `check_error=true` in evidence and distinct fingerprint (FR-015), (7) severity derivation: 0 features→low, 1→medium, 2→high, 3+→critical, (8) creates StoredReport with correct payload, (9) no findings when all granted (existing opened findings are resolved), (10) produces alert events for new and reopened findings only, (11) generator reads permissions from `config('intune_permissions')` at runtime — not hardcoded (FR-017), (12) all findings are scoped to tenant_id via FK constraint (FR-014), (13) subject_type='permission' and subject_external_id={permission_key} set on every posture finding, (14) stale findings for permissions removed from registry are auto-resolved (open finding whose permission_key is not in current comparison → resolved with reason='permission_removed_from_registry')
|
||||
- [X] T022 [US1] Write GeneratePermissionPostureFindingsJob tests in `tests/Feature/PermissionPosture/GeneratePermissionPostureFindingsJobTest.php`: (1) successful run creates OperationRun with type=permission_posture_check and outcome=success, (2) skips tenant without provider connection (no findings, no report, no OperationRun), (3) records summary counts on OperationRun (findings_created, findings_resolved, etc.), (4) handles generator exceptions gracefully (OperationRun marked failed), (5) dispatched from ProviderConnectionHealthCheckJob after successful compare
|
||||
|
||||
**Checkpoint**: US1 complete — posture findings are generated, auto-resolved, re-opened, and tracked via OperationRun. This is the MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 4 — Posture Score Calculation (Priority: P2) + User Story 2 — Persist Posture Snapshot (Priority: P2)
|
||||
|
||||
**Goal (US4)**: Provide a normalized posture score (0-100) for each tenant summarizing permission health.
|
||||
**Goal (US2)**: Each permission check produces a durable posture snapshot (stored report) for temporal queries.
|
||||
|
||||
**Independent Test (US4)**: Tenant with 12/14 permissions → score = 86. Tenant with 14/14 → score = 100.
|
||||
**Independent Test (US2)**: Run posture check; confirm stored report exists with correct report_type, payload schema, and tenant association.
|
||||
|
||||
*Note: PostureScoreCalculator is implemented in Phase 3 (US1 dependency). StoredReport creation is within the generator (Phase 3). This phase adds dedicated acceptance tests for US2 and US4 scenarios.*
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T023 [P] [US4] Extend PostureScoreCalculator tests in `tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php` with acceptance scenarios: exact rounding verification for various N/M combinations (1/3→33, 2/3→67, 7/14→50), confirm score is integer not float
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T024 [US2] Extend StoredReport tests (file created by T012: `tests/Feature/PermissionPosture/StoredReportModelTest.php`) with generator integration scenarios: (1) report created by generator has report_type='permission_posture' with payload containing posture_score, required_count, granted_count, checked_at, and permissions array, (2) posture_score in payload matches PostureScoreCalculator output
|
||||
- [X] T025 [P] [US2] Test temporal ordering in `tests/Feature/PermissionPosture/StoredReportModelTest.php` (extend file from T012): multiple posture reports for same tenant ordered by created_at descending, queryable by tenant_id + report_type
|
||||
- [X] T026 [P] [US2] Test polymorphic reusability in `tests/Feature/PermissionPosture/StoredReportModelTest.php` (extend file from T012): create StoredReport with different report_type value, confirm coexists with permission_posture reports and is independently queryable
|
||||
|
||||
**Checkpoint**: US2 + US4 complete — posture scores are accurate and stored reports provide temporal audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Alert on Missing Permissions (Priority: P3)
|
||||
|
||||
**Goal**: Notify operators via existing alert channels when a tenant is missing critical permissions, using the Alerts v1 pipeline.
|
||||
|
||||
**Independent Test**: Create an alert rule for `EVENT_PERMISSION_MISSING` with min severity = high, run the posture generator for a tenant with a high-severity missing permission, confirm a delivery is queued.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T027 [US3] Add `permissionMissingEvents(): array` method to `app/Jobs/Alerts/EvaluateAlertsJob.php` — queries `Finding` where `finding_type='permission_posture'`, `status IN ('new')`, within the time window (filter by `updated_at`, not `created_at`, to capture re-opened findings whose original creation predates the window). Returns array of event arrays matching Contract 4 schema (event_type, tenant_id, severity, fingerprint_key, title, body, metadata). Wire the method into `handle()` event collection alongside existing `highDriftEvents()` and `compareFailedEvents()` at ~L64-L67.
|
||||
- [X] T028 [P] [US3] Add `EVENT_PERMISSION_MISSING` option to `AlertRuleResource::eventTypeOptions()` in `app/Filament/Resources/AlertRuleResource.php` at ~L376: `AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing'`
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T029 [US3] Write alert integration tests in `tests/Feature/Alerts/PermissionMissingAlertTest.php`: (1) alert rule for EVENT_PERMISSION_MISSING with min severity=high + finding of severity=high → delivery queued, (2) alert rule with min severity=critical + finding of severity=high → no delivery queued, (3) same missing permission across two runs → cooldown/dedupe prevents duplicate notifications (fingerprint_key based suppression), (4) resolved findings do not produce alert events
|
||||
|
||||
**Checkpoint**: US3 complete — operators receive alerts for missing permissions via existing alert channels.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Retention cleanup, configuration, end-to-end integration, and code quality.
|
||||
|
||||
- [X] T030 Create `PruneStoredReportsCommand` in `app/Console/Commands/PruneStoredReportsCommand.php` with signature `stored-reports:prune {--days=}`. Default days from `config('tenantpilot.stored_reports.retention_days', 90)`. Deletes `StoredReport::where('created_at', '<', now()->subDays($days))`. Outputs count of deleted records.
|
||||
- [X] T031 Add `stored_reports.retention_days` configuration key to `config/tenantpilot.php` with default value 90. Register `stored-reports:prune` command in daily schedule in `routes/console.php`.
|
||||
- [X] T032 [P] Write retention pruning tests in `tests/Feature/PermissionPosture/PruneStoredReportsCommandTest.php`: (1) reports older than threshold are deleted, (2) reports within threshold are preserved, (3) custom --days flag overrides config default
|
||||
- [X] T033 Write end-to-end integration test in `tests/Feature/PermissionPosture/PermissionPostureIntegrationTest.php`: simulate full flow — health check calls compare() → posture job dispatched → findings created for missing permissions → stored report created with score → alert events produced for qualifying findings → OperationRun completed with correct counts. Include a lightweight timing assertion: posture generation for a 14-permission tenant completes in <5s (`expect($elapsed)->toBeLessThan(5000)`).
|
||||
- [X] T034 Run `vendor/bin/sail bin pint --dirty` to fix formatting, then run `vendor/bin/sail artisan test --compact --filter=PermissionPosture` to verify all Spec 104 tests pass
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
```
|
||||
Phase 2 (Foundation) ─────┬──> Phase 3 (US1 - P1) 🎯 ──┬──> Phase 4 (US2+US4 - P2)
|
||||
│ ├──> Phase 5 (US3 - P3)
|
||||
│ └──> Phase 6 (Polish)
|
||||
│
|
||||
└── BLOCKS all user story work
|
||||
```
|
||||
|
||||
- **Foundation (Phase 2)**: No dependencies — start immediately. BLOCKS all user stories.
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 completion. This is the MVP.
|
||||
- **US2+US4 (Phase 4)**: Depends on Phase 3 (generator creates reports and scores).
|
||||
- **US3 (Phase 5)**: Depends on Phase 3 (generator produces alert events).
|
||||
- **US4 and US2 can run in parallel with US3** after Phase 3 completes.
|
||||
- **Polish (Phase 6)**: Depends on all user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundation — No dependencies on other stories. **This is the MVP.**
|
||||
- **User Story 4 (P2)**: Depends on US1 (PostureScoreCalculator built there). Tests only — no new implementation.
|
||||
- **User Story 2 (P2)**: Depends on US1 (generator creates reports). Tests only — no new implementation.
|
||||
- **User Story 3 (P3)**: Depends on US1 (generator produces event data). New implementation in EvaluateAlertsJob + AlertRuleResource.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Implementation tasks before test tasks (test tasks reference the implementation)
|
||||
- Models/VOs before services
|
||||
- Services before jobs
|
||||
- Jobs before hooks/integration points
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Phase 2 (Foundation)**:
|
||||
- T001 + T002 (migrations, independent files)
|
||||
- T003 + T004 + T005 (models, independent files)
|
||||
- T007 + T008 + T009 + T010 (constants/badges, independent files)
|
||||
- T012 + T013 + T014 (tests, independent files)
|
||||
|
||||
**Phase 3 (US1)**:
|
||||
- T015 + T016 (PostureResult VO + PostureScoreCalculator, independent files)
|
||||
- T020 can start after T016 (tests calculator)
|
||||
|
||||
**Phase 4 (US2+US4)**:
|
||||
- T023 + T025 + T026 (independent test files)
|
||||
|
||||
**Phase 5 (US3)**:
|
||||
- T028 can run in parallel with T027 (different files)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Foundation Phase
|
||||
|
||||
```bash
|
||||
# Batch 1: Migrations (parallel)
|
||||
T001: Create stored_reports migration
|
||||
T002: Create findings resolved migration
|
||||
|
||||
# Batch 2: Models + Constants (parallel, after migrations written)
|
||||
T003: StoredReport model
|
||||
T004: StoredReportFactory
|
||||
T005: Finding model extensions
|
||||
T007: OperationCatalog constant
|
||||
T008: AlertRule constant
|
||||
T009: FindingStatusBadge resolved mapping
|
||||
T010: FindingTypeBadge
|
||||
|
||||
# Batch 3: Factory states (after T005)
|
||||
T006: FindingFactory states
|
||||
|
||||
# Batch 4: Run migrations
|
||||
T011: vendor/bin/sail artisan migrate
|
||||
|
||||
# Batch 5: Tests (parallel)
|
||||
T012: StoredReport CRUD test
|
||||
T013: Finding resolved lifecycle test
|
||||
T014: Badge rendering test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 2: Foundation (migrations, models, constants, badges)
|
||||
2. Complete Phase 3: User Story 1 (generator, job, health check hook)
|
||||
3. **STOP and VALIDATE**: Run `vendor/bin/sail artisan test --compact --filter=PermissionPosture`
|
||||
4. Posture findings are now generated automatically after each health check
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Foundation → ready
|
||||
2. US1 (P1) → MVP: findings generated, auto-resolved, re-opened, tracked ✅
|
||||
3. US2+US4 (P2) → stored reports + posture scores verified ✅
|
||||
4. US3 (P3) → alerts for missing permissions ✅
|
||||
5. Polish → retention, integration test, code quality ✅
|
||||
|
||||
Each phase adds value without breaking previous phases.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies on incomplete tasks
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Fingerprint formula: `sha256("permission_posture:{tenant_id}:{permission_key}")` truncated to 64 chars
|
||||
- Severity tiers from features count: 0→low, 1→medium, 2→high, 3+→critical
|
||||
- All posture findings set `source='permission_check'` per FR-007
|
||||
- Alert events produced only for new/reopened findings, NOT for resolved or unchanged
|
||||
- Commit after each task or logical group
|
||||
187
tests/Feature/Alerts/PermissionMissingAlertTest.php
Normal file
187
tests/Feature/Alerts/PermissionMissingAlertTest.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// --- Helper ---
|
||||
function createAlertRuleWithDestination(int $workspaceId, string $eventType, string $minSeverity, int $cooldownSeconds = 0): array
|
||||
{
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => $eventType,
|
||||
'minimum_severity' => $minSeverity,
|
||||
'is_enabled' => true,
|
||||
'cooldown_seconds' => $cooldownSeconds,
|
||||
]);
|
||||
|
||||
$rule->destinations()->attach($destination->getKey(), [
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
return [$rule, $destination];
|
||||
}
|
||||
|
||||
// (1) Alert rule for EVENT_PERMISSION_MISSING with min severity=high + finding of severity=high → delivery queued
|
||||
it('queues delivery when permission missing finding matches alert rule severity', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
[$rule, $destination] = createAlertRuleWithDestination(
|
||||
$workspaceId,
|
||||
AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'high',
|
||||
);
|
||||
|
||||
// Create a high-severity permission posture finding
|
||||
$finding = Finding::factory()->permissionPosture()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'Missing permission detected',
|
||||
'body' => 'Permission "DeviceManagementConfiguration.ReadWrite.All" is missing.',
|
||||
'metadata' => ['finding_id' => (int) $finding->getKey()],
|
||||
];
|
||||
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
$workspace = \App\Models\Workspace::query()->find($workspaceId);
|
||||
|
||||
$created = $dispatchService->dispatchEvent($workspace, $event);
|
||||
|
||||
expect($created)->toBe(1);
|
||||
|
||||
$delivery = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('event_type', AlertRule::EVENT_PERMISSION_MISSING)
|
||||
->first();
|
||||
|
||||
expect($delivery)->not->toBeNull()
|
||||
->and($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
||||
->and($delivery->severity)->toBe(Finding::SEVERITY_HIGH);
|
||||
});
|
||||
|
||||
// (2) Alert rule with min severity=critical + finding of severity=high → no delivery queued
|
||||
it('does not queue delivery when finding severity is below minimum', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
[$rule, $destination] = createAlertRuleWithDestination(
|
||||
$workspaceId,
|
||||
AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'critical', // minimum severity is critical
|
||||
);
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH, // high < critical — should not match
|
||||
'fingerprint_key' => 'finding:999',
|
||||
'title' => 'Missing permission detected',
|
||||
'body' => 'Permission "DeviceManagementConfiguration.ReadWrite.All" is missing.',
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
$workspace = \App\Models\Workspace::query()->find($workspaceId);
|
||||
|
||||
$created = $dispatchService->dispatchEvent($workspace, $event);
|
||||
|
||||
expect($created)->toBe(0);
|
||||
});
|
||||
|
||||
// (3) Same missing permission across two runs → cooldown prevents duplicate delivery
|
||||
it('suppresses duplicate delivery via fingerprint cooldown', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
[$rule, $destination] = createAlertRuleWithDestination(
|
||||
$workspaceId,
|
||||
AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'high',
|
||||
cooldownSeconds: 3600, // 1 hour cooldown
|
||||
);
|
||||
|
||||
$finding = Finding::factory()->permissionPosture()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'Missing permission detected',
|
||||
'body' => 'Finding '.$finding->getKey().' was created with severity high.',
|
||||
'metadata' => ['finding_id' => (int) $finding->getKey()],
|
||||
];
|
||||
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
$workspace = \App\Models\Workspace::query()->find($workspaceId);
|
||||
|
||||
// First dispatch — should create a QUEUED delivery
|
||||
$firstCreated = $dispatchService->dispatchEvent($workspace, $event);
|
||||
expect($firstCreated)->toBe(1);
|
||||
|
||||
// Second dispatch — same fingerprint within cooldown → SUPPRESSED
|
||||
$secondCreated = $dispatchService->dispatchEvent($workspace, $event);
|
||||
expect($secondCreated)->toBe(1);
|
||||
|
||||
$deliveries = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('event_type', AlertRule::EVENT_PERMISSION_MISSING)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($deliveries)->toHaveCount(2)
|
||||
->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
||||
->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
||||
});
|
||||
|
||||
// (4) Resolved findings do not produce alert events via permissionMissingEvents()
|
||||
it('resolved findings are excluded from permissionMissingEvents', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
// Create a resolved finding — should not appear in events
|
||||
Finding::factory()->permissionPosture()->resolved()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
]);
|
||||
|
||||
// Use reflection to call the private permissionMissingEvents method
|
||||
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId);
|
||||
$reflection = new ReflectionMethod($job, 'permissionMissingEvents');
|
||||
|
||||
$events = $reflection->invoke(
|
||||
$job,
|
||||
$workspaceId,
|
||||
\Carbon\CarbonImmutable::now('UTC')->subHours(1),
|
||||
);
|
||||
|
||||
expect($events)->toBe([]);
|
||||
});
|
||||
91
tests/Feature/Models/FindingResolvedTest.php
Normal file
91
tests/Feature/Models/FindingResolvedTest.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves a finding with reason', function (): void {
|
||||
$finding = Finding::factory()->permissionPosture()->create();
|
||||
|
||||
$finding->resolve('permission_granted');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
|
||||
$fresh = Finding::query()->find($finding->getKey());
|
||||
expect($fresh->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($fresh->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('reopens a resolved finding', function (): void {
|
||||
$finding = Finding::factory()->permissionPosture()->resolved()->create();
|
||||
|
||||
$newEvidence = [
|
||||
'permission_key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||
'permission_type' => 'application',
|
||||
'expected_status' => 'granted',
|
||||
'actual_status' => 'missing',
|
||||
'blocked_features' => ['policy-sync'],
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$finding->reopen($newEvidence);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_NEW)
|
||||
->and($finding->resolved_at)->toBeNull()
|
||||
->and($finding->resolved_reason)->toBeNull()
|
||||
->and($finding->evidence_jsonb)->toBe($newEvidence);
|
||||
});
|
||||
|
||||
it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$finding = Finding::factory()->permissionPosture()->create();
|
||||
|
||||
$finding->acknowledge($user);
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
|
||||
$finding->resolve('permission_granted');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->acknowledged_at)->not->toBeNull()
|
||||
->and($finding->acknowledged_by_user_id)->toBe($user->getKey())
|
||||
->and($finding->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('has STATUS_RESOLVED constant', function (): void {
|
||||
expect(Finding::STATUS_RESOLVED)->toBe('resolved');
|
||||
});
|
||||
|
||||
it('has FINDING_TYPE_PERMISSION_POSTURE constant', function (): void {
|
||||
expect(Finding::FINDING_TYPE_PERMISSION_POSTURE)->toBe('permission_posture');
|
||||
});
|
||||
|
||||
it('casts resolved_at as datetime', function (): void {
|
||||
$finding = Finding::factory()->permissionPosture()->resolved()->create();
|
||||
$fresh = Finding::query()->find($finding->getKey());
|
||||
|
||||
expect($fresh->resolved_at)->toBeInstanceOf(\Illuminate\Support\Carbon::class);
|
||||
});
|
||||
|
||||
it('creates permission posture findings via factory state', function (): void {
|
||||
$finding = Finding::factory()->permissionPosture()->create();
|
||||
|
||||
expect($finding->finding_type)->toBe(Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->and($finding->source)->toBe('permission_check')
|
||||
->and($finding->subject_type)->toBe('permission')
|
||||
->and($finding->severity)->toBe(Finding::SEVERITY_MEDIUM)
|
||||
->and($finding->evidence_jsonb)->toHaveKey('permission_key');
|
||||
});
|
||||
|
||||
it('creates resolved findings via factory state', function (): void {
|
||||
$finding = Finding::factory()->resolved()->create();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
});
|
||||
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GeneratePermissionPostureFindingsJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\PermissionPosture\FindingGeneratorContract;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function buildJobComparison(array $permissions = [], string $overallStatus = 'missing'): array
|
||||
{
|
||||
return [
|
||||
'overall_status' => $overallStatus,
|
||||
'permissions' => $permissions,
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
// (1) Successful run creates OperationRun with correct type and outcome
|
||||
it('creates OperationRun with correct type and outcome on success', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$comparison = buildJobComparison([
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
]);
|
||||
|
||||
$job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison);
|
||||
$job->handle(
|
||||
app(FindingGeneratorContract::class),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK)
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
});
|
||||
|
||||
// (2) Skips tenant without provider connection
|
||||
it('skips tenant without provider connection', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
// Ensure workspace is set
|
||||
$workspace = \App\Models\Workspace::factory()->create();
|
||||
$tenant->forceFill(['workspace_id' => $workspace->getKey()])->save();
|
||||
|
||||
// Explicitly delete any provider connections
|
||||
$tenant->providerConnections()->delete();
|
||||
|
||||
$comparison = buildJobComparison([
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
]);
|
||||
|
||||
$job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison);
|
||||
$job->handle(
|
||||
app(FindingGeneratorContract::class),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0)
|
||||
->and(StoredReport::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0)
|
||||
->and(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK)->count())->toBe(0);
|
||||
});
|
||||
|
||||
// (3) Records summary counts on OperationRun
|
||||
it('records summary counts on OperationRun', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$comparison = buildJobComparison([
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'granted', 'features' => ['b']],
|
||||
]);
|
||||
|
||||
$job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison);
|
||||
$job->handle(
|
||||
app(FindingGeneratorContract::class),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK)
|
||||
->first();
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
|
||||
expect($counts)->toHaveKey('findings_created')
|
||||
->and($counts['findings_created'])->toBe(1)
|
||||
->and($counts)->toHaveKey('posture_score')
|
||||
->and($counts['posture_score'])->toBe(50);
|
||||
});
|
||||
|
||||
// (4) Handles generator exceptions gracefully
|
||||
it('marks OperationRun as failed on exception', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$this->mock(FindingGeneratorContract::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('generate')->andThrow(new RuntimeException('Test error'));
|
||||
});
|
||||
|
||||
$comparison = buildJobComparison([
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
]);
|
||||
|
||||
$job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison);
|
||||
|
||||
try {
|
||||
$job->handle(
|
||||
app(FindingGeneratorContract::class),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK)
|
||||
->first();
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||
});
|
||||
|
||||
// (5) Dispatched from ProviderConnectionHealthCheckJob after successful compare
|
||||
it('dispatches posture job from health check job', function (): void {
|
||||
Queue::fake([GeneratePermissionPostureFindingsJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
// The actual dispatch is tested by verifying the hook exists in the source
|
||||
// (integration test will cover the full flow in T033)
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\User;
|
||||
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
|
||||
use App\Services\PermissionPosture\PostureResult;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function buildComparison(array $permissions, string $overallStatus = 'missing'): array
|
||||
{
|
||||
return [
|
||||
'overall_status' => $overallStatus,
|
||||
'permissions' => $permissions,
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
function missingPermission(string $key, array $features = ['policy-sync']): array
|
||||
{
|
||||
return ['key' => $key, 'type' => 'application', 'status' => 'missing', 'features' => $features];
|
||||
}
|
||||
|
||||
function grantedPermission(string $key, array $features = ['policy-sync']): array
|
||||
{
|
||||
return ['key' => $key, 'type' => 'application', 'status' => 'granted', 'features' => $features];
|
||||
}
|
||||
|
||||
function errorPermission(string $key, array $features = []): array
|
||||
{
|
||||
return ['key' => $key, 'type' => 'application', 'status' => 'error', 'features' => $features];
|
||||
}
|
||||
|
||||
// (1) Creates findings for missing permissions
|
||||
it('creates findings for missing permissions with correct attributes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
$comparison = buildComparison([
|
||||
missingPermission('DeviceManagementApps.ReadWrite.All', ['policy-sync', 'backup']),
|
||||
]);
|
||||
|
||||
$result = $generator->generate($tenant, $comparison);
|
||||
|
||||
expect($result)->toBeInstanceOf(PostureResult::class)
|
||||
->and($result->findingsCreated)->toBe(1);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->first();
|
||||
|
||||
expect($finding)->not->toBeNull()
|
||||
->and($finding->source)->toBe('permission_check')
|
||||
->and($finding->subject_type)->toBe('permission')
|
||||
->and($finding->subject_external_id)->toBe('DeviceManagementApps.ReadWrite.All')
|
||||
->and($finding->severity)->toBe(Finding::SEVERITY_HIGH) // 2 features → high
|
||||
->and($finding->status)->toBe(Finding::STATUS_NEW)
|
||||
->and($finding->evidence_jsonb['permission_key'])->toBe('DeviceManagementApps.ReadWrite.All')
|
||||
->and($finding->evidence_jsonb['actual_status'])->toBe('missing')
|
||||
->and($finding->fingerprint)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
// (2) Auto-resolves finding when permission granted
|
||||
it('auto-resolves finding when permission is granted', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
// First run: missing
|
||||
$generator->generate($tenant, buildComparison([
|
||||
missingPermission('Perm.A'),
|
||||
]));
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenant->getKey())->where('status', Finding::STATUS_NEW)->count())->toBe(1);
|
||||
|
||||
// Second run: granted
|
||||
$result = $generator->generate($tenant, buildComparison([
|
||||
grantedPermission('Perm.A'),
|
||||
], 'granted'));
|
||||
|
||||
expect($result->findingsResolved)->toBe(1);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->first();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
});
|
||||
|
||||
// (3) Auto-resolves acknowledged finding preserving metadata
|
||||
it('auto-resolves acknowledged finding preserving acknowledged metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
|
||||
|
||||
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
|
||||
$ackUser = User::factory()->create();
|
||||
$finding->acknowledge($ackUser);
|
||||
|
||||
$result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted'));
|
||||
|
||||
$finding->refresh();
|
||||
expect($result->findingsResolved)->toBe(1)
|
||||
->and($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->acknowledged_at)->not->toBeNull()
|
||||
->and($finding->acknowledged_by_user_id)->toBe($ackUser->getKey());
|
||||
});
|
||||
|
||||
// (4) No duplicates on idempotent run
|
||||
it('does not create duplicates on idempotent run', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$comparison = buildComparison([missingPermission('Perm.A')]);
|
||||
|
||||
$result1 = $generator->generate($tenant, $comparison);
|
||||
$result2 = $generator->generate($tenant, $comparison);
|
||||
|
||||
expect($result1->findingsCreated)->toBe(1)
|
||||
->and($result2->findingsCreated)->toBe(0)
|
||||
->and($result2->findingsUnchanged)->toBe(1);
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(1);
|
||||
});
|
||||
|
||||
// (5) Re-opens resolved finding when permission revoked again
|
||||
it('re-opens resolved finding when permission is revoked again', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
// Missing → resolve → missing again
|
||||
$generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
|
||||
$generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted'));
|
||||
|
||||
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED);
|
||||
|
||||
$result = $generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
|
||||
|
||||
$finding->refresh();
|
||||
expect($result->findingsReopened)->toBe(1)
|
||||
->and($finding->status)->toBe(Finding::STATUS_NEW)
|
||||
->and($finding->resolved_at)->toBeNull()
|
||||
->and($finding->resolved_reason)->toBeNull();
|
||||
});
|
||||
|
||||
// (6) Creates error finding for status=error permissions
|
||||
it('creates error finding for status=error permissions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$result = $generator->generate($tenant, buildComparison([
|
||||
errorPermission('Perm.Error'),
|
||||
]));
|
||||
|
||||
expect($result->errorsRecorded)->toBe(1);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->first();
|
||||
|
||||
expect($finding)->not->toBeNull()
|
||||
->and($finding->severity)->toBe(Finding::SEVERITY_LOW)
|
||||
->and($finding->evidence_jsonb['check_error'])->toBeTrue()
|
||||
->and($finding->evidence_jsonb['actual_status'])->toBe('error');
|
||||
|
||||
// Distinct fingerprint from missing findings
|
||||
$generator->generate($tenant, buildComparison([
|
||||
missingPermission('Perm.Error'),
|
||||
]));
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2);
|
||||
});
|
||||
|
||||
// (7) Severity derivation
|
||||
it('derives severity from feature count', function (int $featureCount, string $expectedSeverity): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$features = array_map(fn (int $i): string => "feature-$i", range(1, max(1, $featureCount)));
|
||||
if ($featureCount === 0) {
|
||||
$features = [];
|
||||
}
|
||||
|
||||
$generator->generate($tenant, buildComparison([
|
||||
missingPermission('Perm.Sev', $features),
|
||||
]));
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->whereNull('resolved_at')
|
||||
->first();
|
||||
|
||||
expect($finding->severity)->toBe($expectedSeverity);
|
||||
})->with([
|
||||
'0 features → low' => [0, Finding::SEVERITY_LOW],
|
||||
'1 feature → medium' => [1, Finding::SEVERITY_MEDIUM],
|
||||
'2 features → high' => [2, Finding::SEVERITY_HIGH],
|
||||
'3+ features → critical' => [3, Finding::SEVERITY_CRITICAL],
|
||||
'5 features → critical' => [5, Finding::SEVERITY_CRITICAL],
|
||||
]);
|
||||
|
||||
// (8) Creates StoredReport with correct payload
|
||||
it('creates StoredReport with correct payload', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$comparison = buildComparison([
|
||||
grantedPermission('Perm.A', ['a', 'b']),
|
||||
missingPermission('Perm.B', ['c']),
|
||||
]);
|
||||
|
||||
$result = $generator->generate($tenant, $comparison);
|
||||
|
||||
$report = StoredReport::query()->find($result->storedReportId);
|
||||
|
||||
expect($report)->not->toBeNull()
|
||||
->and($report->report_type)->toBe(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->and($report->tenant_id)->toBe($tenant->getKey())
|
||||
->and($report->payload['posture_score'])->toBe(50)
|
||||
->and($report->payload['required_count'])->toBe(2)
|
||||
->and($report->payload['granted_count'])->toBe(1)
|
||||
->and($report->payload)->toHaveKey('checked_at')
|
||||
->and($report->payload['permissions'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
// (9) No findings when all granted
|
||||
it('resolves open findings when all permissions are granted', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
|
||||
expect(Finding::query()->where('tenant_id', $tenant->getKey())->where('status', Finding::STATUS_NEW)->count())->toBe(1);
|
||||
|
||||
$result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted'));
|
||||
|
||||
expect($result->findingsResolved)->toBe(1)
|
||||
->and($result->findingsCreated)->toBe(0);
|
||||
});
|
||||
|
||||
// (10) Produces alert events for new and reopened findings only
|
||||
it('produces alert events for new and reopened findings only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
// Run 1: create new finding → should produce alert event
|
||||
$generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
|
||||
$events = $generator->getAlertEvents();
|
||||
|
||||
expect($events)->toHaveCount(1)
|
||||
->and($events[0]['event_type'])->toBe('permission_missing');
|
||||
});
|
||||
|
||||
// (11) Generator reads permissions from comparison, not hardcoded
|
||||
it('processes arbitrary permission keys from comparison', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$generator->generate($tenant, buildComparison([
|
||||
missingPermission('CustomPerm.ReadWrite.All', ['feature-x']),
|
||||
]));
|
||||
|
||||
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
|
||||
expect($finding->subject_external_id)->toBe('CustomPerm.ReadWrite.All');
|
||||
});
|
||||
|
||||
// (12) All findings are scoped to tenant_id
|
||||
it('scopes all findings to tenant_id', function (): void {
|
||||
[$user1, $tenant1] = createUserWithTenant();
|
||||
[$user2, $tenant2] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$generator->generate($tenant1, buildComparison([missingPermission('Perm.A')]));
|
||||
$generator->generate($tenant2, buildComparison([missingPermission('Perm.A')]));
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenant1->getKey())->count())->toBe(1)
|
||||
->and(Finding::query()->where('tenant_id', $tenant2->getKey())->count())->toBe(1);
|
||||
});
|
||||
|
||||
// (13) subject_type and subject_external_id set correctly
|
||||
it('sets subject_type=permission and subject_external_id on all posture findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
$generator->generate($tenant, buildComparison([
|
||||
missingPermission('DeviceManagementApps.ReadWrite.All'),
|
||||
missingPermission('DeviceManagementConfiguration.ReadWrite.All'),
|
||||
]));
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->get();
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
expect($finding->subject_type)->toBe('permission')
|
||||
->and($finding->subject_external_id)->not->toBeEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
// (14) Stale findings auto-resolved for registry removals
|
||||
it('auto-resolves stale findings for permissions removed from registry', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$generator = app(PermissionPostureFindingGenerator::class);
|
||||
|
||||
// Run 1: Perm.A and Perm.B are missing
|
||||
$generator->generate($tenant, buildComparison([
|
||||
missingPermission('Perm.A'),
|
||||
missingPermission('Perm.B'),
|
||||
]));
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenant->getKey())->where('status', Finding::STATUS_NEW)->count())->toBe(2);
|
||||
|
||||
// Run 2: Perm.B is no longer in the registry (only Perm.A remains)
|
||||
$result = $generator->generate($tenant, buildComparison([
|
||||
missingPermission('Perm.A'),
|
||||
]));
|
||||
|
||||
expect($result->findingsResolved)->toBeGreaterThanOrEqual(1);
|
||||
|
||||
$stale = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->whereJsonContains('evidence_jsonb->permission_key', 'Perm.B')
|
||||
->first();
|
||||
|
||||
expect($stale->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($stale->resolved_reason)->toBe('permission_removed_from_registry');
|
||||
});
|
||||
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GeneratePermissionPostureFindingsJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Services\PermissionPosture\FindingGeneratorContract;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('full posture check flow: findings, report, score, operations', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$comparison = [
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'type' => 'application', 'status' => 'missing', 'features' => ['backup', 'restore']],
|
||||
['key' => 'DeviceManagementApps.ReadWrite.All', 'type' => 'application', 'status' => 'granted', 'features' => ['app_management']],
|
||||
['key' => 'DeviceManagementManagedDevices.ReadWrite.All', 'type' => 'application', 'status' => 'missing', 'features' => ['compliance']],
|
||||
['key' => 'Group.Read.All', 'type' => 'application', 'status' => 'granted', 'features' => ['assignments']],
|
||||
['key' => 'User.Read', 'type' => 'delegated', 'status' => 'granted', 'features' => []],
|
||||
],
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
$job = new GeneratePermissionPostureFindingsJob($tenant->getKey(), $comparison);
|
||||
$job->handle(
|
||||
app(FindingGeneratorContract::class),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
$elapsed = (microtime(true) - $start) * 1000;
|
||||
|
||||
// --- Timing assertion ---
|
||||
expect($elapsed)->toBeLessThan(5000);
|
||||
|
||||
// --- Findings ---
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->get();
|
||||
|
||||
$missingFindings = $findings->where('status', Finding::STATUS_NEW);
|
||||
expect($missingFindings)->toHaveCount(2); // 2 missing permissions
|
||||
|
||||
// --- StoredReport ---
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->first();
|
||||
|
||||
expect($report)->not->toBeNull()
|
||||
->and($report->payload['posture_score'])->toBe(60) // 3/5 = 60%
|
||||
->and($report->payload['required_count'])->toBe(5)
|
||||
->and($report->payload['granted_count'])->toBe(3)
|
||||
->and($report->payload['permissions'])->toHaveCount(5);
|
||||
|
||||
// --- OperationRun ---
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', OperationCatalog::TYPE_PERMISSION_POSTURE_CHECK)
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and($run->summary_counts['findings_created'])->toBe(2)
|
||||
->and($run->summary_counts['posture_score'])->toBe(60);
|
||||
});
|
||||
|
||||
it('second run auto-resolves findings for newly granted permissions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = app(FindingGeneratorContract::class);
|
||||
|
||||
// First run: 2 missing permissions
|
||||
$comparison1 = [
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'missing', 'features' => ['b']],
|
||||
['key' => 'Perm.C', 'type' => 'application', 'status' => 'granted', 'features' => ['c']],
|
||||
],
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$result1 = $generator->generate($tenant, $comparison1);
|
||||
expect($result1->findingsCreated)->toBe(2)
|
||||
->and($result1->postureScore)->toBe(33);
|
||||
|
||||
// Second run: Perm.A now granted
|
||||
$comparison2 = [
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'granted', 'features' => ['a']],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'missing', 'features' => ['b']],
|
||||
['key' => 'Perm.C', 'type' => 'application', 'status' => 'granted', 'features' => ['c']],
|
||||
],
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$result2 = $generator->generate($tenant, $comparison2);
|
||||
expect($result2->findingsResolved)->toBe(1)
|
||||
->and($result2->findingsUnchanged)->toBe(1)
|
||||
->and($result2->postureScore)->toBe(67);
|
||||
|
||||
// Verify the resolved finding
|
||||
$resolvedFinding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->first();
|
||||
|
||||
expect($resolvedFinding)->not->toBeNull()
|
||||
->and($resolvedFinding->resolved_reason)->toBe('permission_granted');
|
||||
});
|
||||
|
||||
it('alert events are produced for new missing permission findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = app(FindingGeneratorContract::class);
|
||||
|
||||
$comparison = [
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.Dangerous', 'type' => 'application', 'status' => 'missing', 'features' => ['backup', 'restore', 'sync']],
|
||||
],
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$result = $generator->generate($tenant, $comparison);
|
||||
expect($result->findingsCreated)->toBe(1);
|
||||
|
||||
// Verify the finding was created and has proper data for alert pipeline
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->first();
|
||||
|
||||
expect($finding)->not->toBeNull()
|
||||
->and($finding->evidence_jsonb)->toHaveKey('permission_key')
|
||||
->and($finding->evidence_jsonb['permission_key'])->toBe('Perm.Dangerous');
|
||||
});
|
||||
153
tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php
Normal file
153
tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\PermissionPosture\PostureScoreCalculator;
|
||||
|
||||
it('returns 100 when all permissions are granted', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'granted',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'granted', 'features' => ['a']],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'granted', 'features' => ['b']],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(100);
|
||||
});
|
||||
|
||||
it('returns 86 for 12 of 14 granted', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$permissions = [];
|
||||
for ($i = 0; $i < 12; $i++) {
|
||||
$permissions[] = ['key' => "Perm.$i", 'type' => 'application', 'status' => 'granted', 'features' => []];
|
||||
}
|
||||
for ($i = 12; $i < 14; $i++) {
|
||||
$permissions[] = ['key' => "Perm.$i", 'type' => 'application', 'status' => 'missing', 'features' => []];
|
||||
}
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
expect($result)->toBe(86);
|
||||
});
|
||||
|
||||
it('returns 0 when all permissions are missing', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'missing', 'features' => ['b']],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(0);
|
||||
});
|
||||
|
||||
it('returns 100 when 0 permissions required', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'granted',
|
||||
'permissions' => [],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(100);
|
||||
});
|
||||
|
||||
it('returns 100 for single granted permission', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'granted',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'granted', 'features' => ['a']],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 for single missing permission', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(0);
|
||||
});
|
||||
|
||||
it('rounds correctly for 1 of 3 granted (33)', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'A', 'type' => 'application', 'status' => 'granted', 'features' => []],
|
||||
['key' => 'B', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
['key' => 'C', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(33);
|
||||
});
|
||||
|
||||
it('rounds correctly for 2 of 3 granted (67)', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'A', 'type' => 'application', 'status' => 'granted', 'features' => []],
|
||||
['key' => 'B', 'type' => 'application', 'status' => 'granted', 'features' => []],
|
||||
['key' => 'C', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBe(67);
|
||||
});
|
||||
|
||||
it('returns integer not float', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'A', 'type' => 'application', 'status' => 'granted', 'features' => []],
|
||||
['key' => 'B', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
['key' => 'C', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result)->toBeInt();
|
||||
});
|
||||
|
||||
it('returns 50 for 7 of 14 granted', function (): void {
|
||||
$calculator = new PostureScoreCalculator;
|
||||
|
||||
$permissions = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$permissions[] = ['key' => "Perm.$i", 'type' => 'application', 'status' => 'granted', 'features' => []];
|
||||
}
|
||||
for ($i = 7; $i < 14; $i++) {
|
||||
$permissions[] = ['key' => "Perm.$i", 'type' => 'application', 'status' => 'missing', 'features' => []];
|
||||
}
|
||||
|
||||
$result = $calculator->calculate([
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
expect($result)->toBe(50);
|
||||
});
|
||||
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('deletes reports older than retention threshold', function (): void {
|
||||
$old = StoredReport::factory()->create([
|
||||
'created_at' => now()->subDays(100),
|
||||
]);
|
||||
|
||||
$recent = StoredReport::factory()->create([
|
||||
'created_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$this->artisan('stored-reports:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(StoredReport::query()->whereKey($old->getKey())->exists())->toBeFalse()
|
||||
->and(StoredReport::query()->whereKey($recent->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves reports within retention threshold', function (): void {
|
||||
$report = StoredReport::factory()->create([
|
||||
'created_at' => now()->subDays(89),
|
||||
]);
|
||||
|
||||
$this->artisan('stored-reports:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(StoredReport::query()->whereKey($report->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('custom --days flag overrides config default', function (): void {
|
||||
$report30daysOld = StoredReport::factory()->create([
|
||||
'created_at' => now()->subDays(35),
|
||||
]);
|
||||
|
||||
$report10daysOld = StoredReport::factory()->create([
|
||||
'created_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$this->artisan('stored-reports:prune --days=30')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(StoredReport::query()->whereKey($report30daysOld->getKey())->exists())->toBeFalse()
|
||||
->and(StoredReport::query()->whereKey($report10daysOld->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('outputs the count of deleted records', function (): void {
|
||||
StoredReport::factory()->count(3)->create([
|
||||
'created_at' => now()->subDays(200),
|
||||
]);
|
||||
|
||||
$this->artisan('stored-reports:prune')
|
||||
->expectsOutputToContain('Deleted 3 stored report(s)')
|
||||
->assertSuccessful();
|
||||
});
|
||||
203
tests/Feature/PermissionPosture/StoredReportModelTest.php
Normal file
203
tests/Feature/PermissionPosture/StoredReportModelTest.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\PermissionPosture\FindingGeneratorContract;
|
||||
use App\Services\PermissionPosture\PostureScoreCalculator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates a stored report with factory defaults', function (): void {
|
||||
$report = StoredReport::factory()->create();
|
||||
|
||||
expect($report)->toBeInstanceOf(StoredReport::class)
|
||||
->and($report->report_type)->toBe(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->and($report->payload)->toBeArray()
|
||||
->and($report->payload)->toHaveKeys(['posture_score', 'required_count', 'granted_count', 'checked_at', 'permissions']);
|
||||
});
|
||||
|
||||
it('casts payload to array', function (): void {
|
||||
$report = StoredReport::factory()->create();
|
||||
|
||||
$fresh = StoredReport::query()->find($report->getKey());
|
||||
|
||||
expect($fresh->payload)->toBeArray()
|
||||
->and($fresh->payload['posture_score'])->toBe(86);
|
||||
});
|
||||
|
||||
it('belongs to a tenant', function (): void {
|
||||
$report = StoredReport::factory()->create();
|
||||
|
||||
expect($report->tenant)->toBeInstanceOf(Tenant::class)
|
||||
->and($report->tenant->getKey())->toBe($report->tenant_id);
|
||||
});
|
||||
|
||||
it('belongs to a workspace via DerivesWorkspaceIdFromTenant', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$report = StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
]);
|
||||
|
||||
expect($report->workspace_id)->toBe($tenant->workspace_id);
|
||||
});
|
||||
|
||||
it('has the correct REPORT_TYPE_PERMISSION_POSTURE constant', function (): void {
|
||||
expect(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)->toBe('permission_posture');
|
||||
});
|
||||
|
||||
// --- T024: Generator integration scenarios ---
|
||||
|
||||
it('generator creates report with correct report_type and payload schema', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$generator = app(FindingGeneratorContract::class);
|
||||
|
||||
$comparison = [
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'granted', 'features' => ['backup']],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'missing', 'features' => ['restore']],
|
||||
['key' => 'Perm.C', 'type' => 'delegated', 'status' => 'granted', 'features' => ['sync']],
|
||||
],
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$generator->generate($tenant, $comparison);
|
||||
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->first();
|
||||
|
||||
expect($report)->not->toBeNull()
|
||||
->and($report->report_type)->toBe(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->and($report->payload)->toHaveKeys(['posture_score', 'required_count', 'granted_count', 'checked_at', 'permissions'])
|
||||
->and($report->payload['required_count'])->toBe(3)
|
||||
->and($report->payload['granted_count'])->toBe(2)
|
||||
->and($report->payload['permissions'])->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('generator report posture_score matches PostureScoreCalculator output', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$comparison = [
|
||||
'overall_status' => 'missing',
|
||||
'permissions' => [
|
||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'granted', 'features' => []],
|
||||
['key' => 'Perm.B', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
['key' => 'Perm.C', 'type' => 'application', 'status' => 'missing', 'features' => []],
|
||||
],
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$expectedScore = (new PostureScoreCalculator)->calculate($comparison);
|
||||
|
||||
$generator = app(FindingGeneratorContract::class);
|
||||
$result = $generator->generate($tenant, $comparison);
|
||||
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->first();
|
||||
|
||||
expect($report->payload['posture_score'])->toBe($expectedScore)
|
||||
->and($result->postureScore)->toBe($expectedScore)
|
||||
->and($expectedScore)->toBe(33);
|
||||
});
|
||||
|
||||
// --- T025: Temporal ordering ---
|
||||
|
||||
it('multiple posture reports for same tenant ordered by created_at descending', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
// Create reports at different timestamps
|
||||
$first = StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'created_at' => now()->subHours(3),
|
||||
]);
|
||||
|
||||
$second = StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'created_at' => now()->subHours(1),
|
||||
]);
|
||||
|
||||
$third = StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$results = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->orderByDesc('created_at')
|
||||
->pluck('id');
|
||||
|
||||
expect($results->count())->toBe(3)
|
||||
->and($results[0])->toBe($third->getKey())
|
||||
->and($results[1])->toBe($second->getKey())
|
||||
->and($results[2])->toBe($first->getKey());
|
||||
});
|
||||
|
||||
it('reports queryable by tenant_id and report_type', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant();
|
||||
[$user2, $tenantB] = createUserWithTenant();
|
||||
|
||||
StoredReport::factory()->count(2)->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
]);
|
||||
|
||||
$tenantAReports = StoredReport::query()
|
||||
->where('tenant_id', $tenantA->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->count();
|
||||
|
||||
expect($tenantAReports)->toBe(2);
|
||||
});
|
||||
|
||||
// --- T026: Polymorphic reusability ---
|
||||
|
||||
it('different report_type coexists with permission_posture reports', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
]);
|
||||
|
||||
StoredReport::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => 'compliance_summary',
|
||||
'payload' => ['compliant' => 5, 'noncompliant' => 2],
|
||||
]);
|
||||
|
||||
$postureReports = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->count();
|
||||
|
||||
$complianceReports = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('report_type', 'compliance_summary')
|
||||
->count();
|
||||
|
||||
$allReports = StoredReport::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
expect($postureReports)->toBe(1)
|
||||
->and($complianceReports)->toBe(1)
|
||||
->and($allReports)->toBe(2);
|
||||
});
|
||||
53
tests/Feature/Support/Badges/FindingBadgeTest.php
Normal file
53
tests/Feature/Support/Badges/FindingBadgeTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders resolved status badge with success color', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_RESOLVED);
|
||||
|
||||
expect($spec->label)->toBe('Resolved')
|
||||
->and($spec->color)->toBe('success')
|
||||
->and($spec->icon)->toBe('heroicon-o-check-circle');
|
||||
});
|
||||
|
||||
it('still renders new status badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_NEW);
|
||||
|
||||
expect($spec->label)->toBe('New')
|
||||
->and($spec->color)->toBe('warning');
|
||||
});
|
||||
|
||||
it('still renders acknowledged status badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED);
|
||||
|
||||
expect($spec->label)->toBe('Acknowledged')
|
||||
->and($spec->color)->toBe('gray');
|
||||
});
|
||||
|
||||
it('renders permission_posture finding type badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingType, Finding::FINDING_TYPE_PERMISSION_POSTURE);
|
||||
|
||||
expect($spec->label)->toBe('Permission posture')
|
||||
->and($spec->color)->toBe('warning')
|
||||
->and($spec->icon)->toBe('heroicon-m-shield-exclamation');
|
||||
});
|
||||
|
||||
it('renders drift finding type badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingType, Finding::FINDING_TYPE_DRIFT);
|
||||
|
||||
expect($spec->label)->toBe('Drift')
|
||||
->and($spec->color)->toBe('info');
|
||||
});
|
||||
|
||||
it('renders unknown for unrecognized finding type', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingType, 'nonexistent_type');
|
||||
|
||||
expect($spec->label)->toBe('Unknown');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user