Some checks failed
Main Confidence / confidence (push) Failing after 53s
## Summary This PR delivers three related improvements: ### 1. Finding Ownership Semantics (Spec 219) - Add responsibility/accountability labels to findings and finding exceptions - `owner_user_id` = accountable party (governance owner) - `assignee_user_id` = responsible party (technical implementer) - Expose Assign/Reassign actions in FindingResource with audit logging - Add ownership columns and filters to finding list - Propagate owner from finding to exception on creation - Tests: ownership semantics, assignment audit, workflow actions ### 2. Constitution v2.7.0 — LEAN-001 Pre-Production Lean Doctrine - New principle forbidding legacy aliases, migration shims, dual-write logic, and compatibility fixtures in a pre-production codebase - AI-agent 4-question verification gate before adding any compatibility path - Review rule: compatibility shims without answering the gate questions = merge blocker - Exit condition: LEAN-001 expires at first production deployment - Spec template: added default "Compatibility posture" block - Agent instructions: added "Pre-production compatibility check" section ### 3. Backup Set Operation Type Unification - Unified `backup_set.add_policies` and `backup_set.remove_policies` into single canonical `backup_set.update` - Removed all legacy aliases, constants, and test fixtures - Added lifecycle coverage for `backup_set.update` in config - Updated all 14+ test files referencing legacy types ### Spec Artifacts - `specs/219-finding-ownership-semantics/` — full spec, plan, tasks, research, data model, contracts, checklist ### Tests - All affected tests pass (OperationCatalog, backup set, finding workflow, ownership semantics) Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #256
342 lines
9.0 KiB
PHP
342 lines
9.0 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 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,
|
|
];
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|