Implements Spec 104: Provider Permission Posture. What changed - Generates permission posture findings after each tenant permission compare (queued) - Stores immutable posture snapshots as StoredReports (JSONB payload) - Adds global Finding resolved lifecycle (`resolved_at`, `resolved_reason`) with `resolve()` / `reopen()` - Adds alert pipeline event type `permission_missing` (Alerts v1) and Filament option for Alert Rules - Adds retention pruning command + daily schedule for StoredReports - Adds badge mappings for `resolved` finding status and `permission_posture` finding type UX fixes discovered during manual verification - Hide “Diff” section for non-drift findings (only drift findings show diff) - Required Permissions page: “Re-run verification” now links to Tenant view (not onboarding) - Preserve Technical Details `<details>` open state across Livewire re-renders (Alpine state) Verification - Ran `vendor/bin/sail artisan test --compact --filter=PermissionPosture` (50 tests) - Ran `vendor/bin/sail artisan test --compact --filter="FindingResolved|FindingBadge|PermissionMissingAlert"` (20 tests) - Ran `vendor/bin/sail bin pint --dirty` Filament v5 / Livewire v4 compliance - Filament v5 + Livewire v4: no Livewire v3 usage. Panel provider registration (Laravel 11+) - No new panels added. Existing panel providers remain registered via `bootstrap/providers.php`. Global search rule - No changes to global-searchable resources. Destructive actions - No new destructive Filament actions were added in this PR. Assets / deploy notes - No new Filament assets registered. Existing deploy step `php artisan filament:assets` remains unchanged. Test coverage - New/updated Pest feature tests cover generator behavior, job integration, alerting, retention pruning, and resolved lifecycle. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #127
6.3 KiB
6.3 KiB
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 Workspacetenant()→ 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
const FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
const STATUS_RESOLVED = 'resolved';
New Casts
'resolved_at' => 'datetime',
New Method
/**
* 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
const EVENT_PERMISSION_MISSING = 'permission_missing';
No schema change — event_type is already a string column.
Modified Model: OperationCatalog
New Constant
const TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check';
New Model: StoredReport
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
// 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)
{
"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)
{
"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 (existingacknowledge()method)new→resolved: Auto-resolve when permission is grantedacknowledged→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)