TenantAtlas/specs/104-provider-permission-posture/data-model.md
ahmido ef380b67d1 feat(104): Provider Permission Posture (#127)
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
2026-02-21 22:32:52 +00:00

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 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

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)
  • newacknowledged: Manual user action (existing acknowledge() method)
  • newresolved: Auto-resolve when permission is granted
  • acknowledgedresolved: Auto-resolve when permission is granted (acknowledgement metadata preserved)
  • resolvednew: 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)