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