TenantAtlas/apps/platform/app/Models/Finding.php
ahmido 421261a517
Some checks failed
Main Confidence / confidence (push) Failing after 48s
feat: implement finding outcome taxonomy (#267)
## Summary
- implement the finding outcome taxonomy end-to-end with canonical resolve, close, reopen, and verification semantics
- align finding UI, filters, audit metadata, review summaries, and export/read-model consumers to the shared outcome semantics
- add focused Pest coverage and complete the spec artifacts for feature 231

## Details
- manual resolve is limited to the canonical `remediated` outcome
- close and reopen flows now use bounded canonical reasons
- trusted system clear and reopen distinguish verified-clear from verification-failed and recurrence paths
- duplicate lifecycle backfill now closes findings canonically as `duplicate`
- accepted-risk recording now uses the canonical `accepted_risk` reason
- finding detail and list surfaces now expose terminal outcome and verification summaries
- review, snapshot, and review-pack consumers now propagate the same outcome buckets

## Filament / Platform Contract
- Livewire v4.0+ compatibility remains intact
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new globally searchable resource was introduced; `FindingResource` still has a View page and `TenantReviewResource` remains globally searchable false
- lifecycle mutations still run through confirmed Filament actions with capability enforcement
- no new asset family was added; the existing `filament:assets` deploy step is unchanged

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Models/FindingResolvedTest.php tests/Unit/Findings/FindingWorkflowServiceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`
- browser smoke: `/admin/findings/my-work` -> finding detail resolve flow -> queue regression check passed

## Notes
- this commit also includes the existing `.github/agents/copilot-instructions.md` workspace change that was already present in the worktree when all changes were committed

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #267
2026-04-23 07:29:05 +00:00

475 lines
13 KiB
PHP

<?php
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr;
class Finding extends Model
{
/** @use HasFactory<\Database\Factories\FindingFactory> */
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string FINDING_TYPE_DRIFT = 'drift';
public const string FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
public const string FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles';
public const string SEVERITY_LOW = 'low';
public const string SEVERITY_MEDIUM = 'medium';
public const string SEVERITY_HIGH = 'high';
public const string SEVERITY_CRITICAL = 'critical';
public const string STATUS_NEW = 'new';
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_TRIAGED = 'triaged';
public const string STATUS_IN_PROGRESS = 'in_progress';
public const string STATUS_REOPENED = 'reopened';
public const string STATUS_RESOLVED = 'resolved';
public const string STATUS_CLOSED = 'closed';
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
public const string RESOLVE_REASON_REMEDIATED = 'remediated';
public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting';
public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted';
public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry';
public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed';
public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold';
public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive';
public const string CLOSE_REASON_DUPLICATE = 'duplicate';
public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable';
public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk';
public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution';
public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed';
public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment';
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
public const string RESPONSIBILITY_STATE_ASSIGNED = 'assigned';
protected $guarded = [];
protected $casts = [
'acknowledged_at' => 'datetime',
'closed_at' => 'datetime',
'due_at' => 'datetime',
'evidence_jsonb' => 'array',
'first_seen_at' => 'datetime',
'in_progress_at' => 'datetime',
'last_seen_at' => 'datetime',
'reopened_at' => 'datetime',
'resolved_at' => 'datetime',
'sla_days' => 'integer',
'times_seen' => 'integer',
'triaged_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function baselineRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id');
}
public function currentRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class, 'current_operation_run_id');
}
public function acknowledgedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
}
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function assigneeUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_user_id');
}
public function closedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'closed_by_user_id');
}
/**
* @return HasOne<FindingException, $this>
*/
public function findingException(): HasOne
{
return $this->hasOne(FindingException::class);
}
/**
* @return array<int, string>
*/
public static function openStatuses(): array
{
return [
self::STATUS_NEW,
self::STATUS_TRIAGED,
self::STATUS_IN_PROGRESS,
self::STATUS_REOPENED,
];
}
/**
* @return array<int, string>
*/
public static function terminalStatuses(): array
{
return [
self::STATUS_RESOLVED,
self::STATUS_CLOSED,
self::STATUS_RISK_ACCEPTED,
];
}
/**
* @return array<int, string>
*/
public static function openStatusesForQuery(): array
{
return [
...self::openStatuses(),
self::STATUS_ACKNOWLEDGED,
];
}
/**
* @return array<int, string>
*/
public static function highSeverityValues(): array
{
return [
self::SEVERITY_HIGH,
self::SEVERITY_CRITICAL,
];
}
/**
* @return array<int, string>
*/
public static function manualResolveReasonKeys(): array
{
return [
self::RESOLVE_REASON_REMEDIATED,
];
}
/**
* @return array<int, string>
*/
public static function systemResolveReasonKeys(): array
{
return [
self::RESOLVE_REASON_NO_LONGER_DRIFTING,
self::RESOLVE_REASON_PERMISSION_GRANTED,
self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY,
self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED,
self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD,
];
}
/**
* @return array<int, string>
*/
public static function resolveReasonKeys(): array
{
return [
...self::manualResolveReasonKeys(),
...self::systemResolveReasonKeys(),
];
}
/**
* @return array<int, string>
*/
public static function closeReasonKeys(): array
{
return [
self::CLOSE_REASON_FALSE_POSITIVE,
self::CLOSE_REASON_DUPLICATE,
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
self::CLOSE_REASON_ACCEPTED_RISK,
];
}
/**
* @return array<int, string>
*/
public static function manualCloseReasonKeys(): array
{
return [
self::CLOSE_REASON_FALSE_POSITIVE,
self::CLOSE_REASON_DUPLICATE,
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
];
}
/**
* @return array<int, string>
*/
public static function reopenReasonKeys(): array
{
return [
self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION,
self::REOPEN_REASON_VERIFICATION_FAILED,
self::REOPEN_REASON_MANUAL_REASSESSMENT,
];
}
public static function isResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true);
}
public static function isManualResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true);
}
public static function isSystemResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true);
}
public static function isCloseReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::closeReasonKeys(), true);
}
public static function isManualCloseReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true);
}
public static function isRiskAcceptedReason(?string $reason): bool
{
return $reason === self::CLOSE_REASON_ACCEPTED_RISK;
}
public static function isReopenReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true);
}
public static function canonicalizeStatus(?string $status): ?string
{
if ($status === self::STATUS_ACKNOWLEDGED) {
return self::STATUS_TRIAGED;
}
return $status;
}
public static function isOpenStatus(?string $status): bool
{
return is_string($status) && in_array($status, self::openStatusesForQuery(), true);
}
public static function isTerminalStatus(?string $status): bool
{
$canonical = self::canonicalizeStatus($status);
return is_string($canonical) && in_array($canonical, self::terminalStatuses(), true);
}
public function hasOpenStatus(): bool
{
return self::isOpenStatus($this->status);
}
public function isRiskAccepted(): bool
{
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
}
public function acknowledge(User $user): self
{
if ($this->status === self::STATUS_ACKNOWLEDGED) {
return $this;
}
$this->forceFill([
'status' => self::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
$this->save();
return $this;
}
public function resolve(string $reason): self
{
$this->forceFill([
'status' => self::STATUS_RESOLVED,
'resolved_at' => now(),
'resolved_reason' => $reason,
]);
$this->save();
return $this;
}
/**
* @param array<string, mixed> $evidence
*/
public function reopen(array $evidence): self
{
$this->forceFill([
'status' => self::STATUS_NEW,
'resolved_at' => null,
'resolved_reason' => null,
'evidence_jsonb' => $evidence,
]);
$this->save();
return $this;
}
public function resolvedSubjectDisplayName(): ?string
{
$displayName = $this->getAttribute('subject_display_name');
if (is_string($displayName) && trim($displayName) !== '') {
return trim($displayName);
}
$fallback = Arr::get($this->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
}
public function responsibilityState(): string
{
if ($this->owner_user_id === null) {
return self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY;
}
if ($this->assignee_user_id === null) {
return self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED;
}
return self::RESPONSIBILITY_STATE_ASSIGNED;
}
public function hasAccountabilityGap(): bool
{
return $this->responsibilityState() === self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY;
}
public function responsibilityStateLabel(): string
{
return match ($this->responsibilityState()) {
self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'orphaned accountability',
self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'owned but unassigned',
default => 'assigned',
};
}
public function scopeWithSubjectDisplayName(Builder $query): Builder
{
return $query->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
]);
}
public function scopeOpenWorkflow(Builder $query): Builder
{
return $query->whereIn('status', self::openStatusesForQuery());
}
public function scopeDrift(Builder $query): Builder
{
return $query->where('finding_type', self::FINDING_TYPE_DRIFT);
}
public function scopeOpenDrift(Builder $query): Builder
{
return $query
->drift()
->openWorkflow();
}
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->drift()
->where('source', 'baseline.compare')
->where('scope_key', 'baseline_profile:'.$profileId);
}
public function scopeOverdueOpen(Builder $query): Builder
{
return $query
->openWorkflow()
->whereNotNull('due_at')
->where('due_at', '<', now());
}
public function scopeHighSeverity(Builder $query): Builder
{
return $query->whereIn('severity', self::highSeverityValues());
}
public function scopeHighSeverityActive(Builder $query): Builder
{
return $query
->openWorkflow()
->highSeverity();
}
}