feat(104): Provider Permission Posture #127

Merged
ahmido merged 2 commits from 104-provider-permission-posture into dev 2026-02-21 22:32:54 +00:00
46 changed files with 3354 additions and 20 deletions
Showing only changes of commit 222a7e0a97 - Show all commits

View File

@ -33,6 +33,8 @@ ## Active Technologies
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1) - 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) - 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) - 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -52,8 +54,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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 - 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 START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View 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;
}
}

View File

@ -5,6 +5,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
@ -169,6 +170,12 @@ private function refreshViewModel(): void
public function reRunVerificationUrl(): string public function reRunVerificationUrl(): string
{ {
$tenant = $this->scopedTenant;
if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]);
}
return route('admin.onboarding'); return route('admin.onboarding');
} }

View File

@ -379,6 +379,7 @@ public static function eventTypeOptions(): array
AlertRule::EVENT_HIGH_DRIFT => 'High drift', AlertRule::EVENT_HIGH_DRIFT => 'High drift',
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed', AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
AlertRule::EVENT_SLA_DUE => 'SLA due', AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
]; ];
} }

View File

@ -152,6 +152,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Diff') Section::make('Diff')
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
->schema([ ->schema([
ViewEntry::make('settings_diff') ViewEntry::make('settings_diff')
->label('') ->label('')

View File

@ -4,6 +4,7 @@
namespace App\Jobs\Alerts; namespace App\Jobs\Alerts;
use App\Models\AlertRule;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Workspace; use App\Models\Workspace;
@ -58,6 +59,7 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
$events = [ $events = [
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart), ...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart), ...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
]; ];
$createdDeliveries = 0; $createdDeliveries = 0;
@ -253,4 +255,42 @@ private function sanitizeErrorMessage(Throwable $exception): string
return mb_substr($message, 0, 500); 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;
}
} }

View 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;
}
}
}

View File

@ -16,8 +16,8 @@
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\TenantPermissionCheckClusters; use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter; use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable; 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) { if ($result->healthy) {
$run = $runs->updateRun( $run = $runs->updateRun(
$this->operationRun, $this->operationRun,

View File

@ -20,6 +20,8 @@ class AlertRule extends Model
public const string EVENT_SLA_DUE = 'sla_due'; 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_ALL = 'all';
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist'; public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';

View File

@ -16,6 +16,8 @@ class Finding extends Model
public const string FINDING_TYPE_DRIFT = 'drift'; 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_LOW = 'low';
public const string SEVERITY_MEDIUM = 'medium'; public const string SEVERITY_MEDIUM = 'medium';
@ -28,11 +30,14 @@ class Finding extends Model
public const string STATUS_ACKNOWLEDGED = 'acknowledged'; public const string STATUS_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_RESOLVED = 'resolved';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'acknowledged_at' => 'datetime', 'acknowledged_at' => 'datetime',
'evidence_jsonb' => 'array', 'evidence_jsonb' => 'array',
'resolved_at' => 'datetime',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
@ -69,4 +74,27 @@ public function acknowledge(User $user): void
$this->save(); $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();
}
} }

View 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);
}
}

View File

