TenantAtlas/app/Services/Intune/PolicySnapshotRedactor.php
ahmido cd811cff4f Spec 120: harden secret redaction integrity (#146)
## Summary
- replace broad substring-based masking with a shared exact/path-based secret classifier and workspace-scoped fingerprint hashing
- persist protected snapshot metadata on `policy_versions` and keep secret-only changes visible in compare, drift, restore, review, verification, and ops surfaces
- add Spec 120 artifacts, audit documentation, and focused Pest regression coverage for snapshot, audit, verification, review-pack, and notification behavior

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Intune/PolicySnapshotRedactionTest.php tests/Feature/Intune/PolicySnapshotFingerprintIsolationTest.php tests/Feature/ReviewPack/ReviewPackRedactionIntegrityTest.php tests/Feature/OpsUx/OperationRunNotificationRedactionTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Spec / checklist status
| Checklist | Total | Completed | Incomplete | Status |
|-----------|-------|-----------|------------|--------|
| requirements.md | 16 | 16 | 0 | ✓ PASS |

- `tasks.md`: T001-T032 complete
- `tasks.md`: T033 manual quickstart validation is still open and noted for follow-up

## Filament / platform notes
- Livewire v4 compliance is unchanged
- no panel provider changes; `bootstrap/providers.php` remains the registration location
- no new globally searchable resources were introduced, so global search requirements are unchanged
- no new destructive Filament actions were added
- no new Filament assets were added; no `filament:assets` deployment change is required

## Testing coverage touched
- snapshot persistence and fingerprint isolation
- compare/drift protected-change evidence
- audit, verification, review-pack, ops-failure, and notification sanitization
- viewer/read-only Filament presentation updates

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #146
2026-03-07 16:43:01 +00:00

148 lines
5.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Intune;
final class PolicySnapshotRedactor
{
public function __construct(
private readonly ?SecretClassificationService $classificationService = null,
private readonly ?SecretFingerprintHasher $fingerprintHasher = null,
) {}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function redactPayload(array $payload): array
{
$redacted = $this->protectBucket('snapshot', $payload, null);
return is_array($redacted['value']) ? $redacted['value'] : $payload;
}
/**
* @param array<int, array<string, mixed>>|null $assignments
* @return array<int, array<string, mixed>>|null
*/
public function redactAssignments(?array $assignments): ?array
{
if ($assignments === null) {
return null;
}
$redacted = $this->protectBucket('assignments', $assignments, null);
return is_array($redacted['value']) ? $redacted['value'] : $assignments;
}
/**
* @param array<int, array<string, mixed>>|null $scopeTags
* @return array<int, array<string, mixed>>|null
*/
public function redactScopeTags(?array $scopeTags): ?array
{
if ($scopeTags === null) {
return null;
}
$redacted = $this->protectBucket('scope_tags', $scopeTags, null);
return is_array($redacted['value']) ? $redacted['value'] : $scopeTags;
}
/**
* @param array<string, mixed> $payload
* @param array<int, array<string, mixed>>|null $assignments
* @param array<int, array<string, mixed>>|array<string, mixed>|null $scopeTags
*/
public function protect(int $workspaceId, array $payload, ?array $assignments = null, ?array $scopeTags = null): ProtectedSnapshotResult
{
$snapshot = $this->protectBucket('snapshot', $payload, $workspaceId);
$protectedAssignments = $assignments !== null ? $this->protectBucket('assignments', $assignments, $workspaceId) : null;
$protectedScopeTags = $scopeTags !== null ? $this->protectBucket('scope_tags', $scopeTags, $workspaceId) : null;
$secretFingerprints = ProtectedSnapshotResult::emptyFingerprints();
$secretFingerprints['snapshot'] = $snapshot['fingerprints'];
$secretFingerprints['assignments'] = $protectedAssignments['fingerprints'] ?? [];
$secretFingerprints['scope_tags'] = $protectedScopeTags['fingerprints'] ?? [];
return new ProtectedSnapshotResult(
snapshot: is_array($snapshot['value']) ? $snapshot['value'] : $payload,
assignments: $protectedAssignments !== null && is_array($protectedAssignments['value']) ? $protectedAssignments['value'] : $assignments,
scopeTags: $protectedScopeTags !== null && is_array($protectedScopeTags['value']) ? $protectedScopeTags['value'] : $scopeTags,
secretFingerprints: $secretFingerprints,
redactionVersion: SecretClassificationService::REDACTION_VERSION,
protectedPathsCount: count($secretFingerprints['snapshot']) + count($secretFingerprints['assignments']) + count($secretFingerprints['scope_tags']),
);
}
/**
* @param array<int, string> $segments
* @return array{value: mixed, fingerprints: array<string, string>}
*/
private function protectBucket(string $sourceBucket, mixed $value, ?int $workspaceId, array $segments = []): array
{
if (! is_array($value)) {
return [
'value' => $value,
'fingerprints' => [],
];
}
$redacted = [];
$fingerprints = [];
foreach ($value as $key => $item) {
$nextSegments = [...$segments, (string) $key];
$jsonPointer = $this->jsonPointer($nextSegments);
if (is_string($key) && $this->classifier()->protectsField($sourceBucket, $key, $jsonPointer)) {
$redacted[$key] = SecretClassificationService::REDACTED;
if ($workspaceId !== null) {
$fingerprints[$jsonPointer] = $this->hasher()->fingerprint($workspaceId, $sourceBucket, $jsonPointer, $item);
}
continue;
}
$protected = $this->protectBucket($sourceBucket, $item, $workspaceId, $nextSegments);
$redacted[$key] = $protected['value'];
$fingerprints = [...$fingerprints, ...$protected['fingerprints']];
}
return [
'value' => $redacted,
'fingerprints' => $fingerprints,
];
}
/**
* @param array<int, string> $segments
*/
private function jsonPointer(array $segments): string
{
if ($segments === []) {
return '/';
}
return '/'.implode('/', array_map(
static fn (string $segment): string => str_replace(['~', '/'], ['~0', '~1'], $segment),
$segments,
));
}
private function classifier(): SecretClassificationService
{
return $this->classificationService ?? app(SecretClassificationService::class);
}
private function hasher(): SecretFingerprintHasher
{
return $this->fingerprintHasher ?? app(SecretFingerprintHasher::class);
}
}