TenantAtlas/specs/104-provider-permission-posture/contracts/internal-services.md
Ahmed Darrazi 222a7e0a97 feat(104): implement Provider Permission Posture
- T001-T014: Foundation - StoredReport model/migration, Finding resolved
  lifecycle, badge mappings (resolved status, permission_posture type),
  OperationCatalog + AlertRule constants
- T015-T022: US1 - PermissionPostureFindingGenerator with fingerprint-based
  idempotent upsert, severity from feature-impact count, auto-resolve on
  grant, auto-reopen on revoke, error findings (FR-015), stale finding
  cleanup; GeneratePermissionPostureFindingsJob dispatched from health check;
  PostureResult VO + PostureScoreCalculator
- T023-T026: US2+US4 - Stored report payload validation, temporal ordering,
  polymorphic reusability, score accuracy acceptance tests
- T027-T029: US3 - EvaluateAlertsJob.permissionMissingEvents() wired into
  alert pipeline, AlertRuleResource event type option, cooldown/dedupe tests
- T030-T034: Polish - PruneStoredReportsCommand with config retention,
  scheduled daily, end-to-end integration test, Pint clean

UI bug fixes found during testing:
- FindingResource: hide Diff section for non-drift findings
- TenantRequiredPermissions: fix re-run verification link
- tenant-required-permissions.blade.php: preserve details open state

70 tests (50 PermissionPosture + 20 FindingResolved/Badge/Alert), 216 assertions
2026-02-21 23:31:03 +01:00

150 lines
4.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Internal Contracts: Provider Permission Posture (Spec 104)
**Date**: 2026-02-21 | **Branch**: `104-provider-permission-posture`
This feature does NOT expose external HTTP APIs. All contracts are internal service interfaces.
---
## Contract 1: PermissionPostureFindingGenerator
**Service**: `App\Services\PermissionPosture\PermissionPostureFindingGenerator`
### `generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult`
**Input**:
- `$tenant`: The tenant being evaluated
- `$permissionComparison`: Output of `TenantPermissionService::compare()` — see schema below
- `$operationRun`: Optional OperationRun for tracking (created by caller if not provided)
**Input schema** (`$permissionComparison`):
```php
array{
overall_status: 'granted'|'missing'|'error',
permissions: array<int, array{
key: string,
type: string,
description: ?string,
features: array<int, string>,
status: 'granted'|'missing'|'error',
details: ?array,
}>,
last_refreshed_at: ?string,
}
```
**Output** (`PostureResult` value object):
```php
PostureResult {
int $findingsCreated,
int $findingsResolved,
int $findingsReopened,
int $findingsUnchanged,
int $errorsRecorded,
int $postureScore,
int $storedReportId,
}
```
**Side effects**:
1. Creates/updates/resolves `Finding` records (type: `permission_posture`)
2. Creates a `StoredReport` (type: `permission_posture`)
3. Dispatches alert events for new/reopened findings via the alert pipeline
**Idempotency**: Fingerprint-based. Same input produces same findings (no duplicates).
---
## Contract 2: PostureScoreCalculator
**Service**: `App\Services\PermissionPosture\PostureScoreCalculator`
### `calculate(array $permissionComparison): int`
**Input**: `$permissionComparison` — same schema as Contract 1 input.
**Output**: Integer 0100 representing `round(granted_count / required_count * 100)`. Returns 100 if `required_count` is 0.
**Pure function**: No side effects, no DB access.
---
## Contract 3: GeneratePermissionPostureFindingsJob
**Job**: `App\Jobs\GeneratePermissionPostureFindingsJob`
### Constructor
```php
public function __construct(
public readonly int $tenantId,
public readonly array $permissionComparison,
)
```
### `handle(PermissionPostureFindingGenerator $generator): void`
**Flow**:
1. Load Tenant (fail if not found)
2. Skip if tenant has no active provider connection (FR-016)
3. Create OperationRun (`type = permission_posture_check`)
4. Call `$generator->generate($tenant, $permissionComparison, $run)`
5. Record summary counts on OperationRun
6. Complete OperationRun
**Queue**: `default` (same queue as health checks)
**Dispatch point**: `ProviderConnectionHealthCheckJob::handle()` after `TenantPermissionService::compare()` returns, when `$permissionComparison['overall_status'] !== 'error'`.
---
## Contract 4: Alert Event Schema (EVENT_PERMISSION_MISSING)
**Event array** (passed to `AlertDispatchService::dispatchEvent()`):
```php
[
'event_type' => 'permission_missing',
'tenant_id' => int,
'severity' => 'low'|'medium'|'high'|'critical',
'fingerprint_key' => "permission_missing:{tenant_id}:{permission_key}",
'title' => "Missing permission: {permission_key}",
'body' => "Tenant {tenant_name} is missing {permission_key}. Blocked features: {feature_list}.",
'metadata' => [
'permission_key' => string,
'permission_type' => string,
'blocked_features' => array<string>,
'posture_score' => int,
],
]
```
**Produced by**: `PermissionPostureFindingGenerator` for each newly created or re-opened finding.
**Not produced for**: Auto-resolved findings, existing unchanged findings, error findings.
---
## Contract 5: EvaluateAlertsJob Extension
### New method: `permissionMissingEvents(): array`
**Query**: `Finding` where:
- `finding_type = 'permission_posture'`
- `status IN ('new')` (only new/re-opened, not acknowledged or resolved)
- `severity` >= minimum severity threshold from alert rule
- `created_at` or `updated_at` >= window start
**Output**: Array of event arrays matching Contract 4 schema.
---
## Contract 6: StoredReport Retention
### `PruneStoredReportsCommand` (Artisan command)
**Signature**: `stored-reports:prune {--days=90}`
**Behavior**: Deletes `stored_reports` where `created_at < now() - days`. Outputs count of deleted records.
**Schedule**: Daily via `routes/console.php`.