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