@ -33,6 +33,8 @@
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer;
use App\Services\PermissionPosture\FindingGeneratorContract;
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway; use App\Services\Providers\ProviderGateway;
@ -52,6 +54,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
$this->app->singleton(GraphClientInterface::class, function ($app) { $this->app->singleton(GraphClientInterface::class, function ($app) {
$config = $app['config']->get('graph'); $config = $app['config']->get('graph');

View 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;
}

View File

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

View 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,
) {}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\PermissionPosture;
/**
* Calculates a normalized posture score (0100) 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);
}
}

View File

@ -40,6 +40,7 @@ final class BadgeCatalog
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class, BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class, BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
]; ];
/** /**

View File

@ -32,4 +32,5 @@ enum BadgeDomain: string
case AlertDeliveryStatus = 'alert_delivery_status'; case AlertDeliveryStatus = 'alert_delivery_status';
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status'; case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
case BaselineProfileStatus = 'baseline_profile_status'; case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type';
} }

View File

@ -16,6 +16,7 @@ public function spec(mixed $value): BadgeSpec
return match ($state) { return match ($state) {
Finding::STATUS_NEW => new BadgeSpec('New', 'warning', 'heroicon-m-clock'), Finding::STATUS_NEW => new BadgeSpec('New', 'warning', 'heroicon-m-clock'),
Finding::STATUS_ACKNOWLEDGED => new BadgeSpec('Acknowledged', 'gray', 'heroicon-m-check-circle'), 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(), default => BadgeSpec::unknown(),
}; };
} }

View 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(),
};
}
}

View File

@ -6,6 +6,8 @@
final class OperationCatalog final class OperationCatalog
{ {
public const string TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check';
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
@ -47,6 +49,7 @@ public static function labels(): array
'alerts.deliver' => 'Alerts delivery', 'alerts.deliver' => 'Alerts delivery',
'baseline_capture' => 'Baseline capture', 'baseline_capture' => 'Baseline capture',
'baseline_compare' => 'Baseline compare', '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, 'alerts.evaluate', 'alerts.deliver' => 120,
'baseline_capture' => 120, 'baseline_capture' => 120,
'baseline_compare' => 120, 'baseline_compare' => 120,
'permission_posture_check' => 30,
default => null, default => null,
}; };
} }

View File

@ -27,6 +27,12 @@ public static function all(): array
'high', 'high',
'medium', 'medium',
'low', 'low',
'findings_created',
'findings_resolved',
'findings_reopened',
'findings_unchanged',
'errors_recorded',
'posture_score',
]; ];
} }
} }

View File

@ -349,6 +349,10 @@
'http_timeout_seconds' => (int) env('TENANTPILOT_ALERTS_HTTP_TIMEOUT_SECONDS', 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' => [ 'display' => [
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),

View File

@ -34,4 +34,38 @@ public function definition(): array
'evidence_jsonb' => [], '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',
]);
}
} }

View 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'],
],
],
],
];
}
}

View File

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

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 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']);
});
}
};

View File

@ -457,7 +457,13 @@ class="text-primary-600 hover:underline dark:text-primary-400"
</x-filament::section> </x-filament::section>
<x-filament::section heading="Technical details"> <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"> <summary class="cursor-pointer list-none text-sm font-semibold text-gray-900 dark:text-white">
Expand technical details Expand technical details
</summary> </summary>

View File

@ -26,3 +26,8 @@
->everyThirtyMinutes() ->everyThirtyMinutes()
->name(ReconcileAdapterRunsJob::class) ->name(ReconcileAdapterRunsJob::class)
->withoutOverlapping(); ->withoutOverlapping();
Schedule::command('stored-reports:prune')
->daily()
->name('stored-reports:prune')
->withoutOverlapping();

View File

@ -184,7 +184,7 @@ ### Functional Requirements
### Canonical allowed summary keys (single source of truth) ### Canonical allowed summary keys (single source of truth)
The following keys are the ONLY allowed summary keys for Ops-UX rendering: 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). All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).

View File

@ -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 0100 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`.

View 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)
```

View 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).

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

View 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 L116L131 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 L95L142 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

View File

@ -1,19 +1,19 @@
# Feature Specification: Provider Permission Posture # Feature Specification: Provider Permission Posture
**Feature Branch**: `104-provider-permission-posture` **Feature Branch**: `104-provider-permission-posture`
**Created**: 2026-02-26 **Created**: 2026-02-21
**Status**: Draft **Status**: Draft
**Input**: User description: "Provider Permission Posture - StoredReports foundation, Permission Posture Findings generation, and Alerts integration for measured app permissions" **Input**: User description: "Provider Permission Posture - StoredReports foundation, Permission Posture Findings generation, and Alerts integration for measured app permissions"
## Spec Scope Fields *(mandatory)* ## Spec Scope Fields *(mandatory)*
- **Scope**: tenant (per-tenant posture assessment) + workspace (stored reports and alerts extend workspace-level infrastructure) - **Scope**: tenant (per-tenant posture assessment) + workspace (alerts extend workspace-level infrastructure)
- **Primary Routes**: - **Primary Routes**:
- No new Filament pages in this spec; findings and stored reports are backend data consumed by existing Findings/Alerts UI - 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 > Findings (extended with `finding_type=permission_posture`)
- Existing: Monitoring > Alerts (extended with new `EVENT_PERMISSION_MISSING` event type) - Existing: Monitoring > Alerts (extended with new `EVENT_PERMISSION_MISSING` event type)
- **Data Ownership**: - **Data Ownership**:
- `stored_reports` -- workspace-owned, generic report storage (new table) - `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) - `findings` -- tenant-owned, extended with `finding_type=permission_posture` (existing table)
- `tenant_permissions` -- tenant-owned, source of truth for measured permission state (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) - `alert_rules` -- workspace-owned, extended with `EVENT_PERMISSION_MISSING` trigger (existing table)
@ -45,9 +45,11 @@ ### User Story 1 - Generate permission posture findings (Priority: P1)
**Acceptance Scenarios**: **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. 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 from `new` to `resolved`). 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. 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** 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). 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).
--- ---
@ -101,11 +103,11 @@ ### User Story 4 - Posture score calculation (Priority: P2)
### Edge Cases ### Edge Cases
- **Graph API unreachable during posture check**: If TenantPermissionService returns an error state for a permission, the posture generator records the permission as `status=error` in evidence but does NOT create a missing-permission finding for that key. It generates a separate `permission_check_error` finding instead. - **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. - **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. - **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. - **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, not as a blocking batch operation. - **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)* ## Requirements *(mandatory)*
@ -123,7 +125,7 @@ ## Requirements *(mandatory)*
- **Capability registry**: Uses `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE` -- all existing. - **Capability registry**: Uses `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE` -- all existing.
- **Global search**: No new globally searchable resources. - **Global search**: No new globally searchable resources.
- **Destructive actions**: None. Posture findings are system-generated, not user-deletable. - **Destructive actions**: None. Posture findings are system-generated, not user-deletable.
- **Authorization tests**: Positive test: user with FINDINGS_VIEW can see posture findings. Negative test: user without tenant entitlement receives 404. - **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 (OPS-EX-AUTH-001):** Not applicable -- no OIDC/SAML login flows involved.
@ -142,7 +144,7 @@ ### 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-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-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-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. - **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: - **FR-005 (Severity Derivation)**: Finding severity MUST be derived from feature impact:
- Permission blocks 3+ features results in `critical` - Permission blocks 3+ features results in `critical`
- Permission blocks 2 features results in `high` - Permission blocks 2 features results in `high`
@ -150,14 +152,14 @@ ### Functional Requirements
- Permission blocks 0 features results in `low` - 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-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-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`. - **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. - **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 updated, 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. - **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-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) to avoid blocking user-facing requests. - **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-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-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 separate finding with evidence indicating the check failed. - **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-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-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. - **FR-018 (Retention)**: Stored reports MUST support configurable retention. Default: 90 days.
@ -170,10 +172,19 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
### Key Entities *(include if feature involves data)* ### Key Entities *(include if feature involves data)*
- **StoredReport**: A generic, workspace-scoped report record. Polymorphic `report_type` distinguishes domain (e.g., `permission_posture`). JSONB `payload` holds the full report data. Associated with a `tenant_id` and `workspace_id`. Supports temporal queries (ordered by `created_at`). - **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`. Supports `resolved` status for auto-closed findings. - **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. - **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)* ## Success Criteria *(mandatory)*
### Measurable Outcomes ### Measurable Outcomes

View 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

View 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([]);
});

View 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');
});

View File

@ -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();
});

View File

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

View File

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

View 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);
});

View File

@ -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();
});

View 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);
});

View 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');
});