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
246 lines
6.3 KiB
Markdown
246 lines
6.3 KiB
Markdown
# 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)
|
|
```
|