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
This commit is contained in:
ahmido 2026-03-07 16:43:01 +00:00
parent da1adbdeb5
commit cd811cff4f
64 changed files with 4124 additions and 255 deletions

View File

@ -40,6 +40,8 @@ ## Active Technologies
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
- PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail (120-secret-redaction-integrity)
- PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -59,8 +61,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 120-secret-redaction-integrity: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail
- 116-baseline-drift-engine-session-1772451227: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 116-baseline-drift-engine: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -16,6 +16,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\RedactionIntegrity;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
@ -141,6 +142,11 @@ public function defaultInfolist(Schema $schema): Schema
->columns(2);
}
public function redactionIntegrityNote(): ?string
{
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
}
public function content(Schema $schema): Schema
{
return $schema->schema([

View File

@ -16,6 +16,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use App\Support\RedactionIntegrity;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -202,6 +203,11 @@ public static function infolist(Schema $schema): Schema
Section::make('Evidence')
->schema([
TextEntry::make('redaction_integrity_note')
->label('Integrity note')
->state(fn (Finding $record): ?string => static::redactionIntegrityNoteForRecord($record))
->columnSpanFull()
->visible(fn (Finding $record): bool => static::redactionIntegrityNoteForRecord($record) !== null),
TextEntry::make('baseline_evidence_fidelity')
->label('Baseline fidelity')
->badge()
@ -373,6 +379,13 @@ private static function canRenderDriftDiff(Finding $record): bool
};
}
public static function redactionIntegrityNoteForRecord(Finding $record): ?string
{
$evidence = is_array($record->evidence_jsonb) ? $record->evidence_jsonb : [];
return RedactionIntegrity::noteForFindingEvidence($evidence);
}
private static function driftDiffUnavailableMessage(Finding $record): string
{
return match (static::driftChangeType($record)) {

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\FindingResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Contracts\Support\Htmlable;
class ViewFinding extends ViewRecord
{
@ -19,4 +20,9 @@ protected function getHeaderActions(): array
->color('gray'),
];
}
public function getSubheading(): string|Htmlable|null
{
return FindingResource::redactionIntegrityNoteForRecord($this->getRecord());
}
}

View File

@ -18,6 +18,7 @@
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\RedactionIntegrity;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -469,6 +470,7 @@ public static function infolist(Schema $schema): Schema
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'acknowledgements' => $acknowledgements,
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
];
})
->columnSpanFull(),
@ -476,6 +478,16 @@ public static function infolist(Schema $schema): Schema
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
->columnSpanFull(),
Section::make('Integrity')
->schema([
TextEntry::make('redaction_integrity_note')
->label('')
->getStateUsing(fn (OperationRun $record): ?string => RedactionIntegrity::noteForRun($record))
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => RedactionIntegrity::noteForRun($record) !== null)
->columnSpanFull(),
Section::make('Context')
->schema([
ViewEntry::make('context')

View File

@ -24,6 +24,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\RedactionIntegrity;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -637,6 +638,8 @@ public static function table(Table $table): Table
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'policy_id' => $policy->id,
'redaction_version' => $record->redaction_version,
'integrity_warning' => RedactionIntegrity::noteForPolicyVersion($record),
],
]);
@ -650,8 +653,22 @@ public static function table(Table $table): Table
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(),
'redaction_version' => $record->redaction_version,
];
$integrityWarning = RedactionIntegrity::noteForPolicyVersion($record);
if ($integrityWarning !== null) {
$backupItemMetadata['integrity_warning'] = $integrityWarning;
}
$secretFingerprints = is_array($record->secret_fingerprints) ? $record->secret_fingerprints : [];
$protectedPathsCount = RedactionIntegrity::fingerprintCount($secretFingerprints);
if ($protectedPathsCount > 0) {
$backupItemMetadata['protected_paths_count'] = $protectedPathsCount;
}
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}

View File

@ -5,6 +5,7 @@
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\RedactionIntegrity;
use App\Support\Verification\VerificationReportFingerprint;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
@ -89,4 +90,13 @@ public static function shouldRenderForRun(OperationRun $run): bool
return in_array((string) $run->type, ['provider.connection.check'], true);
}
/**
* @param array<string, mixed>|null $report
* @return array<int, string>
*/
public static function redactionNotes(?array $report): array
{
return RedactionIntegrity::verificationNotes($report);
}
}

View File

@ -36,52 +36,51 @@ class Runbooks extends Page
protected string $view = 'filament.system.pages.ops.runbooks';
public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
public ?int $findingsTenantId = null;
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
public ?int $tenantId = null;
/**
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
*/
public ?array $findingsPreflight = null;
/**
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
*/
public ?array $preflight = null;
public function scopeLabel(): string
public function findingsScopeLabel(): string
{
if ($this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
return 'All tenants';
}
$tenantName = $this->selectedTenantName();
$tenantName = $this->selectedTenantName($this->findingsTenantId);
if ($tenantName !== null) {
return "Single tenant ({$tenantName})";
}
return $this->tenantId !== null ? "Single tenant (#{$this->tenantId})" : 'Single tenant';
return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant';
}
public function lastRun(): ?OperationRun
public function findingsLastRun(): ?OperationRun
{
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
}
if (! $platformTenant instanceof Tenant) {
public function selectedTenantName(?int $tenantId): ?string
{
if ($tenantId === null) {
return null;
}
return OperationRun::query()
->where('workspace_id', (int) $platformTenant->workspace_id)
->where('type', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY)
->latest('id')
->first();
}
public function selectedTenantName(): ?string
{
if ($this->tenantId === null) {
return null;
}
return Tenant::query()->whereKey($this->tenantId)->value('name');
return Tenant::query()->whereKey($tenantId)->value('name');
}
public static function canAccess(): bool
@ -106,17 +105,20 @@ protected function getHeaderActions(): array
->label('Preflight')
->color('gray')
->icon('heroicon-o-magnifying-glass')
->form($this->scopeForm())
->form($this->findingsScopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
$this->findingsScopeMode = $scope->mode;
$this->findingsTenantId = $scope->tenantId;
$this->scopeMode = $scope->mode;
$this->tenantId = $scope->tenantId;
$this->preflight = $runbookService->preflight($scope);
$this->findingsPreflight = $runbookService->preflight($scope);
$this->preflight = $this->findingsPreflight;
Notification::make()
->title('Preflight complete')
@ -131,17 +133,17 @@ protected function getHeaderActions(): array
->requiresConfirmation()
->modalHeading('Run: Rebuild Findings Lifecycle')
->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.')
->form($this->runForm())
->disabled(fn (): bool => ! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0)
->form($this->findingsRunForm())
->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
if (! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0) {
if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) {
throw ValidationException::withMessages([
'preflight' => 'Run preflight first.',
]);
}
$scope = $this->scopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
? FindingsLifecycleBackfillScope::singleTenant((int) $this->tenantId)
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
: FindingsLifecycleBackfillScope::allTenants();
$user = auth('platform')->user();
@ -198,7 +200,7 @@ protected function getHeaderActions(): array
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function scopeForm(): array
private function findingsScopeForm(): array
{
return [
Radio::make('scope_mode')
@ -207,7 +209,7 @@ private function scopeForm(): array
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
])
->default($this->scopeMode)
->default($this->findingsScopeMode)
->live()
->required(),
@ -241,13 +243,13 @@ private function scopeForm(): array
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function runForm(): array
private function findingsRunForm(): array
{
return [
TextInput::make('typed_confirmation')
->label('Type BACKFILL to confirm')
->visible(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->required(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->in(['BACKFILL'])
->validationMessages([
'in' => 'Please type BACKFILL to confirm.',
@ -257,7 +259,7 @@ private function runForm(): array
->label('Reason code')
->options(RunbookReason::options())
->required(function (BreakGlassSession $breakGlass): bool {
return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
Textarea::make('reason_text')
@ -265,8 +267,23 @@ private function runForm(): array
->rows(4)
->maxLength(500)
->required(function (BreakGlassSession $breakGlass): bool {
return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
];
}
private function lastRunForType(string $type): ?OperationRun
{
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
if (! $platformTenant instanceof Tenant) {
return null;
}
return OperationRun::query()
->where('workspace_id', (int) $platformTenant->workspace_id)
->where('type', $type)
->latest('id')
->first();
}
}

View File

@ -218,6 +218,7 @@ protected function getViewData(): array
'runData' => $runData,
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
'report' => $report,
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
'isInProgress' => $isInProgress,
'canStart' => $canStart,
'startTooltip' => $isTenantMember && ! $canStart ? UiTooltips::insufficientPermission() : null,

View File

@ -1334,8 +1334,14 @@ private function selectSummaryKind(
platform: $platform,
);
$baselineSnapshotHash = $hasher->hashNormalized($baselineNormalized);
$currentSnapshotHash = $hasher->hashNormalized($currentNormalized);
$baselineSnapshotHash = $hasher->hashNormalized([
'settings' => $baselineNormalized,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
]);
$currentSnapshotHash = $hasher->hashNormalized([
'settings' => $currentNormalized,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
]);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
return 'policy_snapshot';
@ -1344,8 +1350,14 @@ private function selectSummaryKind(
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
$baselineAssignmentsHash = $hasher->hashNormalized($assignmentsNormalizer->normalizeForDiff($baselineAssignments));
$currentAssignmentsHash = $hasher->hashNormalized($assignmentsNormalizer->normalizeForDiff($currentAssignments));
$baselineAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
]);
$currentAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
]);
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
return 'policy_assignments';
@ -1358,8 +1370,14 @@ private function selectSummaryKind(
return 'policy_snapshot';
}
$baselineScopeTagsHash = $hasher->hashNormalized($baselineScopeTagIds);
$currentScopeTagsHash = $hasher->hashNormalized($currentScopeTagIds);
$baselineScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $baselineScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
]);
$currentScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $currentScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
]);
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
return 'policy_scope_tags';
@ -1368,6 +1386,17 @@ private function selectSummaryKind(
return 'policy_snapshot';
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
/**
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
* @param array<string, mixed> $currentProvenance

View File

@ -9,10 +9,12 @@
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Services\Intune\SecretClassificationService;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RedactionIntegrity;
use App\Support\ReviewPackStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
@ -225,6 +227,10 @@ private function buildFileMap(
'tenant_id' => $tenant->external_id,
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
'generated_at' => now()->toIso8601String(),
'redaction_integrity' => [
'protected_values_hidden' => true,
'note' => RedactionIntegrity::protectedValueNote(),
],
'options' => [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
@ -324,11 +330,9 @@ private function buildOperationsCsv($operations, bool $includePii): string
*/
private function redactReportPayload(array $payload, bool $includePii): array
{
if ($includePii) {
return $payload;
}
$payload = $this->redactProtectedPayload($payload);
return $this->redactArrayPii($payload);
return $includePii ? $payload : $this->redactArrayPii($payload);
}
/**
@ -352,6 +356,57 @@ private function redactArrayPii(array $data): array
return $data;
}
/**
* @param array<int|string, mixed> $data
* @param array<int, string> $segments
* @return array<int|string, mixed>
*/
private function redactProtectedPayload(array $data, array $segments = []): array
{
foreach ($data as $key => $value) {
$nextSegments = [...$segments, (string) $key];
$jsonPointer = $this->jsonPointer($nextSegments);
if (is_string($key) && $this->classifier()->protectsField('snapshot', $key, $jsonPointer)) {
$data[$key] = SecretClassificationService::REDACTED;
continue;
}
if (is_array($value)) {
$data[$key] = $this->redactProtectedPayload($value, $nextSegments);
continue;
}
if (is_string($value)) {
$data[$key] = $this->classifier()->sanitizeAuditString($value);
}
}
return $data;
}
/**
* @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 app(SecretClassificationService::class);
}
/**
* Assemble a ZIP file from a file map.
*

View File

@ -22,6 +22,8 @@ class PolicyVersion extends Model
'metadata' => 'array',
'assignments' => 'array',
'scope_tags' => 'array',
'secret_fingerprints' => 'array',
'redaction_version' => 'integer',
'captured_at' => 'datetime',
'capture_purpose' => PolicyVersionCapturePurpose::class,
];

View File

@ -43,6 +43,8 @@ public function fromPolicyVersion(
snapshot: $version->snapshot,
assignments: $version->assignments,
scopeTags: $version->scope_tags,
secretFingerprints: $version->secret_fingerprints,
redactionVersion: $version->redaction_version,
capturedAt: $version->captured_at,
policyVersionId: (int) $version->getKey(),
operationRunId: $version->operation_run_id,
@ -121,6 +123,8 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
'policy_versions.snapshot',
'policy_versions.assignments',
'policy_versions.scope_tags',
'policy_versions.secret_fingerprints',
'policy_versions.redaction_version',
'policy_versions.version_number',
])
->selectRaw('ROW_NUMBER() OVER (PARTITION BY policy_id ORDER BY captured_at DESC, version_number DESC, id DESC) as rn')
@ -161,6 +165,8 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
snapshot: $version->snapshot ?? null,
assignments: $version->assignments ?? null,
scopeTags: $version->scope_tags ?? null,
secretFingerprints: $version->secret_fingerprints ?? null,
redactionVersion: is_numeric($version->redaction_version ?? null) ? (int) $version->redaction_version : null,
capturedAt: $version->captured_at ?? null,
policyVersionId: is_numeric($version->id ?? null) ? (int) $version->id : null,
operationRunId: is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null,
@ -200,6 +206,8 @@ private function buildResolvedEvidence(
mixed $snapshot,
mixed $assignments,
mixed $scopeTags,
mixed $secretFingerprints,
?int $redactionVersion,
mixed $capturedAt,
?int $policyVersionId,
mixed $operationRunId,
@ -216,6 +224,11 @@ private function buildResolvedEvidence(
$scopeTags = is_array($scopeTags) ? $scopeTags : (is_string($scopeTags) ? json_decode($scopeTags, true) : null);
$scopeTags = is_array($scopeTags) ? $scopeTags : [];
$secretFingerprints = is_array($secretFingerprints)
? $secretFingerprints
: (is_string($secretFingerprints) ? json_decode($secretFingerprints, true) : null);
$secretFingerprints = is_array($secretFingerprints) ? $secretFingerprints : [];
$normalized = $this->settingsNormalizer->normalizeForDiff(
snapshot: $snapshot,
policyType: $policyType,
@ -229,6 +242,12 @@ private function buildResolvedEvidence(
'settings' => $normalized,
'assignments' => $normalizedAssignments,
'scope_tag_ids' => $normalizedScopeTagIds,
'secret_fingerprints' => [
'snapshot' => $this->fingerprintBucket($secretFingerprints, 'snapshot'),
'assignments' => $this->fingerprintBucket($secretFingerprints, 'assignments'),
'scope_tags' => $this->fingerprintBucket($secretFingerprints, 'scope_tags'),
],
'redaction_version' => $redactionVersion,
]);
$observedAt ??= is_string($capturedAt) ? CarbonImmutable::parse($capturedAt) : null;
@ -248,7 +267,19 @@ private function buildResolvedEvidence(
'policy_version_id' => $policyVersionId,
'operation_run_id' => is_numeric($operationRunId) ? (int) $operationRunId : null,
'capture_purpose' => $capturePurpose,
'redaction_version' => $redactionVersion,
],
);
}
/**
* @param array<string, mixed> $secretFingerprints
* @return array<string, string>
*/
private function fingerprintBucket(array $secretFingerprints, string $bucket): array
{
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
}

View File

@ -39,6 +39,30 @@ public function buildSettingsDiff(?PolicyVersion $baselineVersion, ?PolicyVersio
$result = $this->versionDiff->compare($from, $to);
$result['policy_type'] = $policyType;
$protectedChanges = $this->protectedPointerChanges($baselineVersion, $currentVersion, 'snapshot');
if ($protectedChanges !== []) {
$existingChanged = is_array($result['changed'] ?? null) ? $result['changed'] : [];
foreach ($protectedChanges as $pointer) {
$existingChanged['Protected > '.$pointer.' (value changed)'] = [
'from' => '[REDACTED]',
'to' => '[REDACTED]',
];
}
$result['changed'] = $existingChanged;
$result['summary']['changed'] = count($existingChanged);
$result['summary']['message'] = sprintf(
'%d added, %d removed, %d changed (%d protected value change%s)',
count(is_array($result['added'] ?? null) ? $result['added'] : []),
count(is_array($result['removed'] ?? null) ? $result['removed'] : []),
count($existingChanged),
count($protectedChanges),
count($protectedChanges) === 1 ? '' : 's',
);
}
return $result;
}
@ -159,7 +183,7 @@ public function buildAssignmentsDiff(Tenant $tenant, ?PolicyVersion $baselineVer
'added' => count($added),
'removed' => count($removed),
'changed' => count($changed),
'message' => sprintf('%d added, %d removed, %d changed', count($added), count($removed), count($changed)),
'message' => $this->assignmentSummaryMessage($baselineVersion, $currentVersion, count($added), count($removed), count($changed)),
'truncated' => $truncated,
'limit' => $limit,
],
@ -206,12 +230,22 @@ public function buildScopeTagsDiff(?PolicyVersion $baselineVersion, ?PolicyVersi
return $rows;
};
$protectedChanges = $this->protectedPointerChanges($baselineVersion, $currentVersion, 'scope_tags');
return [
'summary' => [
'added' => count($addedIds),
'removed' => count($removedIds),
'changed' => 0,
'message' => sprintf('%d added, %d removed', count($addedIds), count($removedIds)),
'message' => $protectedChanges === []
? sprintf('%d added, %d removed', count($addedIds), count($removedIds))
: sprintf(
'%d added, %d removed (%d protected value change%s)',
count($addedIds),
count($removedIds),
count($protectedChanges),
count($protectedChanges) === 1 ? '' : 's',
),
'baseline_count' => count($baselineSet),
'current_count' => count($currentSet),
],
@ -223,6 +257,54 @@ public function buildScopeTagsDiff(?PolicyVersion $baselineVersion, ?PolicyVersi
];
}
private function assignmentSummaryMessage(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, int $added, int $removed, int $changed): string
{
$protectedChanges = $this->protectedPointerChanges($baselineVersion, $currentVersion, 'assignments');
if ($protectedChanges === []) {
return sprintf('%d added, %d removed, %d changed', $added, $removed, $changed);
}
return sprintf(
'%d added, %d removed, %d changed (%d protected value change%s)',
$added,
$removed,
$changed,
count($protectedChanges),
count($protectedChanges) === 1 ? '' : 's',
);
}
/**
* @return array<int, string>
*/
private function protectedPointerChanges(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, string $bucket): array
{
$baseline = $this->fingerprintBucket($baselineVersion, $bucket);
$current = $this->fingerprintBucket($currentVersion, $bucket);
$pointers = array_values(array_unique(array_merge(array_keys($baseline), array_keys($current))));
sort($pointers);
return array_values(array_filter($pointers, static function (string $pointer) use ($baseline, $current): bool {
return ($baseline[$pointer] ?? null) !== ($current[$pointer] ?? null);
}));
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(?PolicyVersion $version, string $bucket): array
{
if (! $version instanceof PolicyVersion || ! is_array($version->secret_fingerprints)) {
return [];
}
$bucketFingerprints = $version->secret_fingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
/**
* @param array<int, array<string, mixed>> $added
* @param array<int, array<string, mixed>> $removed

View File

@ -132,14 +132,19 @@ public function capture(
$scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds);
}
$redactedPayload = $this->snapshotRedactor->redactPayload($payload);
$redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments);
$redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
$protectedSnapshot = $this->snapshotRedactor->protect(
workspaceId: (int) $tenant->workspace_id,
payload: $payload,
assignments: $assignments,
scopeTags: $scopeTags,
);
// 4. Check if PolicyVersion with same snapshot already exists (based on redacted content)
$snapshotHash = hash('sha256', json_encode($redactedPayload));
$snapshotHash = $this->snapshotContractHash(
snapshot: $protectedSnapshot->snapshot,
snapshotFingerprints: $protectedSnapshot->secretFingerprints['snapshot'],
redactionVersion: $protectedSnapshot->redactionVersion,
);
// Find existing version by comparing snapshot content (database-agnostic)
$existingVersion = PolicyVersion::query()
->where('policy_id', $policy->id)
->where('capture_purpose', $capturePurpose->value)
@ -149,20 +154,44 @@ public function capture(
)
->get()
->first(function ($version) use ($snapshotHash) {
return hash('sha256', json_encode($version->snapshot)) === $snapshotHash;
return $this->snapshotContractHash(
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
) === $snapshotHash;
});
if ($existingVersion) {
$updates = [];
if ($includeAssignments && $existingVersion->assignments === null) {
$updates['assignments'] = $redactedAssignments;
$updates['assignments_hash'] = $redactedAssignments ? hash('sha256', json_encode($redactedAssignments)) : null;
$updates['assignments'] = $protectedSnapshot->assignments;
$updates['assignments_hash'] = $protectedSnapshot->assignments
? hash('sha256', json_encode($protectedSnapshot->assignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if ($includeScopeTags && $existingVersion->scope_tags === null) {
$updates['scope_tags'] = $redactedScopeTags;
$updates['scope_tags_hash'] = $redactedScopeTags ? hash('sha256', json_encode($redactedScopeTags)) : null;
$updates['scope_tags'] = $protectedSnapshot->scopeTags;
$updates['scope_tags_hash'] = $protectedSnapshot->scopeTags
? hash('sha256', json_encode($protectedSnapshot->scopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if ($updates !== []) {
$secretFingerprints = is_array($existingVersion->secret_fingerprints)
? $existingVersion->secret_fingerprints
: ProtectedSnapshotResult::emptyFingerprints();
if (array_key_exists('assignments', $updates)) {
$secretFingerprints['assignments'] = $protectedSnapshot->secretFingerprints['assignments'];
}
if (array_key_exists('scope_tags', $updates)) {
$secretFingerprints['scope_tags'] = $protectedSnapshot->secretFingerprints['scope_tags'];
}
$updates['secret_fingerprints'] = $secretFingerprints;
}
if (! empty($updates)) {
@ -180,9 +209,9 @@ public function capture(
return [
'version' => $existingVersion->fresh(),
'captured' => [
'payload' => $redactedPayload,
'assignments' => $redactedAssignments,
'scope_tags' => $redactedScopeTags,
'payload' => $protectedSnapshot->snapshot,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->scopeTags,
'metadata' => $captureMetadata,
],
];
@ -198,9 +227,9 @@ public function capture(
return [
'version' => $existingVersion,
'captured' => [
'payload' => $redactedPayload,
'assignments' => $redactedAssignments,
'scope_tags' => $redactedScopeTags,
'payload' => $protectedSnapshot->snapshot,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->scopeTags,
'metadata' => $captureMetadata,
],
];
@ -215,11 +244,11 @@ public function capture(
$version = $this->versionService->captureVersion(
policy: $policy,
payload: $redactedPayload,
payload: $payload,
createdBy: $createdBy,
metadata: $metadata,
assignments: $redactedAssignments,
scopeTags: $redactedScopeTags,
assignments: $assignments,
scopeTags: $scopeTags,
capturePurpose: $capturePurpose,
operationRunId: $operationRunId,
baselineProfileId: $baselineProfileId,
@ -237,9 +266,9 @@ public function capture(
return [
'version' => $version,
'captured' => [
'payload' => $redactedPayload,
'assignments' => $redactedAssignments,
'scope_tags' => $redactedScopeTags,
'payload' => $protectedSnapshot->snapshot,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->scopeTags,
'metadata' => $captureMetadata,
],
];
@ -337,13 +366,21 @@ public function ensureVersionHasAssignments(
$updates = [];
if ($includeAssignments && $version->assignments === null) {
$updates['assignments'] = $assignments;
$updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null;
$redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments);
$updates['assignments'] = $redactedAssignments;
$updates['assignments_hash'] = $redactedAssignments
? hash('sha256', json_encode($redactedAssignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if ($includeScopeTags && $version->scope_tags === null) {
$updates['scope_tags'] = $scopeTags;
$updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null;
$redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
$updates['scope_tags'] = $redactedScopeTags;
$updates['scope_tags_hash'] = $redactedScopeTags
? hash('sha256', json_encode($redactedScopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
: null;
}
if (! empty($updates)) {
@ -443,4 +480,53 @@ private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array
'names' => $names,
];
}
/**
* @param array<string, mixed> $snapshot
* @param array<string, string> $snapshotFingerprints
*/
private function snapshotContractHash(array $snapshot, array $snapshotFingerprints, ?int $redactionVersion): string
{
return hash(
'sha256',
json_encode(
$this->normalizeHashValue([
'snapshot' => $snapshot,
'secret_fingerprints' => $snapshotFingerprints,
'redaction_version' => $redactionVersion,
]),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
),
);
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$fingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $fingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
private function normalizeHashValue(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->normalizeHashValue($item), $value);
}
ksort($value);
foreach ($value as $key => $item) {
$value[$key] = $this->normalizeHashValue($item);
}
return $value;
}
}

View File

@ -6,21 +6,10 @@
final class PolicySnapshotRedactor
{
private const string REDACTED = '[REDACTED]';
/**
* @var list<string>
*/
private const array SENSITIVE_KEY_PATTERNS = [
'/password/i',
'/secret/i',
'/token/i',
'/client[_-]?secret/i',
'/private[_-]?key/i',
'/shared[_-]?secret/i',
'/preshared/i',
'/certificate/i',
];
public function __construct(
private readonly ?SecretClassificationService $classificationService = null,
private readonly ?SecretFingerprintHasher $fingerprintHasher = null,
) {}
/**
* @param array<string, mixed> $payload
@ -28,7 +17,9 @@ final class PolicySnapshotRedactor
*/
public function redactPayload(array $payload): array
{
return $this->redactValue($payload);
$redacted = $this->protectBucket('snapshot', $payload, null);
return is_array($redacted['value']) ? $redacted['value'] : $payload;
}
/**
@ -41,9 +32,9 @@ public function redactAssignments(?array $assignments): ?array
return null;
}
$redacted = $this->redactValue($assignments);
$redacted = $this->protectBucket('assignments', $assignments, null);
return is_array($redacted) ? $redacted : $assignments;
return is_array($redacted['value']) ? $redacted['value'] : $assignments;
}
/**
@ -56,40 +47,101 @@ public function redactScopeTags(?array $scopeTags): ?array
return null;
}
$redacted = $this->redactValue($scopeTags);
$redacted = $this->protectBucket('scope_tags', $scopeTags, null);
return is_array($redacted) ? $redacted : $scopeTags;
return is_array($redacted['value']) ? $redacted['value'] : $scopeTags;
}
private function isSensitiveKey(string $key): bool
/**
* @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
{
foreach (self::SENSITIVE_KEY_PATTERNS as $pattern) {
if (preg_match($pattern, $key) === 1) {
return true;
}
$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']),
);
}
return false;
}
private function redactValue(mixed $value): mixed
/**
* @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;
return [
'value' => $value,
'fingerprints' => [],
];
}
$redacted = [];
$fingerprints = [];
foreach ($value as $key => $item) {
if (is_string($key) && $this->isSensitiveKey($key)) {
$redacted[$key] = self::REDACTED;
$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;
}
$redacted[$key] = $this->redactValue($item);
$protected = $this->protectBucket($sourceBucket, $item, $workspaceId, $nextSegments);
$redacted[$key] = $protected['value'];
$fingerprints = [...$fingerprints, ...$protected['fingerprints']];
}
return $redacted;
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);
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Services\Intune;
final readonly class ProtectedSnapshotResult
{
/**
* @param array<string, mixed> $snapshot
* @param array<int, array<string, mixed>>|null $assignments
* @param array<int, array<string, mixed>>|array<string, mixed>|null $scopeTags
* @param array{
* snapshot: array<string, string>,
* assignments: array<string, string>,
* scope_tags: array<string, string>
* } $secretFingerprints
*/
public function __construct(
public array $snapshot,
public ?array $assignments,
public ?array $scopeTags,
public array $secretFingerprints,
public int $redactionVersion,
public int $protectedPathsCount,
) {}
/**
* @return array{
* snapshot: array<string, string>,
* assignments: array<string, string>,
* scope_tags: array<string, string>
* }
*/
public static function emptyFingerprints(): array
{
return [
'snapshot' => [],
'assignments' => [],
'scope_tags' => [],
];
}
}

View File

@ -20,6 +20,7 @@
use App\Services\Providers\ProviderGateway;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RedactionIntegrity;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@ -86,6 +87,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
'action' => $existing ? 'update' : 'create',
'conflict' => false,
'restore_mode' => $restoreMode,
'integrity_warning' => $this->backupItemIntegrityWarning($item),
'validation_warning' => BackupItem::odataTypeWarning(
is_array($item->payload) ? $item->payload : [],
$item->policy_type,
@ -148,6 +150,8 @@ public function executeFromPolicyVersion(
'policy_version_id' => $version->id,
'policy_version_number' => $version->version_number,
'policy_id' => $policy->id,
'redaction_version' => $version->redaction_version,
'integrity_warning' => RedactionIntegrity::noteForPolicyVersion($version),
],
]);
@ -161,8 +165,22 @@ public function executeFromPolicyVersion(
'policy_version_id' => $version->id,
'policy_version_number' => $version->version_number,
'version_captured_at' => $version->captured_at?->toIso8601String(),
'redaction_version' => $version->redaction_version,
];
$integrityWarning = RedactionIntegrity::noteForPolicyVersion($version);
if ($integrityWarning !== null) {
$backupItemMetadata['integrity_warning'] = $integrityWarning;
}
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$protectedPathsCount = RedactionIntegrity::fingerprintCount($secretFingerprints);
if ($protectedPathsCount > 0) {
$backupItemMetadata['protected_paths_count'] = $protectedPathsCount;
}
$versionMetadata = is_array($version->metadata) ? $version->metadata : [];
$snapshotSource = $versionMetadata['source'] ?? null;
@ -247,6 +265,14 @@ public function executeForRun(
);
}
private function backupItemIntegrityWarning(BackupItem $item): ?string
{
$metadata = is_array($item->metadata) ? $item->metadata : [];
$warning = $metadata['integrity_warning'] ?? null;
return is_string($warning) && trim($warning) !== '' ? trim($warning) : null;
}
public function execute(
Tenant $tenant,
BackupSet $backupSet,

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Services\Intune;
use Illuminate\Support\Str;
final class SecretClassificationService
{
public const string REDACTED = '[REDACTED]';
public const int REDACTION_VERSION = 1;
/**
* @var array<int, string>
*/
private const array PROTECTED_FIELD_NAMES = [
'access_token',
'apikey',
'api_key',
'authorization',
'bearer',
'bearer_token',
'client_secret',
'cookie',
'password',
'presharedkey',
'pre_shared_key',
'private_key',
'refresh_token',
'sas_token',
'secret',
'set-cookie',
'shared_secret',
'token',
];
/**
* @var array<string, array<int, string>>
*/
private const array PROTECTED_JSON_POINTERS = [
'snapshot' => [
'/wifi/password',
'/authentication/clientSecret',
],
'assignments' => [],
'scope_tags' => [],
'audit' => [],
'verification' => [],
'ops_failure' => [],
];
public function protectsField(string $sourceBucket, string $fieldName, ?string $jsonPointer = null): bool
{
$fieldName = $this->normalizeFieldName($fieldName);
if ($fieldName === '') {
return false;
}
$protectedPointers = self::PROTECTED_JSON_POINTERS[$sourceBucket] ?? [];
if (is_string($jsonPointer) && in_array($jsonPointer, $protectedPointers, true)) {
return true;
}
return in_array($fieldName, self::PROTECTED_FIELD_NAMES, true);
}
public function sanitizeAuditString(string $value): string
{
return $this->sanitizeMessageLikeString($value, '[REDACTED]');
}
public function sanitizeOpsFailureString(string $value): string
{
$sanitized = $this->sanitizeMessageLikeString($value, '[REDACTED_SECRET]');
$sanitized = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $sanitized) ?? $sanitized;
return $sanitized;
}
private function sanitizeMessageLikeString(string $value, string $replacement): string
{
$patterns = [
'/\bAuthorization\s*:\s*Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/i',
'/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*/i',
'/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/',
];
foreach ($patterns as $pattern) {
$value = preg_replace($pattern, $replacement, $value) ?? $value;
}
foreach (self::PROTECTED_FIELD_NAMES as $fieldName) {
$quotedPattern = sprintf('/"%s"\s*:\s*"[^"]*"/i', preg_quote($fieldName, '/'));
$pairPattern = sprintf('/\b%s\b\s*[:=]\s*[^\s,;]+/i', preg_quote($fieldName, '/'));
$value = preg_replace($quotedPattern, sprintf('"%s":"%s"', $fieldName, $replacement), $value) ?? $value;
$value = preg_replace($pairPattern, sprintf('%s=%s', $fieldName, $replacement), $value) ?? $value;
}
return $value;
}
private function normalizeFieldName(string $fieldName): string
{
$fieldName = Str::of($fieldName)
->replace(['-', ' '], '_')
->snake()
->lower()
->toString();
return trim($fieldName);
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Intune;
use Illuminate\Support\Str;
use RuntimeException;
final class SecretFingerprintHasher
{
public function fingerprint(int $workspaceId, string $sourceBucket, string $jsonPointer, mixed $secretValue): string
{
if ($workspaceId <= 0) {
throw new RuntimeException('Workspace-scoped secret fingerprinting requires a valid workspace ID.');
}
$payload = implode('|', [
trim($sourceBucket),
trim($jsonPointer),
$this->normalizeSecretValue($secretValue),
]);
return hash_hmac('sha256', $payload, $this->workspaceKey($workspaceId));
}
private function workspaceKey(int $workspaceId): string
{
$appKey = (string) config('app.key', '');
if ($appKey === '') {
throw new RuntimeException('App key is required for secret fingerprinting.');
}
if (Str::startsWith($appKey, 'base64:')) {
$decoded = base64_decode(Str::after($appKey, 'base64:'), true);
if ($decoded !== false) {
$appKey = $decoded;
}
}
return hash_hmac('sha256', 'policy-version-secret-fingerprints|'.$workspaceId, $appKey, true);
}
private function normalizeSecretValue(mixed $secretValue): string
{
$normalized = $this->normalizeValue($secretValue);
return json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
private function normalizeValue(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->normalizeValue($item), $value);
}
ksort($value);
foreach ($value as $key => $item) {
$value[$key] = $this->normalizeValue($item);
}
return $value;
}
}

View File

@ -41,16 +41,25 @@ public function captureVersion(
?int $operationRunId = null,
?int $baselineProfileId = null,
): PolicyVersion {
$payload = $this->snapshotRedactor->redactPayload($payload);
$assignments = $this->snapshotRedactor->redactAssignments($assignments);
$scopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
$policy->loadMissing('tenant');
$workspaceId = is_numeric($policy->tenant?->workspace_id ?? null)
? (int) $policy->tenant?->workspace_id
: 0;
$protectedSnapshot = $this->snapshotRedactor->protect(
workspaceId: $workspaceId,
payload: $payload,
assignments: $assignments,
scopeTags: $scopeTags,
);
$version = null;
$versionNumber = null;
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
[$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags, $capturePurpose, $operationRunId, $baselineProfileId): array {
[$version, $versionNumber] = DB::transaction(function () use ($policy, $protectedSnapshot, $createdBy, $metadata, $capturePurpose, $operationRunId, $baselineProfileId): array {
// Serialize version number allocation per policy.
Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first();
@ -64,12 +73,14 @@ public function captureVersion(
'platform' => $policy->platform,
'created_by' => $createdBy,
'captured_at' => CarbonImmutable::now(),
'snapshot' => $payload,
'snapshot' => $protectedSnapshot->snapshot,
'metadata' => $metadata,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
'assignments' => $protectedSnapshot->assignments,
'scope_tags' => $protectedSnapshot->scopeTags,
'assignments_hash' => $protectedSnapshot->assignments !== null ? hash('sha256', json_encode($protectedSnapshot->assignments, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null,
'scope_tags_hash' => $protectedSnapshot->scopeTags !== null ? hash('sha256', json_encode($protectedSnapshot->scopeTags, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) : null,
'secret_fingerprints' => $protectedSnapshot->secretFingerprints,
'redaction_version' => $protectedSnapshot->redactionVersion,
'capture_purpose' => $capturePurpose->value,
'operation_run_id' => $operationRunId,
'baseline_profile_id' => $baselineProfileId,

View File

@ -4,6 +4,8 @@
namespace App\Support\Audit;
use App\Services\Intune\SecretClassificationService;
final class AuditContextSanitizer
{
private const REDACTED = '[REDACTED]';
@ -14,7 +16,7 @@ public static function sanitize(mixed $value): mixed
$sanitized = [];
foreach ($value as $key => $item) {
if (is_string($key) && self::shouldRedactKey($key)) {
if (is_string($key) && self::classifier()->protectsField('audit', $key)) {
$sanitized[$key] = self::REDACTED;
continue;
@ -33,18 +35,6 @@ public static function sanitize(mixed $value): mixed
return $value;
}
private static function shouldRedactKey(string $key): bool
{
$key = strtolower(trim($key));
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_contains($key, 'private_key')
|| str_contains($key, 'client_secret');
}
private static function sanitizeString(string $value): string
{
$candidate = trim($value);
@ -53,14 +43,11 @@ private static function sanitizeString(string $value): string
return $value;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $candidate)) {
return self::REDACTED;
return self::classifier()->sanitizeAuditString($value);
}
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
return self::REDACTED;
}
return $value;
private static function classifier(): SecretClassificationService
{
return app(SecretClassificationService::class);
}
}

View File

@ -7,8 +7,8 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\RedactionIntegrity;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Support\Str;
final class OperationUxPresenter
{
@ -83,6 +83,11 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
$body = $body."\n".$summary;
}
$integritySummary = RedactionIntegrity::noteForRun($run);
if (is_string($integritySummary) && trim($integritySummary) !== '') {
$body = $body."\n".trim($integritySummary);
}
$status = match ($uxStatus) {
'succeeded' => 'success',
'partial' => 'warning',
@ -113,12 +118,11 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
return null;
}
$failureMessage = Str::of($failureMessage)
->replace(["\r", "\n"], ' ')
->squish()
->toString();
$failureMessage = RunFailureSanitizer::sanitizeMessage($failureMessage);
$failureMessage = Str::limit($failureMessage, self::FAILURE_MESSAGE_MAX_CHARS, '…');
if (mb_strlen($failureMessage) > self::FAILURE_MESSAGE_MAX_CHARS) {
$failureMessage = mb_substr($failureMessage, 0, self::FAILURE_MESSAGE_MAX_CHARS - 1).'…';
}
return $failureMessage !== '' ? $failureMessage : null;
}

View File

@ -2,6 +2,7 @@
namespace App\Support\OpsUx;
use App\Services\Intune\SecretClassificationService;
use App\Support\Providers\ProviderReasonCodes;
final class RunFailureSanitizer
@ -118,28 +119,16 @@ public static function normalizeReasonCode(string $candidate): string
public static function sanitizeMessage(string $message): string
{
$message = trim(str_replace(["\r", "\n"], ' ', $message));
// Redact obvious PII (emails).
$message = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $message) ?? $message;
// Redact obvious auth headers.
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
// Redact common secret-like key/value patterns.
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
// Redact long opaque blobs that look token-like.
$message = self::classifier()->sanitizeOpsFailureString($message);
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
// Ensure forbidden substrings never leak into stored messages.
$message = str_ireplace(
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
'[REDACTED]',
$message,
);
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*=\s*\[REDACTED_SECRET\]/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"\[REDACTED_SECRET\]"/i', '"[REDACTED_SECRET]"', $message) ?? $message;
return substr($message, 0, 120);
}
private static function classifier(): SecretClassificationService
{
return app(SecretClassificationService::class);
}
}

View File

@ -40,7 +40,15 @@ public static function normalize(array $summaryCounts): array
}
}
return $sanitized;
$ordered = [];
foreach (OperationSummaryKeys::all() as $key) {
if (array_key_exists($key, $sanitized)) {
$ordered[$key] = $sanitized[$key];
}
}
return $ordered;
}
/**
@ -76,6 +84,13 @@ public static function renderSummaryLine(array $summaryCounts): ?string
*/
private static function humanizeKey(string $key): string
{
return ucfirst(str_replace('_', ' ', $key));
return match ($key) {
'items' => 'Affected items',
'tenants' => 'Tenants',
'finding_count' => 'Findings',
'report_count' => 'Reports',
'operation_count' => 'Operations',
default => ucfirst(str_replace('_', ' ', $key)),
};
}
}

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Services\Intune\SecretClassificationService;
final class RedactionIntegrity
{
public static function protectedValueNote(): string
{
return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.';
}
public static function noteForPolicyVersion(PolicyVersion $version): ?string
{
if (self::fingerprintCount($version->secret_fingerprints) > 0) {
return self::protectedValueNote();
}
return null;
}
/**
* @param array<string, mixed> $evidence
*/
public static function noteForFindingEvidence(array $evidence): ?string
{
$notes = self::findingEvidenceNotes($evidence);
return $notes === [] ? null : implode(' ', $notes);
}
public static function noteForRun(OperationRun $run): ?string
{
$context = is_array($run->context) ? $run->context : [];
$integrityNote = data_get($context, 'redaction_integrity.note');
if (is_string($integrityNote) && trim($integrityNote) !== '') {
return trim($integrityNote);
}
if ((bool) data_get($context, 'redaction_integrity.protected_values_hidden', false)) {
return self::protectedValueNote();
}
return null;
}
/**
* @param array<string, mixed>|null $report
* @return array<int, string>
*/
public static function verificationNotes(?array $report): array
{
if (! is_array($report)) {
return [];
}
if (! self::containsPlaceholderFragment($report)) {
return [];
}
return [self::protectedValueNote()];
}
/**
* @param array<string, mixed>|null $fingerprints
*/
public static function fingerprintCount(?array $fingerprints): int
{
if (! is_array($fingerprints)) {
return 0;
}
$count = 0;
foreach ($fingerprints as $bucket) {
if (! is_array($bucket)) {
continue;
}
foreach ($bucket as $digest) {
if (is_string($digest) && trim($digest) !== '') {
$count++;
}
}
}
return $count;
}
/**
* @param array<string, mixed> $evidence
*/
private static function evidenceHasProtectedData(array $evidence): bool
{
foreach (['baseline', 'current'] as $side) {
$fingerprints = data_get($evidence, "{$side}.secret_fingerprints");
if (is_array($fingerprints) && self::fingerprintCount($fingerprints) > 0) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $evidence
* @return array<int, string>
*/
private static function findingEvidenceNotes(array $evidence): array
{
$notes = [];
if (self::evidenceHasProtectedData($evidence)) {
$notes[] = self::protectedValueNote();
}
return array_values(array_unique($notes));
}
private static function containsPlaceholderFragment(mixed $value): bool
{
if (is_string($value)) {
return str_contains($value, SecretClassificationService::REDACTED);
}
if (! is_array($value)) {
return false;
}
foreach ($value as $item) {
if (self::containsPlaceholderFragment($item)) {
return true;
}
}
return false;
}
}

View File

@ -2,21 +2,10 @@
namespace App\Support\Verification;
use App\Services\Intune\SecretClassificationService;
final class VerificationReportSanitizer
{
/**
* @var array<int, string>
*/
private const FORBIDDEN_KEY_SUBSTRINGS = [
'access_token',
'refresh_token',
'client_secret',
'authorization',
'password',
'cookie',
'set-cookie',
];
/**
* Evidence pointers must remain pointer-only. This allowlist is intentionally strict.
*
@ -128,7 +117,7 @@ private static function sanitizeIdentity(array $identity): array
continue;
}
if (self::containsForbiddenKeySubstring($key)) {
if (self::classifier()->protectsField('verification', $key)) {
continue;
}
@ -273,10 +262,6 @@ private static function sanitizeEvidence(array $evidence): array
continue;
}
if (self::containsForbiddenKeySubstring($kind)) {
continue;
}
$value = $pointer['value'] ?? null;
if (is_int($value)) {
@ -354,7 +339,7 @@ private static function sanitizeUrlString(mixed $value, ?string $fallback): ?str
return $fallback;
}
if (self::containsForbiddenKeySubstring($value)) {
if (self::containsSecretQueryParameter($value)) {
return $fallback;
}
@ -380,22 +365,7 @@ private static function sanitizeMessage(mixed $message): string
}
$message = trim(str_replace(["\r", "\n"], ' ', $message));
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
$message = str_ireplace(
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
'[REDACTED]',
$message,
);
$message = trim($message);
$message = self::classifier()->sanitizeAuditString($message);
return $message === '' ? '—' : substr($message, 0, 240);
}
@ -412,10 +382,6 @@ private static function sanitizeShortString(mixed $value, ?string $fallback): ?s
return $fallback;
}
if (self::containsForbiddenKeySubstring($value)) {
return $fallback;
}
return substr($value, 0, 200);
}
@ -439,26 +405,20 @@ private static function sanitizeValueString(string $value): ?string
return null;
}
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
if (self::containsSecretQueryParameter($value)) {
return null;
}
}
return $value;
}
private static function containsForbiddenKeySubstring(string $value): bool
private static function containsSecretQueryParameter(string $value): bool
{
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return true;
}
return preg_match('/(?:[?&]|^)(access_token|refresh_token|client_secret|password|authorization)=/i', $value) === 1;
}
return false;
private static function classifier(): SecretClassificationService
{
return app(SecretClassificationService::class);
}
}

View File

@ -29,6 +29,12 @@ public function definition(): array
'captured_at' => now(),
'snapshot' => ['example' => true],
'metadata' => [],
'secret_fingerprints' => [
'snapshot' => [],
'assignments' => [],
'scope_tags' => [],
],
'redaction_version' => 1,
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
'operation_run_id' => null,
'baseline_profile_id' => null,

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('policy_versions')) {
return;
}
Schema::table('policy_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('policy_versions', 'secret_fingerprints')) {
$table->json('secret_fingerprints')->nullable()->after('scope_tags_hash');
}
if (! Schema::hasColumn('policy_versions', 'redaction_version')) {
$table->unsignedInteger('redaction_version')->nullable()->after('secret_fingerprints');
}
});
}
public function down(): void
{
if (! Schema::hasTable('policy_versions')) {
return;
}
Schema::table('policy_versions', function (Blueprint $table): void {
if (Schema::hasColumn('policy_versions', 'redaction_version')) {
$table->dropColumn('redaction_version');
}
if (Schema::hasColumn('policy_versions', 'secret_fingerprints')) {
$table->dropColumn('secret_fingerprints');
}
});
}
};

648
docs/HANDOVER.md Normal file
View File

@ -0,0 +1,648 @@
# TenantPilot / TenantAtlas — Handover Document
> **Generated**: 2026-03-06 · **Branch**: `dev` · **HEAD**: `da1adbd`
> **Stack**: Laravel 12 · Filament v5 · Livewire v4 · PostgreSQL 16 · Tailwind v4 · Pest 4
---
## Executive Summary
- **Product**: Enterprise Intune Governance SaaS — backup, version-control, restore, drift detection, and observability for Microsoft Intune policy configurations across multiple tenants.
- **Core value prop**: Immutable JSONB policy snapshots, restore wizard with dry-run/preview, inventory-first drift detection against Golden Master baselines, workspace-scoped RBAC, and alert/evidence pipeline.
- **28+ Intune policy types** supported (device config, settings catalog, compliance, app protection, conditional access, scripts, enrollment, endpoint security, update rings, etc.) — defined in [config/tenantpilot.php](config/tenantpilot.php).
- **Maturity**: Production-capable MVP with ~109 specs, 708 test files (582 Feature + 125 Unit + 1 Deprecation), extensive guard tests, and an architectural constitution document.
- **Three Filament panels**: `/admin` (workspace-tenant context), `/admin/t` (tenant-scoped), `/system` (platform operator console).
- **Workspace-first multi-tenancy**: All data is workspace-isolated. Tenants belong to workspaces. Non-members get 404 (deny-as-not-found).
- **Capability-first RBAC**: ~40+ capabilities in a canonical registry ([app/Support/Auth/Capabilities.php](app/Support/Auth/Capabilities.php)), enforced server-side with UI enforcement helpers.
- **Operations system**: Unified `OperationRun` model with 25+ run types, idempotent creation, stale-run detection, 3-surface feedback (toast → progress → DB notification).
- **Baseline/Drift engine**: 4-spec progression (116→119) — meta-fidelity v1 → content-fidelity v1.5 → full evidence capture v2 → legacy drift cutover. All 4 specs implemented on `dev`.
- **Key open areas**: Formal release grouping (R1/R2/R3) exists only as brainstorming; missing: exception/risk-acceptance workflows, compliance evidence packs, cross-tenant promotion, MSP portfolio dashboard, change approval workflows.
- **Biggest risk**: No `.env.example` in repo; Dokploy deployment config is external; no formal CI pipeline config in repo.
---
## Product & Roadmap Snapshot
### What exists (fully implemented on `dev`)
| Domain | Status | Key Specs |
|--------|--------|-----------|
| Policy Sync (28+ types) | ✅ Implemented | 001030 |
| Immutable PolicyVersions (JSONB) | ✅ Implemented | Core |
| BackupSets + BackupItems | ✅ Implemented | 005, 006, 016 |
| Restore Wizard (dry-run, preview, execute) | ✅ Implemented | 011, 023, 049 |
| Assignments backup/restore + group mapping | ✅ Implemented | 004, 006, 094 |
| Settings Catalog readable names | ✅ Implemented | 003, 045 |
| Inventory sync + coverage matrix | ✅ Implemented | 039042 |
| Dependency graph (inventory_links) | ✅ Implemented | 042, 079 |
| OperationRun unified monitoring | ✅ Implemented | 053, 054, 055, 078 |
| Provider connections + gateway | ✅ Implemented | 061, 081, 089, 108 |
| Entra OIDC sign-in | ✅ Implemented | 063, 064 |
| Workspace + Tenant RBAC | ✅ Implemented | 062, 065, 066, 070072 |
| Managed tenant onboarding wizard | ✅ Implemented | 073 |
| Verification checklist | ✅ Implemented | 074, 075, 084 |
| Backup scheduling + retention | ✅ Implemented | 032, 091 |
| Alerts v1 (Teams + Email) | ✅ Implemented | 099, 100 |
| Baseline governance (Golden Master) | ✅ Implemented | 101, 115119 |
| Findings lifecycle + SLA | ✅ Implemented | 044, 111 |
| Permission posture + Entra admin roles | ✅ Implemented | 104, 105 |
| Review pack export (CSV+ZIP) | ✅ Implemented | 109 |
| Settings (workspace + tenant) | ✅ Implemented | 097, 098 |
| System console + control tower | ✅ Implemented | 113, 114 |
| Platform ops runbooks | ✅ Implemented | 113 |
| Unified badges + tag catalog | ✅ Implemented | 059, 060 |
| Legacy purge (bulk ops, old runs) | ✅ Implemented | 056, 086088, 092 |
### Roadmap (from brainstorming — [specs/0800-future-features/brainstorming.md](specs/0800-future-features/brainstorming.md))
**Priority order**: MSP Portfolio + Alerting > Drift + Approvals > Standardisierung/Linting > Promotion DEV→PROD > Recovery Confidence
| Release | Theme | Items | Code exists? |
|---------|-------|-------|-------------|
| R1 "Golden Master Governance" | Baseline drift as production feature | Baseline capture/compare/alerts, findings SLA, evidence capture | ✅ Specs 101, 111, 115119 implemented |
| R1 cont. | Operations polish | OperationRun canonicalization, monitoring hub, action surfaces | ✅ Specs 078, 082, 090, 110 implemented |
| R2 "Tenant Reviews & Evidence" | Evidence packs, stored reports | Review packs, permission posture, Entra admin roles, stored reports | ✅ Partial — Specs 104, 105, 109 implemented; no formal "evidence pack" spec |
| R2 cont. | Exception/risk-acceptance workflow | Finding exceptions, risk acceptance tracking | ❌ Missing — Finding model has `risk_accepted` status but no exception entity |
| R2 cont. | Alert escalation + notification routing | Alert rules per event type, quiet hours, cooldown | ✅ Specs 099, 100 implemented |
| R3 "MSP Portfolio OS" | Cross-tenant compare, portfolio dashboard | Cross-tenant compare, MSP health dashboard, multi-workspace admin | ❌ Spec 043 exists (draft), no implementation |
| R3 cont. | Standardisierung / Policy linting | Policy quality checks, duplicate finder, orphaned items | ❌ Brainstorming only |
| Later | Change approval workflows | Approval gates before restore | ❌ Brainstorming only |
| Later | Recovery confidence | Automated restore tests, readiness report | ❌ Brainstorming only |
| Later | Script & secrets governance | Script diff/approval, secret scanning | ❌ Brainstorming only |
| Later | Security posture score | Blast radius, opt-in high-risk | ❌ Brainstorming only |
---
## Current Feature Map
### Implemented
| Feature | UI Entrypoint | Models | Key Jobs | DB Tables |
|---------|--------------|--------|----------|-----------|
| **Policy Sync** | `PolicyResource` | `Policy`, `PolicyVersion` | `SyncPoliciesJob`, `CapturePolicySnapshotJob` | `policies`, `policy_versions` |
| **Backup Sets** | `BackupSetResource` | `BackupSet`, `BackupItem` | `AddPoliciesToBackupSetJob`, `RemovePoliciesFromBackupSetJob` | `backup_sets`, `backup_items` |
| **Restore Wizard** | `RestoreRunResource` | `RestoreRun` | `ExecuteRestoreRunJob`, `RestoreAssignmentsJob` | `restore_runs` |
| **Backup Scheduling** | `BackupScheduleResource` | `BackupSchedule` | `RunBackupScheduleJob`, `ApplyBackupScheduleRetentionJob` | `backup_schedules` |
| **Inventory** | `InventoryItemResource`, `InventoryCoverage` page | `InventoryItem`, `InventoryLink` | `RunInventorySyncJob`, `ProviderInventorySyncJob` | `inventory_items`, `inventory_links` |
| **Findings** | `FindingResource` | `Finding` | `CompareBaselineToTenantJob`, `GeneratePermissionPostureFindingsJob` | `findings` |
| **Baselines** | `BaselineProfileResource`, `BaselineSnapshotResource`, `BaselineCompareLanding` | `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotItem`, `BaselineTenantAssignment` | `CaptureBaselineSnapshotJob`, `CompareBaselineToTenantJob` | `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments` |
| **Alerts** | `AlertRuleResource`, `AlertDestinationResource`, `AlertDeliveryResource` | `AlertRule`, `AlertDestination`, `AlertDelivery`, `AlertRuleDestination` | `Jobs/Alerts/*` | `alert_rules`, `alert_destinations`, `alert_deliveries`, `alert_rule_destinations` |
| **Operations Hub** | `Operations` page, `TenantlessOperationRunViewer` | `OperationRun` | All domain jobs | `operation_runs` |
| **Provider Connections** | `ProviderConnectionResource` | `ProviderConnection`, `ProviderCredential` | `ProviderConnectionHealthCheckJob` | `provider_connections`, `provider_credentials` |
| **RBAC** | `ChooseWorkspace`, `ChooseTenant`, `NoAccess`, `BreakGlassRecovery` | `Workspace`, `WorkspaceMembership`, `Tenant`, `TenantMembership`, `TenantRoleMapping` | `RefreshTenantRbacHealthJob` | `workspaces`, `workspace_memberships`, `tenants`, `tenant_memberships`, `tenant_role_mappings` |
| **Entra Groups** | `EntraGroupResource` | `EntraGroup` | `EntraGroupSyncJob` | `entra_groups` |
| **Verification** | `TenantDiagnostics`, `TenantRequiredPermissions` | `VerificationCheckAcknowledgement` | — | `verification_check_acknowledgements` |
| **Permission Posture** | Via `FindingResource` | `Finding`, `StoredReport` | `GeneratePermissionPostureFindingsJob`, `ScanEntraAdminRolesJob` | `findings`, `stored_reports` |
| **Review Packs** | `ReviewPackResource` | `ReviewPack` | `GenerateReviewPackJob` | `review_packs` |
| **Settings** | `WorkspaceSettings` page | `WorkspaceSetting`, `TenantSetting` | — | `workspace_settings`, `tenant_settings` |
| **Audit Log** | `AuditLog` page | `AuditLog` | — | `audit_logs` |
| **System Console** | `/system/dashboard`, `/system/ops/runbooks` | `PlatformUser` | — | `platform_users` |
### Partial / In-Progress
| Feature | Status | Gap |
|---------|--------|-----|
| **Cross-tenant compare** | Spec 043 drafted | No implementation code |
| **Policy lifecycle** | Spec 900 drafted | Addresses ghost / orphaned policies — not yet implemented |
| **Exception/Risk acceptance** | Finding has `risk_accepted` status | No formal exception entity, no evidence attachment workflow |
### Missing (no code, no spec beyond brainstorming)
- Change approval workflows / approval gates
- Policy linting / standardization engine
- Recovery confidence / automated restore tests
- Script & secrets governance
- Security posture scoring / blast radius
- MSP portfolio health dashboard
- Compliance light (formal compliance framework mapping)
---
## Architecture & Principles (Non-Negotiables)
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.10.0)
### Core Principles
1. **Inventory-first, Snapshots-second**`InventoryItem` = last observed meta; `PolicyVersion.snapshot` = explicit immutable JSONB capture.
2. **Read/Write Separation** — Analysis is read-only; writes require preview → dry-run → confirmation → audit log.
3. **Single Contract Path to Graph** — All Microsoft Graph calls via `GraphClientInterface` ([app/Services/Graph/GraphClientInterface.php](app/Services/Graph/GraphClientInterface.php)); endpoints modeled in [config/graph_contracts.php](config/graph_contracts.php) (867 lines, 28+ type definitions).
4. **Deterministic Capabilities** — Backup/restore/risk flags derived from config via `CoverageCapabilitiesResolver`.
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
### RBAC-UX Rules
| Rule | Description |
|------|-------------|
| UX-001 | Server-side is source of truth (UI is never security boundary) |
| UX-002 | Non-members get 404 (deny-as-not-found) |
| UX-003 | Members without capability get 403 |
| UX-004 | Actions visible-but-disabled for members lacking capability; hidden for non-members |
| UX-005 | All destructive actions require `->requiresConfirmation()` |
| UX-006 | Capabilities from canonical registry only ([app/Support/Auth/Capabilities.php](app/Support/Auth/Capabilities.php)) |
| UX-007 | Global search must be tenant-safe |
| UX-008 | RBAC regression tests mandatory |
### Operations UX
- **3-surface feedback**: Toast (immediate) → Progress widget (polling) → DB notification (terminal).
- **OperationRun lifecycle**: Service-owned transitions only via `OperationRunService` — no direct status writes.
- **Idempotent creation**: Hash-based dedup with partial unique index.
### Filament Standards
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090).
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
- **No naked forms**: Everything in sections/cards with proper enterprise IA.
### Provider Gateway
- `ProviderGateway` ([app/Services/Providers/ProviderGateway.php](app/Services/Providers/ProviderGateway.php)) — facade over Graph client.
- `ProviderConnectionResolver` resolves default connection per tenant+provider pair with validation (scope, status, credentials).
- `ProviderOperationStartGate` — pre-flight checks before any Graph write.
- `IntuneRbacWriteGate` ([app/Services/Hardening/IntuneRbacWriteGate.php](app/Services/Hardening/IntuneRbacWriteGate.php)) — freshness check before destructive Graph writes.
---
## Data Model Overview
### Core Tables
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `workspaces` | `id`, `name`, `slug`, `archived_at` | Primary isolation boundary |
| `workspace_memberships` | `workspace_id`, `user_id`, `role` | Roles: Owner/Manager/Operator/Readonly |
| `tenants` | `id`, `workspace_id`, `tenant_id`, `external_id`, `name`, `status`, `is_current` | SoftDeletes; `external_id` = Azure tenant GUID |
| `tenant_memberships` | `tenant_id`, `user_id`, `role`, `created_by_user_id` | UUID PK |
| `users` | Standard + `entra_id`, `entra_name`, `is_platform_superadmin` | SoftDeletes |
| `platform_users` | Separate from `users` | `/system` panel auth guard |
### Policy & Versioning
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `policies` | `id`, `workspace_id`, `tenant_id`, `external_id`, `policy_type`, `display_name`, `odata_type`, `metadata` (JSONB), `ignored_at` | One row per Intune policy per tenant |
| `policy_versions` | `id`, `workspace_id`, `tenant_id`, `policy_id`, `version_number`, `snapshot` (JSONB), `metadata` (JSONB), `assignments` (JSONB), `content_hash`, `captured_at`, `capture_purpose` | Immutable snapshots; SoftDeletes |
| `backup_sets` | `id`, `workspace_id`, `tenant_id`, `name`, `status`, `metadata` (JSONB) | SoftDeletes |
| `backup_items` | `id`, `workspace_id`, `backup_set_id`, `policy_id`, `policy_version_id` | Links backup set to specific version |
| `restore_runs` | `id`, `workspace_id`, `tenant_id`, `backup_set_id`, `operation_run_id`, `status`, `is_dry_run`, `idempotency_key`, `requested_items` (JSONB), `preview` (JSONB), `results` (JSONB), `group_mapping` (JSONB) | SoftDeletes |
### Inventory & Dependencies
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `inventory_items` | `id`, `workspace_id`, `tenant_id`, `policy_type`, `external_id`, `display_name`, `meta_jsonb` (JSONB), `last_seen_at`, `last_seen_operation_run_id` | Current state from Graph |
| `inventory_links` | `id`, `workspace_id`, `source_type`, `source_id` (text), `target_type`, `target_id` (text), `relationship_type` | Dependency graph edges |
### Operations
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `operation_runs` | `id`, `workspace_id`, `tenant_id`, `user_id`, `type`, `status` (queued/running/completed), `outcome` (pending/succeeded/partially_succeeded/blocked/failed), `summary_counts` (JSONB), `failure_summary` (JSONB), `context` (JSONB), `started_at`, `completed_at` | Central run tracking for all async operations |
| `audit_logs` | `id`, `workspace_id`, `tenant_id`, `user_id`, `action`, `details` (JSONB) | Immutable audit trail |
### Baselines
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `baseline_profiles` | `id`, `workspace_id`, `name`, `status` (draft/active/archived), `capture_mode`, `scope_jsonb` (JSONB), `active_snapshot_id` | Golden Master definition |
| `baseline_snapshots` | `id`, `baseline_profile_id`, `operation_run_id`, `tenant_id`, `version_label`, `item_count` | Point-in-time snapshot |
| `baseline_snapshot_items` | `id`, `baseline_snapshot_id`, `policy_type`, `external_id`, `display_name`, `content_hash`, `meta_jsonb` (JSONB), `subject_key` | Individual policy item in snapshot |
| `baseline_tenant_assignments` | `id`, `baseline_profile_id`, `tenant_id` | Which tenants are compared against this baseline |
### Findings
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `findings` | `id`, `workspace_id`, `tenant_id`, `finding_type` (drift/permission_posture/entra_admin_roles), `severity`, `status`, `source`, `title`, `description`, `evidence_jsonb` (JSONB), `recurrence_key`, `fingerprint`, `first_seen_at`, `last_seen_at`, `times_seen`, `sla_days`, `due_at`, `evidence_fidelity`, `baseline_operation_run_id`, `current_operation_run_id` | Full lifecycle: new → triaged → in_progress → resolved/closed/risk_accepted |
### Alerts
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `alert_destinations` | `id`, `workspace_id`, `type` (teams_webhook/email), `config` (JSONB) | Where alerts go |
| `alert_rules` | `id`, `workspace_id`, `event_type`, `is_enabled`, `tenant_scope_mode`, `tenant_allowlist` (JSONB), `cooldown_seconds`, `quiet_hours_*` | When to alert |
| `alert_rule_destinations` | `alert_rule_id`, `alert_destination_id`, `workspace_id` | M:N join with workspace scope |
| `alert_deliveries` | `id`, `workspace_id`, `alert_rule_id`, `tenant_id`, `status`, `attempts`, `payload` (JSONB) | Delivery tracking with retry |
### Provider
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `provider_connections` | `id`, `workspace_id`, `tenant_id`, `provider`, `external_tenant_id`, `is_default`, `status`, `scopes_granted` (JSONB), `last_health_check_at` | Graph API connection per tenant |
| `provider_credentials` | `id`, `provider_connection_id`, `credential_type`, `client_id`, `client_secret` (encrypted) | Secrets storage |
### Other
| Table | Notes |
|-------|-------|
| `stored_reports` | Permission posture + Entra admin roles reports, fingerprint dedup |
| `review_packs` | Exportable audit artifact (ZIP), signed download URLs |
| `backup_schedules` | Cron-based backup scheduling with retention keep_last |
| `entra_groups` | Cached directory groups for assignment mapping |
| `entra_role_definitions` | Cached Entra role definitions for admin roles evidence |
| `settings_catalog_definitions` / `settings_catalog_categories` | Settings catalog human-readable name mapping |
| `workspace_settings` / `tenant_settings` | Key-value settings with typed slices |
---
## Operations & Observability
### OperationRun System
- **Central model**: `OperationRun` ([app/Models/OperationRun.php](app/Models/OperationRun.php))
- **Type registry**: `OperationRunType` enum (25+ types) ([app/Support/OperationRunType.php](app/Support/OperationRunType.php))
- **Catalog**: `OperationCatalog` — labels, expected durations, allowed summary keys ([app/Support/OperationCatalog.php](app/Support/OperationCatalog.php))
- **Lifecycle service**: `OperationRunService` (877 lines) — idempotent `ensureRun()`, stale detection, state transitions, summary normalization ([app/Services/OperationRunService.php](app/Services/OperationRunService.php))
- **Status**: `queued``running``completed`
- **Outcome**: `pending` | `succeeded` | `partially_succeeded` | `blocked` | `failed` | `cancelled` (reserved)
- **Context**: JSONB bag storing `selection_hash`, `policy_types`, `categories`, etc.
### Monitoring UI
- **Operations page**: `/admin/operations` — workspace-scoped, tenantless canonical view ([app/Filament/Pages/Monitoring/Operations.php](app/Filament/Pages/Monitoring/Operations.php))
- **Run viewer**: `/admin/operations/{run}` — detail view per run ([app/Filament/Pages/Operations/TenantlessOperationRunViewer.php](app/Filament/Pages/Operations/TenantlessOperationRunViewer.php))
- **Audit log**: `/admin/audit-log` — immutable event log ([app/Filament/Pages/Monitoring/AuditLog.php](app/Filament/Pages/Monitoring/AuditLog.php))
### Scheduled Tasks ([routes/console.php](routes/console.php))
| Schedule | Frequency | Job |
|----------|-----------|-----|
| Backup schedule dispatch | Every minute | `tenantpilot:schedules:dispatch` |
| Directory groups dispatch | Every minute | `tenantpilot:directory-groups:dispatch` |
| Alerts dispatch | Every minute (no overlap) | `tenantpilot:alerts:dispatch` |
| Prune old operations | Daily | `PruneOldOperationRunsJob` |
| Reconcile adapter runs | Every 30 min | `ReconcileAdapterRunsJob` |
| Stored reports prune | Daily | `stored-reports:prune` |
| Review pack prune | Daily | `tenantpilot:review-pack:prune` |
| Baseline evidence prune | Daily | `tenantpilot:baseline-evidence:prune` |
| Entra admin roles scan | Daily (per tenant) | `ScanEntraAdminRolesJob` |
---
## Security: Tenancy / RBAC / Auth
### Three-panel architecture
| Panel | Path | Guard | Purpose |
|-------|------|-------|---------|
| Admin | `/admin` | `web` | Workspace + Tenant management (main UI) |
| Tenant | `/admin/t` | `web` | Tenant-scoped views (within admin session) |
| System | `/system` | `platform` | Platform operator console (separate cookies) |
### Auth flows
- **Entra OIDC**: `/auth/entra/redirect``/auth/entra/callback` (via Socialite)
- **Admin consent**: `/admin/consent/start``/admin/consent/callback` (app registration)
- **RBAC delegated auth**: `/admin/rbac/start``/admin/rbac/callback`
- **Break-glass**: `/system` panel with `BreakGlassRecovery` page, config-gated TTL
### Role hierarchy
| Level | Roles | Source |
|-------|-------|--------|
| Workspace | Owner / Manager / Operator / Readonly | [app/Support/Auth/WorkspaceRole.php](app/Support/Auth/WorkspaceRole.php) |
| Tenant | Owner / Manager / Operator / Readonly | [app/Support/TenantRole.php](app/Support/TenantRole.php) |
| Platform | Capabilities: `access_system_panel`, `use_break_glass`, `console.*`, `ops.*`, `runbooks.*` | [app/Support/Auth/PlatformCapabilities.php](app/Support/Auth/PlatformCapabilities.php) |
### Capability resolver chain
- `Capabilities::all()` — reflection-based registry of ~40+ tenant/workspace capabilities
- `UiEnforcement` ([app/Support/Rbac/UiEnforcement.php](app/Support/Rbac/UiEnforcement.php)) — builder pattern for Filament actions: `forAction()->requireMembership()->requireCapability()->destructive()`
- `WorkspaceUiEnforcement` — mirror for workspace-scoped actions
- **12 Laravel Policy classes** in [app/Policies/](app/Policies/)
### Workspace isolation enforcement
- `DerivesWorkspaceIdFromTenant` concern on models — auto-fills `workspace_id`
- DB-level: `NOT NULL` + FK constraints on `workspace_id` for all tenant-owned tables (migration `2026_02_14_220114`)
- Check constraints on `audit_logs` (migration `2026_02_14_220117`)
- Middleware: `ensure-workspace-selected`, `ensure-workspace-member`, `DenyNonMemberTenantAccess`
---
## Drift/Baseline/Findings: Current State & Gaps
### Baseline Drift Engine (Specs 116119, all merged to `dev`)
| Spec | Title | Status |
|------|-------|--------|
| 116 | Meta-fidelity + coverage guard | ✅ Merged |
| 117 | Content-fidelity via provider chain (v1.5) | ✅ Merged |
| 118 | Full content capture (v2) | ✅ Merged |
| 119 | Drift cutover — legacy drift removal | ✅ Merged |
### Architecture
1. **Capture**: `CaptureBaselineSnapshotJob``BaselineCaptureService` captures inventory metadata + optional full content evidence into `baseline_snapshot_items`.
2. **Compare**: `CompareBaselineToTenantJob``BaselineCompareService` compares snapshot against current tenant state. `CurrentStateEvidenceProvider` chain resolves best available evidence (content > meta).
3. **Findings**: Creates/updates/auto-resolves `Finding` records with `source = 'baseline.compare'`, `finding_type = 'drift'`.
4. **Evidence fidelity**: `evidence_fidelity` field on findings tracks whether comparison was meta-level or content-level.
5. **Guard test**: [tests/Feature/Guards/Spec116OneEngineGuardTest.php](tests/Feature/Guards/Spec116OneEngineGuardTest.php), [tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php](tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php) — prevent regression to legacy drift patterns.
### Findings lifecycle
- Statuses: `new``acknowledged``triaged``in_progress``resolved` / `closed` / `risk_accepted``reopened`
- SLA: `FindingSlaPolicy` computes `due_at` from workspace `findings.sla_days` setting
- Auto-resolve: `BaselineAutoCloseService` resolves findings when drift disappears
- Recurrence: `recurrence_key` + `times_seen` + `first_seen_at` / `last_seen_at` track repeating drift
### Gaps
- **Exception entity**: No formal `FindingException` model — only the `risk_accepted` status exists
- **Evidence pack**: No formal evidence export spec as a standalone artifact (review pack covers partial ground)
- **Cross-tenant compare**: Spec 043 drafted, not implemented — uses `policy_type + normalized display_name` matching
---
## Testing & Tooling
### Test suite
- **Framework**: Pest 4 with `RefreshDatabase`
- **DB**: SQLite `:memory:` (via phpunit.xml)
- **Test files**: 582 Feature + 125 Unit + 1 Deprecation = **708 total**
- **Infrastructure**: [tests/Pest.php](tests/Pest.php) (366 lines) — shared helpers: `createUserWithTenant()`, `fakeIdToken()`, `bindFailHardGraphClient()`, etc.
### Key test categories
| Category | Path | Count (approx) |
|----------|------|-------|
| Guard tests | `tests/Feature/Guards/` | 14 files — enforce architectural rules via code scanning |
| RBAC tests | `tests/Feature/Rbac/`, `tests/Feature/TenantRBAC/` | ~15 files |
| Baseline/Drift | `tests/Feature/BaselineDriftEngine/`, `tests/Feature/Baselines/`, `tests/Feature/Drift/` | ~20 files |
| Operations | `tests/Feature/Operations/`, `tests/Feature/Monitoring/` | ~10 files |
| Bulk operations | `tests/Feature/Bulk*Test.php` | ~20 files |
| Restore | `tests/Feature/Restore*Test.php` | ~15 files |
| Provider/Graph | `tests/Feature/ProviderConnections/`, `tests/Feature/Graph/`, `tests/Unit/Graph*` | ~15 files |
| Alerts | `tests/Feature/Alerts/`, `tests/Unit/Alerts/` | ~10 files |
| Inventory | `tests/Feature/Inventory/`, `tests/Unit/Inventory/` | ~15 files |
| Workspace isolation | `tests/Feature/WorkspaceIsolation/` | ~5 files |
| Deprecation | `tests/Deprecation/` | 1 file (is_platform_superadmin ban) |
### Guard tests (architectural enforcement)
These tests scan source code to prevent regressions:
| Test | Enforces |
|------|----------|
| `ActionSurfaceContractTest` | All resources have required action surfaces |
| `NoAdHocFilamentAuthPatternsTest` | No ad-hoc auth patterns in Filament |
| `NoAdHocStatusBadgesTest` | Use badge catalog, not ad-hoc badges |
| `NoLegacyRunsTest` | No legacy run patterns in codebase |
| `NoLegacyTenantGraphOptionsTest` | No legacy graph options usage |
| `NoTenantCredentialRuntimeReadsSpec081Test` | No runtime credential reads |
| `Spec116OneEngineGuardTest` | Single drift engine architecture |
| `Spec118NoLegacyBaselineDriftGuardTest` | No legacy baseline drift code |
### Tooling
| Tool | Command | Notes |
|------|---------|-------|
| Run all tests | `vendor/bin/sail artisan test --compact` | |
| Run specific file | `vendor/bin/sail artisan test --compact tests/Feature/SomeTest.php` | |
| Filter test | `vendor/bin/sail artisan test --compact --filter=testName` | |
| Pint formatter | `vendor/bin/sail bin pint --dirty` | Default Laravel preset |
| No `.pint.json` | Uses default rules | |
| No PHPStan config found | UNKNOWN — check if integrated | |
---
## Deployment & Runbooks
### Local development
```bash
# Start services
vendor/bin/sail up -d
# Run migrations
vendor/bin/sail artisan migrate
# Start queue worker (or use docker-compose queue service)
vendor/bin/sail artisan queue:work --tries=3 --timeout=300
# Build frontend assets
vendor/bin/sail npm run build
# Run tests
vendor/bin/sail artisan test --compact
```
### Docker services ([docker-compose.yml](docker-compose.yml))
| Service | Image | Ports |
|---------|-------|-------|
| `laravel.test` | PHP 8.4 Sail | 80, 5173 (Vite) |
| `queue` | Same image | `queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000` |
| `pgsql` | PostgreSQL 16 | 5432 |
| `redis` | Redis 7 Alpine | 6379 |
### Deployment: Dokploy (staging → production)
- **No Dokploy config in repo** — configuration lives on the VPS per [Agents.md](Agents.md)
- **Two environments**: Staging (mandatory gate) → Production
- **Required env vars** (partial — no `.env.example`):
- Standard Laravel: `APP_KEY`, `DB_*`, `REDIS_*`, `QUEUE_CONNECTION`
- Entra auth: `AZURE_AD_CLIENT_ID`, `AZURE_AD_CLIENT_SECRET`, `AZURE_AD_TENANT_ID`
- Feature flags: `BREAK_GLASS_ENABLED`, `ALLOW_ADMIN_MAINTENANCE_ACTIONS`
- Alerts: `TENANTPILOT_ALERTS_ENABLED`
- Baselines: `TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED`
- Write gate: `TENANTPILOT_INTUNE_WRITE_GATE_ENABLED`
- **Deploy checklist**: `php artisan migrate`, `php artisan filament:assets`, queue restart
### Platform runbooks
- `FindingsLifecycleBackfillRunbookService` ([app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php](app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php)) — safe backfill of findings lifecycle fields
- Accessible at `/system/ops/runbooks` with platform capabilities
---
## Risks & Gaps (Top 10)
| # | Risk | Location | Impact | Suggested Action |
|---|------|----------|--------|-----------------|
| 1 | **No `.env.example`** | Repo root | New dev can't set up without tribal knowledge | Create `.env.example` with all required keys from `config/*.php` |
| 2 | **No CI pipeline config in repo** | Missing | No automated test gating on PRs | Add Gitea CI / GitHub Actions config |
| 3 | **No PHPStan/Larastan** | Missing | Type-safety gaps undetected | UNKNOWN — check if integrated; if not, add baseline |
| 4 | **Exception/risk-acceptance workflow incomplete** | `Finding.STATUS_RISK_ACCEPTED` exists, no exception entity | Can't track formal risk acceptances with evidence | Spec needed for `FindingException` model with approval chain |
| 5 | **SQLite for tests vs PostgreSQL in prod** | [phpunit.xml](phpunit.xml) | JSONB, GIN indexes, PG-specific features untested | Add [phpunit.pgsql.xml](phpunit.pgsql.xml) — file exists but may not be in CI |
| 6 | **No formal release process** | Roadmap only in brainstorming doc | No versioned release artifacts, no changelog | Define release cadence and changelog automation |
| 7 | **Dokploy config external** | Per [Agents.md](Agents.md) | Deployment not reproducible from repo alone | Document or codify Dokploy config |
| 8 | **Cross-tenant compare unimplemented** | Spec 043 draft only | Key MSP feature missing | Prioritize for next sprint if MSP use case is active |
| 9 | **Policy lifecycle / ghost policies** | Spec 900 draft only | Deleted-in-Intune policies persist indefinitely | Implement orphan detection + soft-archive flow |
| 10 | **Break-glass recovery surface** | Config-gated, TTL-limited | Limited testing evidence | Verify break-glass flow has smoke tests in test suite |
---
## Next Actions (Top 5)
1. **Create `.env.example`** — Document all required env vars from `config/*.php` files. Low effort, high impact for onboarding.
2. **Add CI pipeline** — Gitea / runner config for `vendor/bin/sail artisan test --compact` + `vendor/bin/sail bin pint --test` on every PR. Critical for sustained quality.
3. **Formal exception/risk-acceptance spec** — Design `FindingException` entity with approval chain, evidence attachment, and UI workflow. Blocks R2 "Evidence Packs" roadmap item.
4. **PostgreSQL test mode** — Enable [phpunit.pgsql.xml](phpunit.pgsql.xml) in CI for a subset of tests that exercise JSONB/GIN-specific behavior.
5. **Cross-tenant compare implementation** (Spec 043) — Build read-only compare view first (N=2 tenants). This is the first step toward the MSP portfolio use case.
---
## Appendix
### Specs Index (109 specs)
| # | Slug | Status |
|---|------|--------|
| 001 | rbac-onboarding | ✅ Implemented |
| 003 | settings-catalog-readable | ✅ Implemented |
| 004 | assignments-scope-tags | ✅ Implemented |
| 005 | bulk-operations | ✅ Implemented |
| 006 | sot-foundations-assignments | ✅ Implemented |
| 007 | device-config-compliance | ✅ Implemented |
| 008 | apps-app-management | ✅ Implemented |
| 009 | app-protection-policy | ✅ Implemented |
| 011 | restore-run-wizard | ✅ Implemented |
| 012 | windows-update-rings | ✅ Implemented |
| 013 | scripts-management | ✅ Implemented |
| 014 | enrollment-autopilot | ✅ Implemented |
| 017 | policy-types-mam-endpoint-security-baselines | ✅ Implemented |
| 018 | driver-updates-wufb | ✅ Implemented |
| 023 | endpoint-security-restore | ✅ Implemented |
| 024 | terms-and-conditions | ✅ Implemented |
| 026 | custom-compliance-scripts | ✅ Implemented |
| 027 | enrollment-config-subtypes | ✅ Implemented |
| 028 | device-categories | ✅ Implemented |
| 029 | wip-policies | ✅ Implemented |
| 030 | intune-rbac-backup | ✅ Implemented |
| 031 | tenant-portfolio-context-switch | ✅ Implemented |
| 039 | inventory-program | ✅ Meta-spec (040044) |
| 040 | inventory-core | ✅ Implemented |
| 041 | inventory-ui | ✅ Implemented |
| 043 | cross-tenant-compare-and-promotion | 📋 Draft |
| 045 | settingscatalog-classification | ✅ Implemented |
| 049 | backup-restore-job-orchestration | ✅ Implemented |
| 051 | entra-group-directory-cache | ✅ Implemented |
| 052 | async-add-policies | ✅ Implemented |
| 053 | unify-runs-monitoring | ✅ Implemented |
| 054 | unify-runs-suitewide | ✅ Implemented |
| 055 | ops-ux-rollout | ✅ Implemented |
| 058 | tenant-ui-polish | ✅ Implemented |
| 059 | unified-badges | ✅ Implemented |
| 060 | tag-badge-catalog | ✅ Implemented |
| 061 | provider-foundation | ✅ Implemented |
| 062 | tenant-rbac-v1 | ✅ Implemented |
| 063 | entra-signin | ✅ Implemented |
| 064 | auth-structure | ✅ Implemented |
| 065 | tenant-rbac-v1 | ✅ Implemented |
| 070 | workspace-create-membership-fix | ✅ Implemented |
| 071 | tenant-selection-workspace-scope | ✅ Implemented |
| 072 | managed-tenants-workspace-enforcement | ✅ Implemented |
| 074 | verification-checklist | ✅ Implemented |
| 077 | workspace-nav-monitoring-hub | ✅ Implemented |
| 078 | operations-tenantless-canonical | ✅ Implemented |
| 079 | inventory-links-non-uuid-ids | ✅ Implemented |
| 081 | provider-connection-cutover | ✅ Implemented |
| 084 | verification-surfaces-unification | ✅ Implemented |
| 085 | tenant-operate-hub | ✅ Implemented |
| 086 | retire-legacy-runs-into-operation-runs | ✅ Implemented |
| 087 | legacy-runs-removal | ✅ Implemented |
| 088 | remove-tenant-graphoptions-legacy | ✅ Implemented |
| 089 | provider-connections-tenantless-ui | ✅ Implemented |
| 091 | backupschedule-retention-lifecycle | ✅ Implemented |
| 092 | legacy-purge-final | ✅ Implemented |
| 093 | scope-001-workspace-id-isolation | ✅ Implemented |
| 094 | assignment-ops-observability-hardening | ✅ Implemented |
| 095 | graph-contracts-registry-completeness | ✅ Implemented |
| 096 | ops-polish-assignment-dedupe-system-tracking | ✅ Implemented |
| 097 | settings-foundation | ✅ Implemented |
| 098 | settings-slices-v1-backup-drift-ops | ✅ Implemented |
| 099 | alerts-v1-teams-email | ✅ Implemented |
| 100 | alert-target-test-actions | ✅ Implemented |
| 101 | golden-master-baseline-governance-v1 | ✅ Implemented |
| 102 | filament-5-2-1-upgrade | ✅ Implemented |
| 103 | ia-scope-filter-semantics | ✅ Implemented |
| 104 | provider-permission-posture | ✅ Implemented |
| 105 | entra-admin-roles-evidence-findings | ✅ Implemented |
| 106 | required-permissions-sidebar-context | ✅ Implemented |
| 107 | workspace-chooser | ✅ Implemented |
| 108 | provider-access-hardening | ✅ Implemented |
| 109 | review-pack-export | ✅ Implemented |
| 110 | ops-ux-enforcement | ✅ Implemented |
| 111 | findings-workflow-sla | ✅ Implemented |
| 112 | list-expand-parity | ✅ Implemented |
| 113 | platform-ops-runbooks | ✅ Implemented |
| 114 | system-console-control-tower | ✅ Implemented |
| 115 | baseline-operability-alerts | ✅ Implemented |
| 116 | baseline-drift-engine (meta v1) | ✅ Implemented |
| 117 | baseline-drift-engine (content v1.5) | ✅ Implemented |
| 118 | baseline-drift-engine (full capture v2) | ✅ Implemented |
| 119 | baseline-drift-engine (cutover) | ✅ Implemented |
| 700 | bugfix | ✅ Implemented |
| 900 | policy-lifecycle | 📋 Draft |
| 0800 | future-features (brainstorming) | 📋 Brainstorming |
> Specs not listed above (e.g., 002, 010, 015, 016, 025, 032, 042, 044, 046048, 056057, 066067, 073, 075076, 080, 082083, 090, 999) have spec directories but were not individually verified. Based on merge history and code presence, the vast majority are implemented.
### Key File Map (Top 20 files to read first)
| File | Why |
|------|-----|
| [.specify/memory/constitution.md](.specify/memory/constitution.md) | Non-negotiable architectural rules |
| [Agents.md](Agents.md) | Agent workflow, branching, environment |
| [config/tenantpilot.php](config/tenantpilot.php) | 28+ policy types, feature flags, all config |
| [config/graph_contracts.php](config/graph_contracts.php) | Graph API contract registry (867 lines) |
| [app/Support/Auth/Capabilities.php](app/Support/Auth/Capabilities.php) | Canonical RBAC capability registry |
| [app/Support/OperationRunType.php](app/Support/OperationRunType.php) | All operation run types |
| [app/Support/OperationCatalog.php](app/Support/OperationCatalog.php) | Labels, durations, summary keys |
| [app/Services/OperationRunService.php](app/Services/OperationRunService.php) | Central run lifecycle (877 lines) |
| [app/Support/Rbac/UiEnforcement.php](app/Support/Rbac/UiEnforcement.php) | RBAC UI enforcement builder (678 lines) |
| [app/Services/Providers/ProviderGateway.php](app/Services/Providers/ProviderGateway.php) | Graph API facade |
| [app/Services/Providers/ProviderConnectionResolver.php](app/Services/Providers/ProviderConnectionResolver.php) | Connection resolution + validation |
| [app/Services/Baselines/BaselineCompareService.php](app/Services/Baselines/BaselineCompareService.php) | Drift comparison engine |
| [app/Services/Baselines/BaselineCaptureService.php](app/Services/Baselines/BaselineCaptureService.php) | Snapshot capture |
| [app/Models/Finding.php](app/Models/Finding.php) | Finding lifecycle model |
| [app/Models/OperationRun.php](app/Models/OperationRun.php) | Central run model |
| [app/Models/Tenant.php](app/Models/Tenant.php) | Tenant model (320 lines) |
| [routes/web.php](routes/web.php) | All routes (3 panels, auth, workspace) |
| [routes/console.php](routes/console.php) | Scheduler definitions |
| [tests/Pest.php](tests/Pest.php) | Test helpers & infrastructure |
| [specs/0800-future-features/brainstorming.md](specs/0800-future-features/brainstorming.md) | Future roadmap ideas |
### Key Commands
```bash
# Local dev
vendor/bin/sail up -d # Start all services
vendor/bin/sail artisan migrate # Run migrations
vendor/bin/sail artisan queue:work --tries=3 # Process queue
vendor/bin/sail npm run build # Build frontend
# Testing
vendor/bin/sail artisan test --compact # Full suite
vendor/bin/sail artisan test --compact --filter=testName # Single test
vendor/bin/sail artisan test --compact tests/Feature/Guards/ # Guard tests only
# Code quality
vendor/bin/sail bin pint --dirty # Format changed files
# Deployment
vendor/bin/sail artisan filament:assets # Publish Filament assets
vendor/bin/sail artisan migrate --force # Production migration
```
### Quickstart (exact steps)
1. Clone repo, `cd TenantAtlas`
2. `cp .env.example .env` (MISSING — create from config files or request from team)
3. `composer install`
4. `vendor/bin/sail up -d`
5. `vendor/bin/sail artisan key:generate`
6. `vendor/bin/sail artisan migrate`
7. `vendor/bin/sail npm install && vendor/bin/sail npm run build`
8. `vendor/bin/sail artisan filament:assets`
9. Login: Navigate to `/admin` — redirects to workspace chooser. For first-time setup, create a workspace and onboard a tenant via the wizard.
10. System console: Navigate to `/system` — requires `platform_users` record with `platform` guard.

View File

@ -0,0 +1,384 @@
# TenantPilot M365 Policy Coverage Gap Analysis
**Date:** 2026-03-07
**Author:** Gap Analysis (Automated Deep Research)
**Scope:** Security, Governance & Baseline-relevante Policy-Familien über Microsoft 365 hinweg
**Methode:** Repo-Ist-Analyse + Microsoft Graph API Research + Produktpriorisierung
---
## 1. Executive Summary
- **Intune-Coverage ist stark:** 27 Policy-Typen + 3 Foundation-Typen sind produktiv integriert mit Backup, Restore, Drift, Inventory und Baselines. Die Intune-Abdeckung gehört zu den umfangreichsten am Markt.
- **Entra ID ist die größte High-Value-Lücke:** Conditional Access wird bereits gesichert (Preview-Restore), aber Named Locations, Authentication Methods Policy, Authentication Strengths, Cross-Tenant Access Settings und Authorization Policy fehlen komplett — alles davon ist über Graph v1.0 produktreif adressierbar.
- **Entra Admin Roles sind ein Alleinstellungsmerkmal:** Die Evidence-/Findings-Integration für hochprivilegierte Rollen ist bereits implementiert und in dieser Form selten bei Wettbewerbern.
- **SharePoint/OneDrive Tenant Settings** sind über die Graph v1.0 `admin/sharepoint/settings` API sauber erreichbar und für MSPs ein sehr häufiger Audit-Punkt (External Sharing, Guest Access).
- **Exchange Online Security-Policies** (Anti-Phishing, Safe Links, Safe Attachments, Anti-Spam) sind **nicht** sauber über Graph adressierbar — sie erfordern Exchange Online PowerShell (EXO V3). Integration ist **fachlich wichtig, technisch aufwändig**.
- **Microsoft Defender for Office 365** Policies teilen den Exchange-PowerShell-Kanal — gleiches Integrationsproblem.
- **UTCM (Unified Tenant Configuration Management)** ist in Preview und signalisiert langfristig einen vereinheitlichten Backup/Restore/Drift-Kanal für alle M365-Workloads. Strategisch relevant, aber noch nicht produktreif.
- **Die nächsten 5 High-Impact-Kandidaten** sind: Named Locations, Authentication Methods Policy, SharePoint Tenant Settings, Authentication Strengths und Cross-Tenant Access Settings — alle über Graph v1.0.
- **Purview/DLP** hat für MSPs begrenzten Wert (typischerweise Enterprise-Scope, selten in MSP-Baselines) und sollte nur als Roadmap-Signal behandelt werden.
- **Die größte strategische Chance** liegt in der Positionierung als „Unified M365 Configuration Governance" — dafür fehlt primär der Entra-ID-Block.
---
## 2. Current Coverage Snapshot
### 2.1 Was ist heute abgedeckt?
**Intune / Endpoint Management — 27 Policy-Typen (Full Backup/Restore/Inventory/Drift):**
| Kategorie | Policy-Typen |
|-----------|-------------|
| Configuration | Device Configuration, Administrative Templates (ADMX), Settings Catalog |
| Compliance | Device Compliance, Custom Compliance Scripts |
| Update Management | Windows Update Rings, Feature Updates, Quality Updates, Driver Updates |
| Apps / MAM | App Protection (MAM), App Configuration (MAM), App Configuration (Device), Mobile Apps (Metadata) |
| Scripts | PowerShell Scripts, macOS Shell Scripts, Proactive Remediations (Health Scripts) |
| Enrollment | Autopilot Profiles, Enrollment Status Page, Enrollment Limits, Platform Restrictions, Enrollment Notifications, Enrollment Restrictions, Terms & Conditions |
| Endpoint Security | Endpoint Security Intents, Endpoint Security Policies, Security Baselines |
**Foundation-Typen (3):**
Assignment Filters, Scope Tags, Notification Message Templates
**Entra ID — Teilweise:**
| Bereich | Status | Details |
|---------|--------|---------|
| Conditional Access Policies | ✅ Backup, Inventory, Versioning | Restore ist `preview-only` (bewusste Entscheidung wegen Risiko) |
| Entra Admin Roles | ✅ Evidence & Findings | Rolendefinitionen, Assignments, Severity-Klassifizierung, Finding-Generierung, Alerts |
| Entra Groups | ✅ Directory Cache | Read-only Sync für Group-Name-Resolution |
| Service Principals | ✅ Probing | RBAC-Onboarding-Validierung |
### 2.2 Schwerpunkt
Der klare Schwerpunkt liegt auf **Intune Endpoint Management**. Alle Intune-Policy-Familien haben:
- Graph Contract Definitionen mit Select/Expand/TypeFamily
- Assignment CRUD Paths
- Backup/Restore-Unterstützung
- Inventory Integration
- Drift Detection (über Baselines/Snapshots)
### 2.3 Erkennbare Lücken
| Domäne | Gap-Einschätzung |
|--------|-----------------|
| Entra ID (Identity) | Nur CA + Admin Roles; Named Locations, Auth Methods, Auth Strengths, Cross-Tenant Access, Authorization Policy, Security Defaults fehlen komplett |
| SharePoint / OneDrive | Keine Unterstützung |
| Exchange Online | Keine Unterstützung |
| Defender for Office 365 | Keine Unterstützung |
| Purview / DLP | Keine Unterstützung |
| Intune Policy Sets | Im Repo als Spec (025), aber nicht im `supported_policy_types` |
| Intune Device Categories | Im Repo als Spec (028), aber nicht im `supported_policy_types` |
### 2.4 Im Repo vorbereitet, aber nicht vollständig produktisiert
| Element | Status |
|---------|--------|
| `conditionalAccessPolicy` | Graph Contract vollständig definiert, aber Restore auf `preview-only` limitiert |
| Policy Sets (Spec 025) | Spec vorhanden, keine Implementation |
| Device Categories (Spec 028) | Spec vorhanden, keine Implementation |
| WIP Policies (Spec 029) | Spec vorhanden, keine Implementation — EOL-Thema, geringe Priorität |
| Intune RBAC Backup (Spec 030) | Spec vorhanden — Role Definitions/Assignments unter `deviceManagement/` |
| Cross-Tenant Compare (Spec 043) | Architektur-Spec vorhanden, Implementation läuft |
---
## 3. Coverage & Gap Matrix
### 3.1 Entra ID / Identity & Access
| Policy Domain | Policy Family | API / Management Path | Access Channel | Coverage | MSP Value | Governance Relevance | Feasibility | Maturity | SKU Notes | Recommendation | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Entra ID | **Conditional Access Policies** | `identity/conditionalAccess/policies` | Graph v1.0 | **Partial** (Backup+Inventory, no full restore) | High | High | Easy | Production | P1/P2 required | **Now** | Restore-Ausbau ist bewusste Risikoentscheidung; Read/Backup/Versioning ist produktiv |
| Entra ID | **Named Locations** | `identity/conditionalAccess/namedLocations` | Graph v1.0 | **No** | High | High | Easy | Production | P1/P2 | **Now** | Direkte Abhängigkeit von CA Policies; MSPs brauchen dies für Baseline-Vergleiche. CRUD über Graph v1.0 vollständig. |
| Entra ID | **Authentication Methods Policy** | `policies/authenticationMethodsPolicy` | Graph v1.0 | **No** | High | High | Easy | Production | Alle SKUs (Basis); einige Methods P1/P2 | **Now** | Tenant-weite Konfiguration (FIDO2, Authenticator, SMS, etc.). Zentrales Audit-Thema für MSPs. Singleton-Resource. |
| Entra ID | **Authentication Strengths** | `policies/authenticationStrengths/policies` | Graph v1.0 | **No** | High | High | Easy | Production | P1/P2 (CA dependency) | **Now** | Custom Auth Strength Definitions für CA. Graph v1.0 GA. MSP-Baseline-Essential. |
| Entra ID | **Cross-Tenant Access Settings** | `policies/crossTenantAccessPolicy` | Graph v1.0 | **No** | High | High | Moderate | Production | P1/P2 (B2B) | **Next** | Partner/Default/Inbound/Outbound Settings. Komplexere Struktur (Sub-Resources), aber über Graph v1.0 GA. Kritisch für B2B-Governance. |
| Entra ID | **Authorization Policy** | `policies/authorizationPolicy` | Graph v1.0 | **No** | Medium | High | Easy | Production | Alle SKUs | **Next** | Singleton: steuert Guest Invite, Self-Service Sign-up, User Consent, etc. Einfache Integration, hoher Audit-Wert. |
| Entra ID | **Security Defaults** | `policies/identitySecurityDefaultsEnforcementPolicy` | Graph v1.0 | **No** | Medium | High | Easy | Production | Alle SKUs | **Next** | Singleton: ein Boolean + Metadata. Triviale Integration. Wichtig als Posture-Signal (an/aus). |
| Entra ID | **Admin Consent Request Policy** | `policies/adminConsentRequestPolicy` | Graph v1.0 | **No** | Medium | Medium | Easy | Production | Alle SKUs | **Next** | App Consent Workflow Settings. Simple Singleton. |
| Entra ID | **Permission Grant Policies** | `policies/permissionGrantPolicies` | Graph v1.0 | **No** | Medium | Medium | Moderate | Production | Alle SKUs | **Later** | Steuert welche Permissions User/Admins auto-consenten dürfen. Relevant, aber Nischen-Audit. |
| Entra ID | **Conditional Access Templates** | `identity/conditionalAccess/templates` | Graph beta | **No** | Low | Medium | Hard | Beta | P1/P2 | **Roadmap** | Beta-only. Eher Reference-Daten als konfigurierbare Policies. |
| Entra ID | **Identity Protection Policies** | `identityProtection/` | Graph v1.0 (read) / beta (write) | **No** | Medium | High | Hard | Mixed | P2 required | **Later** | Risk-based CA (User/Sign-In Risk). Lesen über v1.0, Konfiguration nur beta. SKU-abhängig (P2). |
| Entra ID | **Entra Admin Roles** (Evidence) | `roleManagement/directory/roleDefinitions` + `roleAssignments` | Graph v1.0 | **Yes** | High | High | — | Production | — | — | Bereits implementiert als Findings + Alerts |
| Entra ID | **Directory Groups** (Cache) | `groups` | Graph v1.0 | **Yes** | Medium | Medium | — | Production | — | — | Read-only Cache für Name-Resolution |
### 3.2 SharePoint / OneDrive
| Policy Domain | Policy Family | API / Management Path | Access Channel | Coverage | MSP Value | Governance Relevance | Feasibility | Maturity | SKU Notes | Recommendation | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| SharePoint | **Tenant Sharing Settings** | `admin/sharepoint/settings` | Graph v1.0 | **No** | High | High | Easy | Production | SPO License | **Now** | External Sharing Level, Guest Access Domains, Link Defaults. Häufigster SPO-Audit-Punkt. Singleton-Resource über Graph v1.0. |
| SharePoint | **Site Creation Settings** | `admin/sharepoint/settings` (Teil von Tenant Settings) | Graph v1.0 | **No** | Low | Low | Easy | Production | SPO License | **Later** | Enthalten in demselben Endpoint wie Sharing Settings, aber geringerer Governance-Wert. |
| OneDrive | **OneDrive Storage Settings** | `admin/sharepoint/settings` (Subset) | Graph v1.0 | **No** | Low | Low | Easy | Production | ODB License | **Roadmap** | Default Storage Limits, Sync Restrictions. Limitierter Audit-Wert. |
### 3.3 Exchange Online
| Policy Domain | Policy Family | API / Management Path | Access Channel | Coverage | MSP Value | Governance Relevance | Feasibility | Maturity | SKU Notes | Recommendation | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Exchange | **Anti-Phishing Policy** | EXO PowerShell: `Get-AntiPhishPolicy` | **Non-Graph (PowerShell)** | **No** | High | High | Hard | Production (PS) | EOP / Defender P1 | **Later** | Kein Graph-Endpoint. Erfordert EXO V3 Remote-PowerShell oder REST-basierte EXO V3 Cmdlets. Fachlich sehr wichtig, technisch aufwändig für SaaS-Produkt. |
| Exchange | **Safe Links Policy** | EXO PowerShell: `Get-SafeLinksPolicy` | **Non-Graph (PowerShell)** | **No** | High | High | Hard | Production (PS) | Defender P1/P2 | **Later** | Gleiche Situation wie Anti-Phishing. Kein Graph-Endpoint. Kritische Security-Baseline. |
| Exchange | **Safe Attachments Policy** | EXO PowerShell: `Get-SafeAttachmentPolicy` | **Non-Graph (PowerShell)** | **No** | High | High | Hard | Production (PS) | Defender P1/P2 | **Later** | Gleiche Situation. Kein Graph. |
| Exchange | **Anti-Spam Policy** | EXO PowerShell: `Get-HostedContentFilterPolicy` | **Non-Graph (PowerShell)** | **No** | Medium | High | Hard | Production (PS) | EOP (alle SKUs) | **Later** | Guter Audit-Wert, aber PowerShell-Only. |
| Exchange | **DKIM Settings** | EXO PowerShell: `Get-DkimSigningConfig` | **Non-Graph (PowerShell)** | **No** | Medium | High | Hard | Production (PS) | Alle SKUs | **Roadmap** | Email-Authentifizierung. PowerShell-Only. |
| Exchange | **Transport Rules** | EXO PowerShell: `Get-TransportRule`; teilweise `security/rules` beta | **Non-Graph (PowerShell)** / Graph beta partial | **No** | Medium | Medium | Hard | Mixed | Alle SKUs | **Roadmap** | Komplexe Objekte. PowerShell primär. Beta-Graph sehr limitiert. |
| Exchange | **Remote Domains** | EXO PowerShell | **Non-Graph (PowerShell)** | **No** | Low | Medium | Hard | Production (PS) | Alle SKUs | **Roadmap** | Nischen-Governance. |
| Exchange | **OWA Mailbox Policy** | EXO PowerShell | **Non-Graph (PowerShell)** | **No** | Low | Low | Hard | Production (PS) | Alle SKUs | **Roadmap** | Geringer Baseline-Wert. |
### 3.4 Microsoft Defender for Office 365 / XDR
| Policy Domain | Policy Family | API / Management Path | Access Channel | Coverage | MSP Value | Governance Relevance | Feasibility | Maturity | SKU Notes | Recommendation | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Defender | **Preset Security Policies** | EXO PowerShell: `Get-*Policy` + Portal | **Non-Graph (PowerShell + Portal)** | **No** | High | High | Hard | Indirect | Defender P1/P2 | **Roadmap** | Microsoft's empfohlene Baseline. Keine Graph-API. Nur über PS + Security Portal steuerbar. |
| Defender | **Attack Simulation Training Config** | `security/attackSimulation/` | Graph beta | **No** | Low | Medium | Hard | Beta | Defender P2 | **Roadmap** | Nur Simulation-Runs über beta-API erreichbar, nicht die Konfiguration. |
| Defender | **Quarantine Policies** | EXO PowerShell: `Get-QuarantinePolicy` | **Non-Graph (PowerShell)** | **No** | Low | Medium | Hard | Production (PS) | EOP | **Roadmap** | Nischen-Governance. |
### 3.5 Intune Noch fehlende / teilweise fehlende Typen
| Policy Domain | Policy Family | API / Management Path | Access Channel | Coverage | MSP Value | Governance Relevance | Feasibility | Maturity | SKU Notes | Recommendation | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Intune | **Policy Sets** | `deviceAppManagement/policySets` | Graph beta | **No** (Spec 025 exists) | Medium | Medium | Moderate | Beta | Intune P1 | **Next** | Spec vorhanden. Beta-API. Nützlich für Gruppierung, aber nicht Kern-Security. |
| Intune | **Device Categories** | `deviceManagement/deviceCategories` | Graph v1.0 | **No** (Spec 028 exists) | Low | Low | Easy | Production | Intune P1 | **Later** | Einfache Integration, aber geringer Governance-Wert. |
| Intune | **Intune RBAC** (Role Definitions/Assignments) | `deviceManagement/roleDefinitions` + `roleAssignments` | Graph v1.0 | **No** (Spec 030 exists) | Medium | High | Moderate | Production | Intune P1 | **Next** | Bereits als Graph Contract definiert (`rbacRoleAssignment`). Spec vorhanden. Wichtig für Governance. |
| Intune | **Apple VPP Tokens** | `deviceAppManagement/vppTokens` | Graph beta | **No** | Low | Low | Moderate | Beta | Intune P1 + Apple VPP | **Roadmap** | Operational, nicht Security-relevant. |
| Intune | **DEP Profiles (Apple)** | `deviceManagement/depOnboardingSettings` | Graph beta | **No** | Medium | Medium | Moderate | Beta | Intune P1 + Apple DEP | **Later** | Relevant für Apple-heavy MSPs. Beta. |
| Intune | **Windows Feature Update Expedite** | `deviceManagement/windowsQualityUpdatePolicies` | Graph beta | **No** | Medium | Medium | Moderate | Beta | Intune P1 | **Later** | Neuer Update-Typ. Beta. Eigenständige Policy. |
### 3.6 Purview / Information Governance (Optional)
| Policy Domain | Policy Family | API / Management Path | Access Channel | Coverage | MSP Value | Governance Relevance | Feasibility | Maturity | SKU Notes | Recommendation | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Purview | **Sensitivity Labels** | `security/informationProtection/sensitivityLabels` | Graph v1.0 (read) / beta (full) | **No** | Medium | High | Hard | Mixed | M365 E3/E5 | **Roadmap** | Lesen über v1.0. Aber Labels + Publishing Policies sind komplex. Enterprise-Feature, selten in MSP-Baselines. |
| Purview | **DLP Policies** | Security & Compliance PowerShell / `security/` beta | **Non-Graph (PowerShell)** / Graph beta partial | **No** | Low | Medium | Hard | Beta/Indirect | M365 E3/E5/Compliance Add-on | **Roadmap** | Kein stabiler Graph-Kanal. Enterprise-only. Nicht MSP-Baseline-tauglich. |
| Purview | **Retention Policies** | `security/` beta; PowerShell primary | **Non-Graph (PowerShell)** / Graph beta partial | **No** | Low | Medium | Hard | Beta/Indirect | M365 E3/E5 | **Roadmap** | Ähnliches Problem wie DLP. |
---
## 4. Top Missing High-Value Gaps
Die folgenden 10 Lücken bieten den größten kombinierten Wert aus MSP-Relevanz, Audit-Wichtigkeit und technischer Umsetzbarkeit:
### Rang 1: Entra ID Named Locations
- **Warum:** Direkte Abhängigkeit von CA Policies. Jeder CA-Baseline-Vergleich ist ohne Named Locations unvollständig. `identity/conditionalAccess/namedLocations` ist Graph v1.0 GA mit vollem CRUD.
- **Aufwand:** Gering. Gleicher Pattern wie CA Policies.
### Rang 2: Entra ID Authentication Methods Policy
- **Warum:** Tenant-weite Steuerung von MFA-Methoden (FIDO2, Authenticator, SMS, Temporary Access Pass). Top-Audit-Thema. `policies/authenticationMethodsPolicy` ist ein Singleton über Graph v1.0.
- **Aufwand:** Gering. Einzelne Resource mit Sub-Configurations pro Method.
### Rang 3: SharePoint Tenant Sharing Settings
- **Warum:** External Sharing Level, Guest Access Domain Allow/Block, Default Link Type. Häufigster SharePoint-Audit-Punkt. `admin/sharepoint/settings` ist Graph v1.0 GA.
- **Aufwand:** Gering. Singleton-Resource.
### Rang 4: Entra ID Authentication Strengths
- **Warum:** Custom Authentication Strength Policies für CA. Definiert erlaubte MFA-Kombinationen. `policies/authenticationStrengths/policies` ist Graph v1.0 GA.
- **Aufwand:** Gering. Collection mit einfacher Struktur.
### Rang 5: Entra ID Cross-Tenant Access Settings
- **Warum:** B2B Inbound/Outbound Policies, Default Settings, Partner Configurations. Kritisch für MSP-Governance bei Multi-Tenant-Umgebungen. Graph v1.0 GA.
- **Aufwand:** Moderat. Verschachtelte Struktur (Default + N Partner-Configs mit je Inbound/Outbound/Trust).
### Rang 6: Entra ID Authorization Policy
- **Warum:** Steuert grundlegende Tenant-Einstellungen: Wer darf Guests einladen, Self-Service Sign-up, User Consent Flow. `policies/authorizationPolicy` ist Graph v1.0 GA. Singleton.
- **Aufwand:** Gering. Ein Objekt.
### Rang 7: Entra ID Security Defaults Policy
- **Warum:** Ein Boolean (+ Metadata) das bestimmt ob Security Defaults aktiviert sind. Trivialer Check, aber kritisches Posture-Signal: Ein Tenant mit Security Defaults OFF und ohne CA ist ein Red Flag.
- **Aufwand:** Trivial. Ein GET-Request.
### Rang 8: Intune RBAC Role Definitions & Assignments
- **Warum:** Intune RBAC Backup/Governance. Wer hat welche Rechte im Intune-Kontext? Graph Contract bereits definiert. Spec 030 existiert.
- **Aufwand:** Moderat. Graph Contract Grundstruktur vorhanden.
### Rang 9: Entra ID Admin Consent Request Policy
- **Warum:** App Consent Workflow Settings. Einfaches Singleton. Audit-relevant (wer darf Apps genehmigen?).
- **Aufwand:** Gering.
### Rang 10: Conditional Access Restore-Ausbau
- **Warum:** CA Backup ist produktiv, Restore ist `preview-only`. Full Restore (mit Safeguards wie reportOnly-Modus, Dry-Run Validation) würde den CA-Workflow komplett machen.
- **Aufwand:** Moderat. Risiko-Management ist die eigentliche Herausforderung, nicht die API.
---
## 5. Not Sensible Before Launch
Die folgenden Kandidaten sind fachlich relevant, aber aktuell **nicht sinnvoll für die unmittelbare Umsetzung**:
### Exchange Online Anti-Phishing / Safe Links / Safe Attachments
- **Warum nicht:** Kein Graph-Endpoint. Erfordert Exchange Online PowerShell V3 (REST-based) Integration. Das bedeutet: separater Auth-Flow (Certificate-Based Auth für EXO), separater Management-Kanal, separates Rate-Limit-Handling. Die technische Architektur von TenantPilot (Graph-Contract-basiert) müsste fundamental erweitert werden.
- **Status:** Wichtig, aber aktuell schlecht integrierbar. Eher Roadmap-/Marketing-Signal.
### Microsoft Defender Preset Security Policies
- **Warum nicht:** Gleiche EXO-PowerShell-Abhängigkeit. Zusätzlich: Preset Security Policies sind ein Microsoft-eigenes Baseline-Konzept — die sinnvolle Governance-Integration erfordert Verständnis des Tiering-Modells (Standard/Strict).
- **Status:** Roadmap only.
### Purview DLP / Retention Policies
- **Warum nicht:** Kein stabiler Graph-Kanal. Erfordert Security & Compliance PowerShell. SKU-abhängig (E3/E5). MSPs nutzen DLP seltener als Enterprise-Kunden. ROI für TenantPilot ist gering.
- **Status:** Roadmap only (ggf. Enterprise-Tier).
### Identity Protection Policies (Risk-Based CA)
- **Warum nicht:** Lesen über Graph v1.0 möglich, aber Konfiguration nur über beta. Harte P2-SKU-Abhängigkeit. MSPs mit M365 BP (Business Premium) haben kein P2.
- **Status:** Later / Roadmap.
### Intune Policy Sets (beta API)
- **Warum nicht:** Beta-API. Policy Sets sind ein Organisations-Feature, kein Security-Feature. Der Governance-Wert (welche Policies gehören zusammen?) ist nice-to-have, nicht must-have.
- **Status:** Next (nach Stabilisierung der beta-API).
### Sensitivity Labels
- **Warum nicht:** Read-API ist v1.0, aber die vollständige Konfiguration (Label Policies, Auto-Labeling, Publishing) erfordert beta oder S&C PowerShell. Enterprise-Feature. Rare im MSP-Baseline-Kontext.
- **Status:** Roadmap only.
---
## 6. UTCM Roadmap Signals
### Was ist UTCM?
**Unified Tenant Configuration Management** (Codename: Microsoft 365 Backup & Configuration Management) ist Microsofts Ansatz, Konfigurationen über alle M365-Workloads hinweg einheitlich zu adressieren. Stand März 2026:
### Strategisch interessante Signale
| Signal | Bedeutung für TenantPilot | Zeitrahmen |
|--------|--------------------------|------------|
| **UTCM Configuration Profiles API** (Preview) | Einheitliches Read/Write/Diff für SPO, EXO, Teams, Defender Settings über Graph | Beobachten. Preview seit Mitte 2025. Keine GA-Timeline bekannt. |
| **M365 Backup API** (`solutions/backupRestore/`) | Graph v1.0 GA für SPO/ODB/EXO **Data** Backup. Nicht für Konfiguration. | **Nicht relevant** für TenantPilot (Data Backup ≠ Config Governance). |
| **Multi-Tenant Management APIs** (`tenantRelationships/managedTenants/`) | Read-only Aggregation über MSP-Managed Tenants. Baselines, Alerts, Compliance Trends. | **Beobachten.** Überschneidung mit TenantPilot's eigenem Multi-Tenant-Modell. Könnte als Datenquelle nützlich sein. |
| **UTCM Drift Detection** | Microsoft's eigene Drift Detection für M365 Config. Aktuell sehr begrenzt (nur wenige Workloads). | **Langfristiges Wettbewerbsrisiko.** TenantPilot's Drift Engine ist deutlich umfangreicher. |
### Bewertung
UTCM ist heute ein **Beobachtungssignal**, kein Umsetzungsziel:
- Die Preview-APIs sind instabil und haben sehr begrenzten Workload-Scope
- Microsoft's eigene Roadmap zeigt keine klare GA-Timeline
- TenantPilot sollte UTCM als **langfristigen Integrationspartner** betrachten, nicht als kurzfristiges Feature-Ziel
- Die Strategie sollte sein: Graph v1.0 APIs zuerst, UTCM als Beschleuniger wenn GA
---
## 7. Recommended Product Sequencing
### Now (Q2 2026 unmittelbar umsetzbar, hoher ROI)
| # | Kandidat | Begründung |
|---|----------|-----------|
| 1 | **Entra: Named Locations** | CA-Abhängigkeit, Graph v1.0, triviales Pattern |
| 2 | **Entra: Authentication Methods Policy** | Top-Audit-Thema, Graph v1.0, Singleton |
| 3 | **SharePoint: Tenant Sharing Settings** | Häufigster SPO-Audit-Punkt, Graph v1.0, Singleton |
| 4 | **Entra: Authentication Strengths** | CA-Dependency, Graph v1.0, einfach |
| 5 | **Entra: Security Defaults** | Trivial (1 Boolean), aber kritisches Posture-Signal |
*Alle 5 Kandidaten nutzen ausschließlich Graph v1.0 Stable-APIs und erfordern keinen neuen Integrationskanal.*
### Next (Q3-Q4 2026 moderater Aufwand, solider Wert)
| # | Kandidat | Begründung |
|---|----------|-----------|
| 6 | **Entra: Cross-Tenant Access Settings** | B2B-Governance, Graph v1.0, aber verschachtelte Struktur |
| 7 | **Entra: Authorization Policy** | Tenant-Grundkonfiguration, Graph v1.0, Singleton |
| 8 | **Intune: RBAC Role Definitions/Assignments** | Spec 030 vorhanden, Graph Contract existiert, Governance-Must |
| 9 | **Entra: Admin Consent Request Policy** | Einfach, Graph v1.0, App-Consent-Governance |
| 10 | **Conditional Access: Full Restore** | Backup existiert; Restore-Workflow mit Safety-Guardrails ausbauen |
### Later (2027 H1 requires architecture investment or beta stabilization)
| # | Kandidat | Begründung |
|---|----------|-----------|
| 11 | **Entra: Permission Grant Policies** | Graph v1.0, aber Nischen-Thema |
| 12 | **Entra: Identity Protection Policies** | SKU-abhängig (P2), Write nur beta |
| 13 | **Intune: Policy Sets** | Beta API, nice-to-have Governance |
| 14 | **Intune: DEP Profiles (Apple)** | Beta, Apple-spezifisch |
| 15 | **Exchange: Anti-Phishing/Safe Links/Safe Attachments** | Erfordert EXO PowerShell-Kanal — architektonische Erweiterung |
### Roadmap Only (strategisch, nicht kurzfristig)
| Kandidat | Begründung |
|----------|-----------|
| **UTCM Integration** | Preview, keine GA-Timeline |
| **Defender Preset Security Policies** | PowerShell-Only, komplexes Tiering |
| **Purview DLP / Retention** | PowerShell + beta, Enterprise-SKU, geringer MSP-ROI |
| **Sensitivity Labels (Full)** | Mixed API, Enterprise-Feature |
| **Exchange Transport Rules** | PowerShell primary, komplexe Objekte |
| **Multi-Tenant Management API** | Read-only Aggregation, Überschneidung mit eigenem Modell |
---
## 8. Sources
### Microsoft Graph API Documentation (v1.0)
- [Conditional Access Policies](https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0)
- [Named Locations](https://learn.microsoft.com/en-us/graph/api/resources/namedlocation?view=graph-rest-1.0)
- [Authentication Methods Policy](https://learn.microsoft.com/en-us/graph/api/resources/authenticationmethodspolicy?view=graph-rest-1.0)
- [Authentication Strength Policy](https://learn.microsoft.com/en-us/graph/api/resources/authenticationstrengthpolicy?view=graph-rest-1.0)
- [Cross-Tenant Access Policy](https://learn.microsoft.com/en-us/graph/api/resources/crosstenantaccesspolicy-overview?view=graph-rest-1.0)
- [Authorization Policy](https://learn.microsoft.com/en-us/graph/api/resources/authorizationpolicy?view=graph-rest-1.0)
- [Identity Security Defaults](https://learn.microsoft.com/en-us/graph/api/resources/identitysecuritydefaultsenforcementpolicy?view=graph-rest-1.0)
- [Admin Consent Request Policy](https://learn.microsoft.com/en-us/graph/api/resources/adminconsentrequestpolicy?view=graph-rest-1.0)
- [Permission Grant Policies](https://learn.microsoft.com/en-us/graph/api/resources/permissiongrantpolicy?view=graph-rest-1.0)
- [SharePoint Settings](https://learn.microsoft.com/en-us/graph/api/resources/sharepointsettings?view=graph-rest-1.0)
- [Role Management (Directory)](https://learn.microsoft.com/en-us/graph/api/resources/rolemanagement?view=graph-rest-1.0)
### Microsoft Graph API Documentation (beta)
- [Identity Protection](https://learn.microsoft.com/en-us/graph/api/resources/identityprotection-overview?view=graph-rest-beta)
- [Policy Sets (Intune)](https://learn.microsoft.com/en-us/graph/api/resources/intune-policyset-policyset?view=graph-rest-beta)
- [UTCM / Multi-Tenant Management](https://learn.microsoft.com/en-us/graph/api/resources/managedtenants-managedtenant?view=graph-rest-beta)
### Exchange Online / Defender (PowerShell)
- [EXO V3 Module](https://learn.microsoft.com/en-us/powershell/exchange/exchange-online-powershell-v2?view=exchange-ps)
- [Anti-Phishing Policies](https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-policies-about)
- [Safe Links](https://learn.microsoft.com/en-us/defender-office-365/safe-links-about)
- [Safe Attachments](https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-about)
- [Preset Security Policies](https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies)
### Purview
- [Sensitivity Labels API](https://learn.microsoft.com/en-us/graph/api/resources/security-sensitivitylabel?view=graph-rest-1.0)
- [Data Loss Prevention (Compliance)](https://learn.microsoft.com/en-us/purview/dlp-learn-about-dlp)
### UTCM / M365 Backup
- [Microsoft 365 Backup (Data)](https://learn.microsoft.com/en-us/graph/api/resources/backuprestoreroot?view=graph-rest-1.0)
- [Multi-Tenant Management (Lighthouse)](https://learn.microsoft.com/en-us/graph/api/resources/managedtenants-managedtenant?view=graph-rest-beta)
---
## Appendix A: Architektur-Anmerkungen für die Umsetzung
### "Now"-Kandidaten folgen dem bestehenden Graph-Contract-Pattern
Alle 5 „Now"-Kandidaten können in das bestehende `config/graph_contracts.php` + `GraphContractRegistry` Muster integriert werden:
```
// Beispiel-Contract für Named Locations
'namedLocation' => [
'resource' => 'identity/conditionalAccess/namedLocations',
'allowed_select' => ['id', 'displayName', '@odata.type', 'modifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.ipNamedLocation',
'#microsoft.graph.countryNamedLocation',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
],
```
### Singleton-Resources (neue Kategorie)
Authentication Methods Policy, Authorization Policy und Security Defaults sind **Singletons** (ein Objekt pro Tenant, kein Collection-CRUD). Das bestehende Pattern (Collection-basiert mit CRUD) muss um einen Singleton-Modus erweitert werden:
- GET (kein $top, kein Paging)
- PATCH (kein POST/DELETE)
- Vergleich: Vorher/Nachher eines einzelnen Objekts
### Permissions-Impact
| Kandidat | Benötigte Permission | Typ |
|----------|---------------------|-----|
| Named Locations | `Policy.Read.All` (bereits granted) | Read |
| Auth Methods Policy | `Policy.Read.All` (bereits granted) | Read |
| Auth Strengths | `Policy.Read.All` (bereits granted) | Read |
| Security Defaults | `Policy.Read.All` (bereits granted) | Read |
| SharePoint Settings | `SharePointTenantSettings.Read.All` | **Neu** |
| Cross-Tenant Access | `Policy.Read.All` (bereits granted) | Read |
| Authorization Policy | `Policy.Read.All` (bereits granted) | Read |
Bemerkenswert: **4 von 5 „Now"-Kandidaten** erfordern keine neuen Permissions — `Policy.Read.All` ist bereits in `config/intune_permissions.php` als granted definiert. Nur SharePoint benötigt eine neue Permission.

View File

@ -0,0 +1,542 @@
# Redaction / Masking / Sanitizing — Codebase Audit Report
**Auditor:** Security + Data-Integrity Codebase Auditor
**Date:** 2026-03-06
**Scope:** Entire TenantAtlas repo (excluding `vendor/`, `node_modules/`, compiled views)
**Severity Classification:** SEV-1 = data loss / secret exposure in production path; SEV-2 = data quality degradation; SEV-3 = cosmetic / defense-in-depth
---
## Executive Summary
### The Bug
**`PolicySnapshotRedactor`** — the central class that masks secrets before persisting `PolicyVersion` snapshots to the database — uses **substring-matching regex patterns** (`/password/i`, `/secret/i`, `/token/i`, `/certificate/i`) against JSON keys. This causes **false-positive redaction** of Intune configuration keys such as:
| Key | Actual Meaning | Wrongly Redacted? |
|-----|---------------|-------------------|
| `passwordMinimumLength` | Config: minimum password length (integer) | **YES** — matches `/password/i` |
| `passwordRequired` | Config: boolean toggle | **YES** — matches `/password/i` |
| `deviceCompliancePasswordRequired` | Config: boolean toggle | **YES** — matches `/password/i` |
| `localAdminPasswordRotationEnabled` | Config: boolean toggle | **YES** — matches `/password/i` |
| `passwordExpirationDays` | Config: integer | **YES** — matches `/password/i` |
| `passwordMinimumCharacterSetCount` | Config: integer | **YES** — matches `/password/i` |
| `passwordBlockSimple` | Config: boolean | **YES** — matches `/password/i` |
| `passwordPreviousPasswordBlockCount` | Config: integer | **YES** — matches `/password/i` |
| `certificateValidityPeriodScale` | Config: enum | **YES** — matches `/certificate/i` |
| `certificateRenewalThresholdPercentage` | Config: integer | **YES** — matches `/certificate/i` |
| `tokenType` | Config: enum | **YES** — matches `/token/i` |
### Impact
1. **SEV-1: Persistent data loss** — Redaction happens **before** storing `PolicyVersion.snapshot` (JSONB). The original values are **irrecoverably destroyed**. This breaks:
- Drift detection (diffs show `[REDACTED]` vs `[REDACTED]` — always "no change")
- Baseline compare (config values masked, comparison meaningless)
- Restore flows (restored policies could lose critical settings)
- Evidence packs / review pack exports
2. **SEV-2: False drift suppression** — Two snapshots with different `passwordMinimumLength` values (e.g., 8 vs 12) will both store `[REDACTED]`, producing identical hashes → drift goes undetected.
3. **Real secrets remain correctly redacted** — The existing patterns DO catch actual secrets (`password`, `wifi_password`, `clientSecret`, etc.) — the problem is OVER-matching config keys that *contain* the word "password".
### Affected Layers
- **3 distinct redaction/sanitization systems** with the same bug pattern
- **2 persistent storage paths** (PolicyVersion, AuditLog)
- **1 non-persistent rendering path** (Verification reports)
- **~8 additional downstream consumers** of already-redacted data
---
## Findings — Complete Redaction Map
### Finding F-1: `PolicySnapshotRedactor` (CRITICAL — SEV-1)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Intune/PolicySnapshotRedactor.php` |
| **Lines** | 14-23 (pattern definitions), 64-70 (`isSensitiveKey`), 72-93 (`redactValue`) |
| **Layer** | Storage (persistent JSONB) |
| **What's redacted** | Any key whose name substring-matches 8 regex patterns |
| **Entry points** | `PolicyCaptureOrchestrator::capture()` (L148-150), `VersionService::captureVersion()` (L47-49) |
| **Persisted** | **YES** — redacted data stored as `PolicyVersion.snapshot`, `.assignments`, `.scope_tags` |
| **Reversible** | **NO** — original values lost permanently |
**Rules (exact patterns):**
```php
private const array SENSITIVE_KEY_PATTERNS = [
'/password/i', // ← FALSE POSITIVES: passwordMinimumLength, passwordRequired, etc.
'/secret/i', // ← FALSE POSITIVES: possible but less common in Intune
'/token/i', // ← FALSE POSITIVES: tokenType, tokenAccountType, etc.
'/client[_-]?secret/i', // ← OK (specific enough)
'/private[_-]?key/i', // ← OK (specific enough)
'/shared[_-]?secret/i', // ← OK (specific enough)
'/preshared/i', // ← OK (specific enough)
'/certificate/i', // ← FALSE POSITIVES: certificateValidityPeriodScale, etc.
];
```
**False positive examples:**
- `passwordMinimumLength: 12` → becomes `passwordMinimumLength: "[REDACTED]"`
- `passwordRequired: true` → becomes `passwordRequired: "[REDACTED]"`
- `certificateValidityPeriodScale: "years"` → becomes `certificateValidityPeriodScale: "[REDACTED]"`
- `tokenType: "user"` → becomes `tokenType: "[REDACTED]"`
**Risk Score:** 🔴 **CRITICAL** — Permanent data loss in production path. Destroys drift detection, baseline compare, and restore fidelity.
**Double-redaction issue:** `VersionService::captureVersion()` (L47-49) applies the redactor, AND `PolicyCaptureOrchestrator::capture()` (L148-150) also applies it before passing to `captureVersion()`. When called through the orchestrator, data is redacted twice (harmless but indicates confusion about ownership).
---
### Finding F-2: `AuditContextSanitizer` (HIGH — SEV-2)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/Audit/AuditContextSanitizer.php` |
| **Lines** | 36-44 (`shouldRedactKey`) |
| **Layer** | Audit logging (persistent) |
| **What's redacted** | Keys containing substrings: `token`, `secret`, `password`, `authorization`, `private_key`, `client_secret` |
| **Entry points** | `WorkspaceAuditLogger::log()` , `Intune\AuditLogger::log()` |
| **Persisted** | **YES** — stored in `AuditLog.metadata` (JSONB) |
**Rules (exact logic):**
```php
return str_contains($key, 'token')
|| str_contains($key, 'secret')
|| str_contains($key, 'password')
|| str_contains($key, 'authorization')
|| str_contains($key, 'private_key')
|| str_contains($key, 'client_secret');
```
**False positive examples:** Same as F-1 — any audit metadata key containing `password` or `token` as a substring is redacted. If audit context includes policy configuration metadata (e.g., `passwordMinimumLength`), it will be masked.
**Risk Score:** 🟠 **HIGH** — Audit log data corruption. Less severe than F-1 because audit logs typically contain operational metadata, not raw policy payloads. However, if policy config keys appear in audit context (e.g., during drift detection logging), they will be silently destroyed.
---
### Finding F-3: `VerificationReportSanitizer` (MEDIUM — SEV-3)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/Verification/VerificationReportSanitizer.php` |
| **Lines** | 10-18 (`FORBIDDEN_KEY_SUBSTRINGS`), 131, 276, 357, 415, 443-460 |
| **Layer** | UI rendering (non-persistent) |
| **What's redacted** | Values/keys containing: `access_token`, `refresh_token`, `client_secret`, `authorization`, `password`, `cookie`, `set-cookie` |
| **Entry points** | `VerificationReportViewer` (Filament UI), `OperationRunResource` |
| **Persisted** | **NO** — sanitization at rendering time only |
**Rules:**
```php
private const FORBIDDEN_KEY_SUBSTRINGS = [
'access_token', 'refresh_token', 'client_secret',
'authorization', 'password', 'cookie', 'set-cookie',
];
// Used via str_contains() on key names AND value strings
```
**False positive examples:** If a verification report evidence value contains the substring "password" (e.g., a check message like "passwordMinimumLength must be ≥ 8"), the entire value would be nulled out. The `containsForbiddenKeySubstring` is also applied to **values**, not just keys (L357, L415), which could null out diagnostic strings.
**Risk Score:** 🟡 **MEDIUM** — No data loss (rendering only), but could hide useful diagnostic information from operators.
---
### Finding F-4: `RunFailureSanitizer::sanitizeMessage()` (LOW — SEV-3)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/OpsUx/RunFailureSanitizer.php` |
| **Lines** | 119-140 |
| **Layer** | Error message sanitization (persistent in OperationRun context) |
| **What's redacted** | Bearer tokens, JWT-like strings, specific key=value patterns (`access_token`, `refresh_token`, `client_secret`, `password`) |
| **Entry points** | Various jobs, `OperationRunService` |
| **Persisted** | **YES** — stored in OperationRun error context |
**Rules:** Uses exact key names (`access_token`, `refresh_token`, `client_secret`, `password`) in regex patterns matching `key=value` or `"key":"value"` format. Also does a final `str_ireplace` of these exact substrings.
**False positive risk:** LOW. The regex targets `key=value` patterns specifically. However, the blanket `str_ireplace` on line 137 will replace the substring `password` in ANY position — a message like "Check passwordMinimumLength policy" becomes "Check [REDACTED]MinimumLength policy". But these are error messages, not structured data.
**Risk Score:** 🟢 **LOW** — Messages may be garbled but no structured data loss.
---
### Finding F-5: `GenerateReviewPackJob::redactArrayPii()` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Jobs/GenerateReviewPackJob.php` |
| **Lines** | 340-352 |
| **Layer** | Export (ZIP download) |
| **What's redacted** | PII fields: `displayName`, `display_name`, `userPrincipalName`, `user_principal_name`, `email`, `mail` |
| **Entry points** | Review pack generation |
| **Persisted** | No (exported ZIP) |
**Rules:** Exact key match via `in_array($key, $piiKeys, true)`.
**Risk Score:** 🟢 **SAFE** — Uses exact key matching. No false positive risk for policy config keys.
---
### Finding F-6: `VerificationReportSanitizer::sanitizeMessage()` (LOW — SEV-3)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Support/Verification/VerificationReportSanitizer.php` |
| **Lines** | 382-413 |
| **Layer** | UI rendering |
| **What's redacted** | Same pattern as `RunFailureSanitizer`: Bearer tokens, key=value secrets, long opaque blobs, plus blanket `str_ireplace` |
**Same issue as F-4:** The `str_ireplace` at L404-409 will corrupt any message containing "password" as a substring.
---
### Finding F-7: `DriftEvidence::sanitize()` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Drift/DriftEvidence.php` |
| **Lines** | 12-28 |
| **Layer** | Drift evidence formatting |
| **What's redacted** | Nothing — this is an allowlist-based key filter, not a secret redactor |
**Risk Score:** 🟢 **SAFE** — Uses allowlist approach. No false positives.
---
### Finding F-8: `InventoryMetaSanitizer` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Inventory/InventoryMetaSanitizer.php` |
| **Layer** | Inventory metadata normalization |
| **What's redacted** | Nothing secret-related — normalizes structural metadata (odata_type, etag, scope_tag_ids) |
**Risk Score:** 🟢 **SAFE** — Not a secret redactor at all.
---
### Finding F-9: `GraphContractRegistry::sanitizeArray()` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Services/Graph/GraphContractRegistry.php` |
| **Lines** | 637+ |
| **Layer** | Graph API payload preparation (restore path) |
| **What's redacted** | Read-only/metadata keys stripped for Graph API compliance |
**Risk Score:** 🟢 **SAFE** — Not secret redaction. Strips OData metadata keys using exact-match lists.
---
### Finding F-10: Model-level `$hidden` (SAFE)
| Models | Hidden attributes |
|--------|------------------|
| `User` | `password`, `remember_token` |
| `ProviderCredential` | `payload` (encrypted:array) |
| `AlertDestination` | (needs verification) |
| `PlatformUser` | (needs verification) |
**Risk Score:** 🟢 **SAFE** — Standard Laravel serialization protection. Correct usage.
---
### Finding F-11: `ProviderCredentialObserver` (SAFE)
| Attribute | Value |
|-----------|-------|
| **File** | `app/Observers/ProviderCredentialObserver.php` |
| **Layer** | Audit logging for credential changes |
| **What's redacted** | Logs `'redacted_fields' => ['client_secret']` as metadata |
**Risk Score:** 🟢 **SAFE** — Explicitly documents redacted fields; doesn't perform substring matching.
---
## Root Cause Analysis
### Primary Root Cause: Substring Regex on Key Names
The `PolicySnapshotRedactor` (F-1) is the **single root cause** by severity. It uses:
```php
'/password/i' → preg_match('/password/i', 'passwordMinimumLength') === 1 ← FALSE POSITIVE
```
This pattern has **no word boundaries**, **no exact-match semantics**, and **no allowlist of known safe keys**.
### Secondary Root Cause: Redaction at Persistence Time
The redactor runs **before** data is stored in `PolicyVersion.snapshot`. This makes the data loss **permanent and irrecoverable**. If redaction only happened at rendering/export time, the original data would still be available for drift detection, compare, and restore.
### Tertiary Root Cause: No Centralized Redaction Service
Three independent implementations (`PolicySnapshotRedactor`, `AuditContextSanitizer`, `VerificationReportSanitizer`) each define their own rules with inconsistent approaches. There is no single source of truth for "what is a secret key."
---
## Patch Plan
### Phase 1: Quick Fix — Allowlist + Word Boundaries (PRIORITY A — do first)
**Fix `PolicySnapshotRedactor`** to use an explicit allowlist of exact secret key names instead of substring regex:
```php
// BEFORE (broken):
private const array SENSITIVE_KEY_PATTERNS = [
'/password/i',
'/secret/i',
'/token/i',
// ...
];
// AFTER (fixed):
private const array EXACT_SECRET_KEYS = [
'password',
'wifi_password',
'wifiPassword',
'clientSecret',
'client_secret',
'sharedSecret',
'shared_secret',
'sharedKey',
'shared_key',
'preSharedKey',
'pre_shared_key',
'presharedKey',
'psk',
'privateKey',
'private_key',
'certificatePassword',
'certificate_password',
'pfxPassword',
'pfx_password',
'token', // bare "token" only — NOT tokenType
'accessToken',
'access_token',
'refreshToken',
'refresh_token',
'bearerToken',
'bearer_token',
'secret', // bare "secret" only — NOT secretReferenceValueId
'passphrase',
];
// Match exact key name (case-insensitive):
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower(trim($key));
foreach (self::EXACT_SECRET_KEYS as $secretKey) {
if ($normalized === strtolower($secretKey)) {
return true;
}
}
return false;
}
```
**Apply the same fix to `AuditContextSanitizer::shouldRedactKey()`.**
### Phase 2: Move Redaction to Render Time (PRIORITY A — architectural fix)
Currently the redaction happens **before persistence**:
```
Graph API → PolicySnapshotRedactor → PolicyVersion.snapshot (REDACTED! 💀)
```
The correct architecture is:
```
Graph API → PolicyVersion.snapshot (ORIGINAL ✅)
[On read/render/export]
PolicySnapshotRedactor → UI / Export / Notification
```
This requires:
1. Remove `snapshotRedactor->redactPayload()` calls from `PolicyCaptureOrchestrator` and `VersionService::captureVersion()`
2. Apply redaction at rendering time in Filament resources, export jobs, and notification formatters
3. Ensure logs (`Log::info/warning`) never include raw snapshot data (they currently don't — they only log IDs)
**Migration consideration:** Existing `PolicyVersion` records with `[REDACTED]` values cannot be recovered. Document this as known data loss for historical versions.
### Phase 3: Centralized Redaction Service (PRIORITY B — proper fix)
Create a single `SecretKeyClassifier` service:
```php
final class SecretKeyClassifier
{
// Exact secret keys from Intune/Graph schemas
private const array SECRET_KEYS = [ /* ... */ ];
// Keys that LOOK like secrets but are config values (safety net)
private const array SAFE_CONFIG_KEYS = [
'passwordMinimumLength',
'passwordRequired',
'passwordExpirationDays',
'passwordMinimumCharacterSetCount',
'passwordBlockSimple',
'passwordPreviousPasswordBlockCount',
'deviceCompliancePasswordRequired',
'localAdminPasswordRotationEnabled',
'certificateValidityPeriodScale',
'certificateRenewalThresholdPercentage',
'tokenType',
'tokenAccountType',
// ... extend as new policy types are added
];
public function isSecretKey(string $key): bool { /* ... */ }
public function isKnownSafeConfigKey(string $key): bool { /* ... */ }
}
```
All three sanitizer systems (`PolicySnapshotRedactor`, `AuditContextSanitizer`, `VerificationReportSanitizer`) should delegate to this single classifier.
### Phase 4: Schema-Driven Secret Classification (PRIORITY C — long-term)
Extend `GraphContractRegistry` with per-policy-type secret field metadata:
```php
'deviceConfiguration' => [
// ...existing...
'secret_fields' => ['wifi_password', 'preSharedKey', 'certificatePassword'],
],
```
The `SecretKeyClassifier` would then consult the contract registry for type-specific secret lists, providing the most precise classification possible.
---
## Required Tests
### Unit Tests (new)
```
tests/Unit/PolicySnapshotRedactorFalsePositiveTest.php
```
**Sample test data:**
```json
{
"passwordMinimumLength": 12,
"passwordRequired": true,
"wifi_password": "SuperGeheimesPasswort123!",
"clientSecret": "abc",
"token": "xyz",
"deviceCompliancePasswordRequired": true,
"localAdminPasswordRotationEnabled": true,
"certificatePassword": "pfxpw",
"sharedKey": "psk",
"osVersionMinimum": "10.0.22631",
"certificateValidityPeriodScale": "years",
"tokenType": "user",
"passwordExpirationDays": 90,
"passwordBlockSimple": true
}
```
**Expected after redaction:**
| Key | Expected |
|-----|----------|
| `passwordMinimumLength` | `12` (VISIBLE) |
| `passwordRequired` | `true` (VISIBLE) |
| `wifi_password` | `[REDACTED]` |
| `clientSecret` | `[REDACTED]` |
| `token` | `[REDACTED]` |
| `deviceCompliancePasswordRequired` | `true` (VISIBLE) |
| `localAdminPasswordRotationEnabled` | `true` (VISIBLE) |
| `certificatePassword` | `[REDACTED]` |
| `sharedKey` | `[REDACTED]` |
| `osVersionMinimum` | `10.0.22631` (VISIBLE) |
| `certificateValidityPeriodScale` | `years` (VISIBLE) |
| `tokenType` | `user` (VISIBLE) |
| `passwordExpirationDays` | `90` (VISIBLE) |
| `passwordBlockSimple` | `true` (VISIBLE) |
### Unit Tests (update existing)
- `tests/Unit/AuditContextSanitizerTest.php` — add false-positive key tests
- `tests/Feature/Intune/PolicySnapshotRedactionTest.php` — add config key preservation tests
- `tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php` — add config key preservation
### Integration Tests
- Drift compare with `passwordMinimumLength` changes → must detect change
- Baseline compare with `passwordRequired` difference → must show diff
- Review pack export → config keys visible, actual secrets redacted
- Restore flow → original config values preserved through full cycle
---
## Guard Rails (Regression Prevention)
### 1. CI Regression Test
A dedicated test that asserts known Intune config keys are NEVER redacted:
```php
it('never redacts known Intune configuration keys', function (string $key, mixed $value) {
$redactor = new PolicySnapshotRedactor();
$result = $redactor->redactPayload([$key => $value]);
expect($result[$key])->toBe($value, "Config key '{$key}' was incorrectly redacted");
})->with([
['passwordMinimumLength', 12],
['passwordRequired', true],
['passwordExpirationDays', 90],
['passwordMinimumCharacterSetCount', 4],
['passwordBlockSimple', true],
['passwordPreviousPasswordBlockCount', 5],
['deviceCompliancePasswordRequired', true],
['localAdminPasswordRotationEnabled', true],
['certificateValidityPeriodScale', 'years'],
['certificateRenewalThresholdPercentage', 20],
['tokenType', 'user'],
]);
```
### 2. Static Analysis / CI Grep
Add a CI step that fails if any new code introduces substring-matching regex against "password"/"secret"/"token" without word boundaries:
```bash
# Fail if new redaction patterns use substring matching
grep -rn --include='*.php' "preg_match.*password\|str_contains.*password" app/ \
| grep -v "SAFE_CONFIG_KEYS\|EXACT_SECRET_KEYS\|SecretKeyClassifier" \
&& echo "FAIL: New substring-based password detection found" && exit 1
```
### 3. Central Redaction Service
All secret-classification logic must flow through `SecretKeyClassifier` (Phase 3). No component should independently define what constitutes a "secret key."
---
## Summary Table
| # | File | Layer | Persisted | False Positives | Severity | Fix Priority |
|---|------|-------|-----------|----------------|----------|-------------|
| F-1 | `PolicySnapshotRedactor.php` | Storage | **YES** | **HIGH** (password*, certificate*, token*) | **SEV-1** | **IMMEDIATE** |
| F-2 | `AuditContextSanitizer.php` | Audit | **YES** | **HIGH** (password, token substrings) | **SEV-2** | **HIGH** |
| F-3 | `VerificationReportSanitizer.php` | UI | No | **MEDIUM** (password in values) | **SEV-3** | MEDIUM |
| F-4 | `RunFailureSanitizer.php` | Errors | YES | **LOW** (str_ireplace on messages) | **SEV-3** | LOW |
| F-5 | `GenerateReviewPackJob.php` | Export | No | None (exact match) | SAFE | — |
| F-6 | `VerificationReportSanitizer::sanitizeMessage()` | UI | No | **LOW** (same as F-4) | **SEV-3** | LOW |
| F-7 | `DriftEvidence.php` | Drift | No | None (allowlist) | SAFE | — |
| F-8 | `InventoryMetaSanitizer.php` | Inventory | No | None (structural only) | SAFE | — |
| F-9 | `GraphContractRegistry.php` | API prep | No | None (exact match) | SAFE | — |
| F-10 | Model `$hidden` | Serialization | N/A | None | SAFE | — |
| F-11 | `ProviderCredentialObserver.php` | Audit | No | None (explicit docs) | SAFE | — |
---
## Assumptions & Caveats
1. **ASSUMPTION:** Intune Graph API responses contain keys like `passwordMinimumLength` at the top-level of policy JSON. Verified against Microsoft Graph documentation for deviceManagement policies — confirmed.
2. **ASSUMPTION:** No other secret redaction exists in Monolog processors or Laravel exception handlers. Verified: no custom Monolog processors found in `config/logging.php` or `app/Exceptions/`.
3. **CANNOT VERIFY:** Whether any existing `PolicyVersion` snapshots in production have already suffered data loss from this bug. **Recommendation:** Query production DB for `PolicyVersion` records where `snapshot::jsonb ? 'passwordMinimumLength'` and check if the value is `[REDACTED]`.
4. **ASSUMPTION:** Telescope / Debugbar are not deployed to production. If they are, their built-in redaction (which typically uses similar patterns) could also cause false positives in debug data.
5. **Double redaction in orchestrator path:** When `PolicyCaptureOrchestrator::capture()` calls `VersionService::captureVersion()`, the redactor runs twice (once in each). This is functionally harmless (idempotent) but indicates unclear ownership.

View File

@ -16,6 +16,8 @@
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$redactionNotes = $redactionNotes ?? [];
$redactionNotes = is_array($redactionNotes) ? array_values(array_filter($redactionNotes, 'is_string')) : [];
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
@ -105,6 +107,14 @@
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
@if ($redactionNotes !== [])
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
@foreach ($redactionNotes as $note)
<div>{{ $note }}</div>
@endforeach
</div>
@endif
</div>
@else
@php
@ -160,6 +170,14 @@
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
@if ($redactionNotes !== [])
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
@foreach ($redactionNotes as $note)
<div>{{ $note }}</div>
@endforeach
</div>
@endif
</div>
<div x-data="{ tab: 'issues' }" class="space-y-4">

View File

@ -1,3 +1,9 @@
<x-filament-panels::page>
@if ($this->redactionIntegrityNote())
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $this->redactionIntegrityNote() }}
</div>
@endif
{{ $this->infolist }}
</x-filament-panels::page>

View File

@ -1,16 +1,15 @@
@php
$lastRun = $this->lastRun();
$lastRunStatusSpec = $lastRun
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $lastRun->status)
$findingsLastRun = $this->findingsLastRun();
$findingsLastRunStatusSpec = $findingsLastRun
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status)
: null;
$lastRunOutcomeSpec = $lastRun && (string) $lastRun->status === 'completed'
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $lastRun->outcome)
$findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed'
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome)
: null;
@endphp
<x-filament-panels::page>
<div class="space-y-6">
{{-- Operator warning banner --}}
<x-filament::section>
<div class="flex items-start gap-3">
<x-heroicon-o-exclamation-triangle class="h-6 w-6 shrink-0 text-amber-500 dark:text-amber-400" />
@ -18,13 +17,12 @@
<div>
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Runbooks can modify customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
</p>
</div>
</div>
</x-filament::section>
{{-- Runbook card: Rebuild Findings Lifecycle --}}
<x-filament::section>
<x-slot name="heading">
Rebuild Findings Lifecycle
@ -36,56 +34,54 @@
<x-slot name="afterHeader">
<x-filament::badge color="info" size="sm">
{{ $this->scopeLabel() }}
{{ $this->findingsScopeLabel() }}
</x-filament::badge>
</x-slot>
<div class="space-y-4">
{{-- Last run metadata --}}
@if ($lastRun)
@if ($findingsLastRun)
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ $lastRun->created_at?->diffForHumans() ?? '—' }}
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
</span>
@if ($lastRunStatusSpec)
@if ($findingsLastRunStatusSpec)
<x-filament::badge
:color="$lastRunStatusSpec->color"
:icon="$lastRunStatusSpec->icon"
:color="$findingsLastRunStatusSpec->color"
:icon="$findingsLastRunStatusSpec->icon"
size="sm"
>
{{ $lastRunStatusSpec->label }}
{{ $findingsLastRunStatusSpec->label }}
</x-filament::badge>
@endif
@if ($lastRunOutcomeSpec)
@if ($findingsLastRunOutcomeSpec)
<x-filament::badge
:color="$lastRunOutcomeSpec->color"
:icon="$lastRunOutcomeSpec->icon"
:color="$findingsLastRunOutcomeSpec->color"
:icon="$findingsLastRunOutcomeSpec->icon"
size="sm"
>
{{ $lastRunOutcomeSpec->label }}
{{ $findingsLastRunOutcomeSpec->label }}
</x-filament::badge>
@endif
@if ($lastRun->initiator_name)
@if ($findingsLastRun->initiator_name)
<span class="text-xs text-gray-500 dark:text-gray-400">
by {{ $lastRun->initiator_name }}
by {{ $findingsLastRun->initiator_name }}
</span>
@endif
</div>
@endif
{{-- Preflight results --}}
@if (is_array($this->preflight))
@if (is_array($this->findingsPreflight))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->preflight['affected_count'] ?? 0)) }}
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
@ -94,7 +90,7 @@
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->preflight['total_count'] ?? 0)) }}
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
@ -103,20 +99,19 @@
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ is_numeric($this->preflight['estimated_tenants'] ?? null) ? number_format((int) $this->preflight['estimated_tenants']) : '—' }}
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
</p>
</div>
</x-filament::section>
</div>
@if ((int) ($this->preflight['affected_count'] ?? 0) <= 0)
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
Nothing to do for the current scope.
</div>
@endif
@else
{{-- Preflight CTA --}}
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
@ -124,6 +119,6 @@
@endif
</div>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@ -16,6 +16,7 @@
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
$hasSummary = count($summaryCounts) > 0;
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
@endphp
<x-filament-panels::page>
@ -96,6 +97,14 @@
</dl>
</x-filament::section>
@if ($integrityNote)
<x-filament::section>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $integrityNote }}
</div>
</x-filament::section>
@endif
@if ($hasSummary)
<x-filament::section>
<x-slot name="heading">

View File

@ -104,6 +104,7 @@
@include('filament.components.verification-report-viewer', [
'run' => $runData,
'report' => $report,
'redactionNotes' => $redactionNotes ?? [],
])
<div class="flex flex-wrap items-center gap-2">

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Secret Redaction Hardening & Snapshot Data Integrity
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated against the repository spec template after correcting the feature number from `001` to `120`.
- No clarification questions are required before `/speckit.plan`.
- Existing Filament surfaces are referenced only to document impact; the spec does not introduce new user workflows or dependencies.

View File

@ -0,0 +1,92 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/specs/120-secret-redaction-integrity/contracts/protected-snapshot.schema.json",
"title": "Protected Snapshot Contract",
"type": "object",
"required": [
"redaction_version",
"snapshot",
"secret_fingerprints"
],
"properties": {
"redaction_version": {
"type": "integer",
"const": 1,
"description": "Classifier contract version for newly protected policy versions."
},
"snapshot": {
"description": "Protected snapshot payload with safe configuration values preserved.",
"$ref": "#/$defs/jsonValue"
},
"assignments": {
"description": "Protected assignments payload under the same contract.",
"$ref": "#/$defs/jsonValue"
},
"scope_tags": {
"description": "Protected scope-tag payload under the same contract.",
"$ref": "#/$defs/jsonValue"
},
"secret_fingerprints": {
"type": "object",
"required": ["snapshot", "assignments", "scope_tags"],
"properties": {
"snapshot": {
"$ref": "#/$defs/fingerprintBucket"
},
"assignments": {
"$ref": "#/$defs/fingerprintBucket"
},
"scope_tags": {
"$ref": "#/$defs/fingerprintBucket"
}
},
"additionalProperties": false
}
},
"$defs": {
"jsonValue": {
"oneOf": [
{ "type": "object", "additionalProperties": { "$ref": "#/$defs/jsonValue" } },
{ "type": "array", "items": { "$ref": "#/$defs/jsonValue" } },
{ "type": "string" },
{ "type": "number" },
{ "type": "integer" },
{ "type": "boolean" },
{ "type": "null" }
]
},
"fingerprintBucket": {
"type": "object",
"description": "Map of RFC 6901 JSON Pointer paths to lowercase HMAC-SHA256 hex digests.",
"propertyNames": {
"type": "string",
"pattern": "^/(|.*)$"
},
"additionalProperties": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"examples": [
{
"redaction_version": 1,
"snapshot": {
"wifi": {
"ssid": "Corp",
"password": "[REDACTED]"
},
"passwordMinimumLength": 12
},
"assignments": [],
"scope_tags": [],
"secret_fingerprints": {
"snapshot": {
"/wifi/password": "2a1ec8cbf1ea9c0d5a9770b7eeed93ec651987369f9fbeb6f1df2dfeb5a86fd4"
},
"assignments": {},
"scope_tags": {}
}
}
]
}

View File

@ -0,0 +1,70 @@
# Data Model — Secret Redaction Hardening & Snapshot Data Integrity (Spec 120)
This spec extends existing persistence and introduces no new base tables.
## Entities
### 1) PolicyVersion (existing: `App\Models\PolicyVersion`)
Tenant-owned immutable policy evidence.
#### New / changed fields
- `workspace_id` (existing required scope field for newer rows; used for workspace-scoped fingerprint derivation)
- `snapshot` (JSON/array): protected snapshot payload with non-secret values preserved and secret values replaced by `[REDACTED]`
- `assignments` (JSON/array|null): protected assignment payload under the same contract
- `scope_tags` (JSON/array|null): protected scope-tag payload under the same contract
- `secret_fingerprints` (new JSON/array):
- shape:
- `snapshot`: object keyed by RFC 6901 JSON Pointer
- `assignments`: object keyed by RFC 6901 JSON Pointer
- `scope_tags`: object keyed by RFC 6901 JSON Pointer
- values: lowercase HMAC-SHA256 hex digests
- `redaction_version` (new integer contract marker for compliant writes):
- `1` = protected under the Spec 120 classifier contract
#### Relationships
- Belongs to `Tenant`
- Belongs to `Policy`
- Belongs to `OperationRun` (nullable)
- Belongs to `BaselineProfile` (nullable)
#### Validation / invariants
- New writes must set `redaction_version = 1`.
- If a protected value is persisted as `[REDACTED]`, a matching digest entry must exist in `secret_fingerprints` for the same source bucket + JSON Pointer.
- If `redaction_version = 1`, `secret_fingerprints` may be empty only when no protected fields were classified.
- Version identity for dedupe must consider both the protected payload and `secret_fingerprints` so secret-only changes create a new version.
### 2) ProtectedSnapshotResult (new transient service DTO)
The canonical output of the new protection pipeline before persistence.
#### Fields
- `snapshot` (array)
- `assignments` (array|null)
- `scope_tags` (array|null)
- `secret_fingerprints` (array{snapshot: array<string, string>, assignments: array<string, string>, scope_tags: array<string, string>})
- `redaction_version` (int)
- `protected_paths_count` (int)
#### Validation / invariants
- Must be deterministic for the same input payload, workspace, and classifier version.
- Must preserve original object/list shape.
- Must never include raw secret values in any field.
### 3) SecretClassificationRule (new application-level value object)
Non-persisted classifier rule consumed by snapshot, audit, verification, and ops sanitizers.
#### Fields
- `source_bucket` (`snapshot|assignments|scope_tags|audit|verification|ops_failure`)
- `json_pointer` (string|null)
- `field_name` (string)
- `decision` (`protected|visible`)
- `reason` (`exact_key`, `exact_path`, `message_pattern`, `default_visible`)
#### Validation / invariants
- Exact-path rules take precedence over exact-key rules.
- Unknown fields default to visible unless a protected rule matches.
- Message-level sanitizers may protect by exact token pattern, but must not broad-match harmless phrases.
## Derived / Computed Values
- `protection_digest` (implementation detail): composite hash of protected payload + `secret_fingerprints`, used for version dedupe.
- `protected_change_detected` (derived): true when compare/drift sees a fingerprint difference for the same protected path even though the visible payload remains `[REDACTED]`.

View File

@ -0,0 +1,94 @@
# Implementation Plan: Secret Redaction Hardening & Snapshot Data Integrity
**Branch**: `120-secret-redaction-integrity` | **Date**: 2026-03-06 | **Spec**: [specs/120-secret-redaction-integrity/spec.md](specs/120-secret-redaction-integrity/spec.md)
**Input**: Feature specification from `/specs/120-secret-redaction-integrity/spec.md`
## Summary
Harden persisted policy evidence by replacing broad substring-based masking with one exact/path-based classifier, moving protected snapshot ownership to `VersionService`, adding dedicated `policy_versions.secret_fingerprints` and `policy_versions.redaction_version` fields, and extending audit/output sanitizers to preserve safe configuration language.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail
**Storage**: PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables)
**Testing**: Pest 4 feature/unit tests run via `vendor/bin/sail artisan test --compact`
**Target Platform**: Laravel Sail containers for local dev; web application with tenant `/admin` and workspace/admin monitoring surfaces
**Project Type**: Single Laravel web application
**Performance Goals**: Deterministic protected snapshot generation on every capture; monitoring pages remain DB-only; secret-only changes must not collapse during dedupe
**Constraints**: No new dependencies; no historical-data remediation workflow; workspace-scoped HMACs only; no raw substring redaction in persisted snapshot or audit paths
**Scale/Scope**: Touches all `PolicyVersion` writes, downstream drift/compare/restore consumers, audit/verification/ops sanitizers, and focused Pest regression coverage
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after scope update: still passed.*
- **Inventory-first**: PASS — inventory remains “last observed”; Spec 120 only hardens immutable snapshot protection.
- **Read/write separation**: PASS — writes are limited to protected snapshot persistence and existing user-initiated flows.
- **Graph contract path**: PASS — no new Graph endpoints or bypasses are introduced.
- **Deterministic capabilities**: PASS — capability logic is unchanged; regression work focuses on deterministic classifier output and version identity.
- **RBAC / plane separation**: PASS — tenant evidence remains under `/admin`; no new cross-plane workflow remains in scope.
- **Workspace / tenant isolation**: PASS — workspace-scoped HMAC derivation uses `workspace_id`.
- **Destructive confirmation standard**: PASS — no new destructive surfaces are introduced.
- **Global search safety**: PASS — no new searchable resources are added.
- **Run observability**: PASS — existing capture/compare/restore/export flows keep their current operations behavior.
- **Ops-UX 3-surface feedback**: PASS — existing operation starts remain unchanged.
- **OperationRun lifecycle ownership**: PASS — no direct status/outcome writes are introduced.
- **Ops regression guards**: PASS — the plan keeps regression tests for redaction and output behavior.
- **Data minimization**: PASS — fingerprint storage is non-reversible, logs/audit remain sanitized, and no raw secret material is persisted.
- **BADGE-001 / Filament action surface / UX-001**: PASS — the release changes existing read-only views only.
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/Operations/
│ ├── Resources/FindingResource/
│ ├── Support/
│ └── Widgets/Tenant/
├── Models/
├── Services/
│ ├── Audit/
│ └── Intune/
└── Support/
├── Audit/
├── OpsUx/
└── Verification/
database/
├── factories/
└── migrations/
tests/
├── Feature/
│ ├── Audit/
│ ├── Intune/
│ ├── OpsUx/
│ ├── Operations/
│ └── Verification/
└── Unit/
├── Intune/
├── OpsUx/
└── Verification/
```
**Structure Decision**: Keep the existing single Laravel application structure. Implement the central classifier and protected snapshot DTO under `app/Services/Intune`, extend existing sanitizers in `app/Support/*`, evolve `PolicyVersion` persistence via migrations/model/factory updates, and cover the behavior with focused Pest tests under existing `tests/Feature` and `tests/Unit` namespaces.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|

View File

@ -0,0 +1,58 @@
# Quickstart — Secret Redaction Hardening & Snapshot Data Integrity (Spec 120)
## Prereqs
- Run the app with Sail.
- Use a workspace with at least one tenant that already has policy snapshots.
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Run migrations: `vendor/bin/sail artisan migrate`
## How to exercise the feature (manual)
### 1) Capture a policy with safe configuration fields
- Capture or refresh a policy version whose payload contains safe keys such as:
- `passwordMinimumLength`
- `passwordRequired`
- `certificateValidityPeriodScale`
- `tokenType`
- Expected:
- The persisted `PolicyVersion` keeps those configuration values intact.
- `redaction_version = 1`.
- `secret_fingerprints` is empty if no true protected fields are present.
### 2) Capture a policy with true protected fields
- Capture or refresh a policy version whose payload contains true secrets such as:
- `password`
- `clientSecret`
- `privateKey`
- Expected:
- The persisted payload stores `[REDACTED]` at the protected paths.
- `secret_fingerprints` contains digests for those paths.
- No raw secret appears in `snapshot`, `assignments`, `scope_tags`, audit metadata, verification output, or run failures.
### 3) Validate secret-only change detection
- Re-capture the same policy after changing only a true protected value.
- Expected:
- A new `PolicyVersion` is created even if the visible protected payload is unchanged.
- Compare/drift surfaces report a protected change without revealing the value.
- Safe configuration fields remain readable.
### 4) Validate audit and output readability
- Review the existing tenant/admin and workspace/admin surfaces that show sanitized evidence:
- finding detail view
- verification report viewer/widget
- operation run detail / monitoring surfaces
- Expected:
- true secret values remain hidden
- harmless phrases such as `passwordMinimumLength` remain readable
- protected-value messaging is visible where the UI explains hidden values
## Tests (Pest)
- Run focused suites once implemented:
- `vendor/bin/sail artisan test --compact tests/Feature/Intune/PolicySnapshotRedactionTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/AuditContextSanitizerTest.php`
- `vendor/bin/sail artisan test --compact --filter=VerificationReportSanitizer`
- `vendor/bin/sail artisan test --compact --filter=RunFailureSanitizer`
- Format changed PHP files before final review:
- `vendor/bin/sail bin pint --dirty --format agent`

View File

@ -0,0 +1,44 @@
# Research — Secret Redaction Hardening & Snapshot Data Integrity (Spec 120)
This document records the design choices for the reduced Spec 120 scope after removing the pre-go-live legacy-data remediation workflow.
## Decisions
### 1) Central classification authority
- Decision: Introduce one shared secret-classification service that evaluates protected fields by exact field name plus canonical path, and reuse it across snapshot protection, audit sanitization, verification sanitization, and ops failure sanitization.
- Rationale: The current codebase had multiple substring-based sanitizers. Spec 120 requires one authority so safe configuration fields like `passwordMinimumLength` remain visible while true secrets stay protected.
### 2) Canonical protected-path format
- Decision: Represent protected locations as source-bucketed RFC 6901 JSON Pointers, stored under `secret_fingerprints` buckets: `snapshot`, `assignments`, and `scope_tags`.
- Rationale: JSON Pointer is deterministic, array-safe, and avoids ambiguity between object keys and numeric list indexes.
### 3) Single ownership of persisted snapshot protection
- Decision: Make `VersionService::captureVersion()` the sole write-time owner of protected snapshot generation.
- Rationale: `VersionService` is the final `PolicyVersion` persistence boundary. Removing duplicate masking from `PolicyCaptureOrchestrator` eliminates double-redaction and ensures dedupe/version creation decisions use the same protected result.
### 4) Protected snapshot persistence contract
- Decision: Persist protected values as `[REDACTED]`, store the ruleset marker in `policy_versions.redaction_version`, and store path-keyed HMAC digests in `policy_versions.secret_fingerprints`.
- Rationale: The placeholder preserves JSON shape for downstream consumers, while dedicated columns keep the change signal and contract version out of generic metadata.
### 5) Fingerprint derivation strategy
- Decision: Use HMAC-SHA256 with a signing key derived from the app key and the stable `workspace_id`, then hash the tuple `(source_bucket, json_pointer, normalized_secret_value)`.
- Rationale: This satisfies the workspace-isolation requirement while keeping fingerprints deterministic inside one workspace and non-correlatable across workspaces.
### 6) Fingerprinting scope and version identity
- Decision: Apply the protected contract consistently to all persisted protected payload buckets: `snapshot`, `assignments`, and `scope_tags`. Version identity must incorporate both the visible protected payload and the fingerprint map so secret-only changes create a new `PolicyVersion`.
- Rationale: If dedupe ignores `secret_fingerprints`, secret-only changes still collapse into one version and FR-120-007 fails.
### 7) Output readability and integrity messaging
- Decision: Protected-value messaging remains text-first on existing viewers and export surfaces. The product explains that protected values were intentionally hidden, but it does not ship a dedicated historical-data remediation workflow.
- Rationale: Production starts with fresh compliant data, so the feature only needs to explain current protected behavior, not historical repair.
### 8) Regression strategy
- Decision: Replace substring-match regression expectations with a corpus-based test matrix covering safe fields, true secrets, secret-only version changes, audit/verification readability, and notification/export behavior.
- Rationale: The existing suite proved the old broken behavior. Phase 1 needs tests that lock in exact/path-based classification and block new broad substring redactors.
## Repo Facts Used
- `PolicySnapshotRedactor` previously used broad regex patterns and was invoked both in `PolicyCaptureOrchestrator` and `VersionService`.
- `AuditContextSanitizer`, `VerificationReportSanitizer`, and `RunFailureSanitizer` all contained substring-based protection logic.
- `policy_versions` already stores immutable snapshot evidence consumed by drift, compare, and restore flows.
- Pre-go-live data is disposable for this product rollout, so no supported legacy-data remediation workflow is required in this feature.

View File

@ -0,0 +1,197 @@
# Feature Specification: Secret Redaction Hardening & Snapshot Data Integrity
**Feature Branch**: `120-secret-redaction-integrity`
**Created**: 2026-03-06
**Updated**: 2026-03-07
**Status**: Draft
**Owner**: Platform / Security
**Input**: User description: "Spec 120 — Secret Redaction Hardening & Snapshot Data Integrity (Enterprise)"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace + tenant
- **Primary Routes**:
- Tenant-context drift and finding detail views
- Tenant-context baseline compare landing and related run detail links
- Workspace and tenant monitoring views that show sanitized run or audit context
- Existing verification and review/export surfaces that render policy evidence
- **Data Ownership**:
- Tenant-owned: policy snapshots, drift evidence, restore evidence, compare inputs, operation context tied to a tenant
- Workspace-owned: audit records, review/export artifacts, workspace-level monitoring context
- **RBAC**:
- No new roles or capabilities are introduced
- Existing membership and capability rules continue to govern snapshot capture, drift/compare visibility, restore flows, monitoring views, and exports
- Non-members remain 404; members without capability remain 403
For canonical-view specs: not applicable.
## Problem Statement
Current secret redaction rules treat broad words such as “password”, “token”, and “certificate” as automatic matches whenever they appear inside a key name. That behavior incorrectly masks normal policy configuration data before it is persisted. Once those values are replaced, downstream experiences such as drift detection, compare views, restore fidelity, evidence exports, and audit review cannot recover the original meaning.
The product needs a hardened redaction model that protects actual secrets while preserving legitimate configuration data and maintaining trustworthy governance evidence.
## Goals
- **Zero false positives for configuration fields**: legitimate policy settings remain visible and comparable.
- **Reliable secret protection**: true credentials, keys, passphrases, and tokens stay hidden across storage, logs, exports, and UI output.
- **Meaningful change detection**: secret changes remain detectable without exposing secret values.
- **Single source of truth**: one classification policy governs all snapshot, audit, and output redaction behavior.
## Non-Goals
- No vault or external secret-management project.
- No historical-remediation workflow for pre-go-live data.
- No expansion of RBAC scope or new operator personas.
- No requirement to extend `GraphContractRegistry` with `secret_fields` metadata in the first release.
## Definitions
- **Secret field**: a value that must never be exposed verbatim to operators or exported artifacts.
- **Configuration field**: a policy setting whose actual value must remain available for compare, drift, restore, and audit comprehension.
- **Protected snapshot**: a persisted policy snapshot that preserves non-secret values and stores only protected representations for secret values.
- **Output context**: any user-facing or operator-facing rendering surface, export, notification, or log message derived from protected data.
## Assumptions
- Existing capture, drift, compare, restore, audit, and export workflows remain the same functional entry points.
- Pre-go-live development data is disposable and no historical policy snapshot inventory will be carried into production.
- This feature may change how existing viewers render evidence, but it does not introduce new primary user workflows.
## Clarifications
### Session 2026-03-06
- Q: Where should protected snapshot fingerprinting/versioning data live? → A: Add dedicated `policy_versions.secret_fingerprints` and `policy_versions.redaction_version` columns.
- Q: How should protected secret fields be represented inside persisted snapshots? → A: Persist them as a constant placeholder like `[REDACTED]`, with `secret_fingerprints` holding the change signal.
- Q: How should workspace-scoped fingerprint HMACs be derived? → A: Derive them from the app key plus a stable workspace-specific salt or identifier.
- Q: Should schema-driven `GraphContractRegistry` secret metadata be part of the initial implementation? → A: No. Keep the first release focused on a central exact/path-based classifier and defer schema-driven contract metadata to a follow-up phase.
### Session 2026-03-07
- Q: Do we need a legacy-data remediation workflow before go-live? → A: No. Pre-go-live data is disposable, production starts with fresh compliant captures, and no historical snapshot inventory is being migrated in.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Preserve trustworthy drift and compare evidence (Priority: P1)
As a tenant operator, I can compare snapshots and investigate drift without harmless configuration values being masked as secrets, so findings remain trustworthy and actionable.
**Why this priority**: Preventing silent data corruption is the core value of this feature and unblocks every downstream governance workflow.
**Independent Test**: Capture or compare policies containing known safe configuration fields and verify that those values remain visible in snapshot-backed evidence while actual secret values remain protected.
**Acceptance Scenarios**:
1. **Given** a policy snapshot contains configuration fields whose names include words like “password”, “token”, or “certificate”, **When** the snapshot is persisted and later used for drift or compare, **Then** those configuration values remain available for meaningful comparison.
2. **Given** two snapshots differ only in a protected secret value, **When** drift or compare is run, **Then** the system reports that a protected value changed without revealing the value itself.
---
### User Story 2 - Protect secrets consistently across operational surfaces (Priority: P2)
As a workspace or tenant operator, I can use monitoring, audit, verification, and export surfaces without secret values leaking into visible output.
**Why this priority**: Defense-in-depth must remain intact even after storage integrity is fixed.
**Independent Test**: View monitoring details, verification output, review/export artifacts, and audit context for records that include both true secrets and similarly named configuration fields, then verify that only the true secrets are hidden.
**Acceptance Scenarios**:
1. **Given** an audit or monitoring record includes both protected fields and harmless configuration values, **When** the record is rendered, **Then** only the protected values are hidden and the diagnostic meaning of the record remains intact.
2. **Given** a verification or export artifact includes policy evidence with protected values, **When** an operator views or downloads the artifact, **Then** no secret value is exposed while non-secret configuration evidence remains understandable.
### Edge Cases
- Unknown field names that contain sensitive-looking words but behave like configuration fields must default to preserving operator-meaningful data unless the field is explicitly classified as protected.
- Nested structures and repeated field names must be classified consistently regardless of depth or path segment position.
- The same secret value used in different workspaces must remain non-correlatable across workspaces while still allowing change detection within a workspace.
- Free-text messages that mention words like `passwordMinimumLength` or `tokenType` must remain readable unless they contain an actual secret value pattern.
## Requirements *(mandatory)*
### Constitution alignment (required)
- This feature changes how snapshot and audit data are protected before downstream use. It does not add new Microsoft Graph endpoints, and it does not add a new operator workflow.
- Existing capture, compare, restore, and export behaviors remain observable through the existing operations surfaces.
### Constitution alignment (OPS-UX)
- Existing capture, compare, restore, and export operations MUST continue to follow the three-surface feedback contract when user-initiated.
- `OperationRun` lifecycle ownership remains unchanged; this feature does not allow direct status or outcome writes outside the service-owned lifecycle.
- Regression tests MUST cover any new run summary or redaction-related operations behavior introduced by this feature.
### Constitution alignment (RBAC-UX)
- Authorization planes involved: tenant/admin `/admin` with tenant-context views plus workspace/admin monitoring and audit surfaces.
- Non-members or users outside the permitted workspace or tenant scope continue to receive 404 responses.
- Members lacking the existing capability to view findings, exports, monitoring, or audits continue to receive 403 responses.
- Any operator action that starts snapshot capture, restore, or export remains enforced server-side and may not rely on UI visibility alone.
- No raw capability strings or role-string checks are introduced by this feature.
### Constitution alignment (BADGE-001)
- If existing UI surfaces display a “protected change” indicator, those labels MUST use centralized badge semantics rather than page-specific mappings.
- The initial release does not require new badge-only indicators; explanatory copy may remain text-first.
### Constitution alignment (Filament Action Surfaces)
- This feature changes rendering behavior on existing Filament surfaces but does not add new destructive actions or new mutation entry points in scope.
- The action surface contract remains satisfied because all existing actions keep their current behavior; only the evidence shown behind those actions changes.
### Constitution alignment (UX-001 — Layout & Information Architecture)
- Existing Filament screens may display clearer protected-value messaging, but this feature does not introduce new create/edit forms.
- Any new empty or blocked state related to protected evidence MUST keep a single clear call to action and maintain existing information architecture patterns.
### Functional Requirements
- **FR-120-001 Exact secret classification**: The system MUST classify protected fields using an exact, path-aware classification policy rather than broad substring matching.
- **FR-120-002 Zero false positives for known configuration data**: The system MUST preserve legitimate configuration values even when their field names include sensitive-looking words.
- **FR-120-003 Single classification authority**: Snapshot protection, audit sanitization, and output sanitization MUST all rely on the same authoritative classification rules.
- **FR-120-003a Initial classifier scope**: The first release MUST implement the classification authority as a central exact/path-based classifier and MUST NOT require new `GraphContractRegistry` secret-field metadata to be complete.
- **FR-120-004 Single ownership of snapshot protection**: The system MUST apply snapshot protection at one clearly defined point in the persistence flow and MUST NOT apply duplicate masking passes to the same snapshot write.
- **FR-120-005 Dedicated persistence fields**: The system MUST persist protected-change data in dedicated `policy_versions.secret_fingerprints` and `policy_versions.redaction_version` columns rather than embedding it in generic metadata.
- **FR-120-006 Persisted secret placeholder contract**: The system MUST preserve the original field shape in persisted snapshots by replacing protected secret values with a constant placeholder token such as `[REDACTED]`; the placeholder itself MUST NOT be used as the change-detection signal.
- **FR-120-007 Protected change detectability**: The system MUST retain a non-reversible protected-value change signal so compare, drift, and review workflows can report that a secret changed without exposing the secret.
- **FR-120-007a Workspace-scoped fingerprint derivation**: The system MUST derive protected-value fingerprints from the application key plus a stable workspace-specific salt or identifier so the same secret cannot be correlated across workspaces.
- **FR-120-008 Snapshot integrity for downstream workflows**: Protected snapshots MUST remain usable as the source of truth for drift detection, baseline comparison, restore fidelity, and evidence generation.
- **FR-120-009 Audit integrity**: Audit context protection MUST hide true secret values while preserving non-secret operational details needed for investigation and support.
- **FR-120-010 Output-context hardening**: Monitoring views, verification output, review/export artifacts, notifications, and human-readable failure messages MUST avoid leaking secret values while remaining readable and diagnostically useful.
- **FR-120-011 Transparency of protection behavior**: Operators MUST be able to tell when protected values were hidden without revealing the hidden values themselves.
- **FR-120-012 Regression guardrails**: The product MUST include automated guardrails that fail when new broad substring-based redaction logic is introduced into protected storage or audit paths.
### Non-Functional Requirements
- **NFR-120-001 Determinism**: The same input snapshot and classification rules MUST always produce the same protected result and the same protected-change signal.
- **NFR-120-002 Workspace isolation**: Protected-change signals MUST not enable value correlation across workspace boundaries and MUST rely on the workspace-scoped derivation strategy defined for fingerprints.
- **NFR-120-003 Explainability**: Operators MUST be able to distinguish visible configuration data from intentionally hidden protected values.
### Deferred Follow-Up
- Schema-driven secret classification via `GraphContractRegistry` metadata is explicitly deferred to a later phase after the central classifier and regression corpus are stable.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Resource Page | app/Filament/Resources/FindingResource/Pages/ViewFinding.php | No change | Existing finding list → view | No change | No change | No change | Existing workflow actions unchanged | N/A | Yes | Read-only drift/compare evidence may show protected-change messaging; no new actions are introduced. |
| Widget / Support Viewer | app/Filament/Widgets/Tenant/TenantVerificationReport.php and app/Filament/Support/VerificationReportViewer.php | No change | Existing verification widget drill-in | N/A | N/A | Existing verification empty state remains single-CTA | N/A | N/A | No new mutation | Output sanitization changes only; action surface contract unchanged. |
| Page | app/Filament/Pages/Operations/TenantlessOperationRunViewer.php | No change | Existing operations list → run detail | No change | No change | No change | No change | N/A | Yes | Human-readable failure and context rendering becomes more precise; no new actions or destructive flows are added. |
## Key Entities *(include if feature involves data)*
- **Protected Snapshot Record**: the persisted source-of-truth record used by compare, drift, restore, and evidence workflows, containing visible configuration data plus dedicated `secret_fingerprints` and `redaction_version` fields for protected secret tracking.
- **Protected Placeholder**: the constant persisted token used to preserve snapshot field shape for secret values while the real change signal is stored separately in `secret_fingerprints`.
- **Protected Change Signal**: a non-reversible marker that allows the system to determine whether a secret changed without revealing the secret value.
- **Workspace-Scoped Fingerprint Salt**: the stable workspace-specific input combined with the app key to derive non-correlatable secret fingerprints.
- **Secret Classification Policy**: the authoritative rule set that determines whether a field is a true secret or a visible configuration field.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-120-001 Zero safe-field regressions**: 100% of the approved regression corpus of known configuration fields remains visible after protection is applied.
- **SC-120-002 Protected-field coverage**: 100% of the approved regression corpus of known secret fields is hidden in snapshot, audit, and output-context tests.
- **SC-120-003 Meaningful protected-change detection**: When only a protected value changes, compare and drift workflows report a protected change without exposing the value in 100% of covered regression scenarios.
- **SC-120-004 Diagnostic readability**: Verification, monitoring, and failure-message regression tests preserve operator-readable messages for known safe phrases such as policy configuration names in 100% of covered cases.

View File

@ -0,0 +1,109 @@
# Tasks: Secret Redaction Hardening & Snapshot Data Integrity
**Input**: Design documents from `/specs/120-secret-redaction-integrity/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest).
**RBAC**: The feature keeps existing authorization planes intact. Tenant/admin surfaces remain under `/admin`. Non-members remain 404, members missing capability remain 403.
**Filament UI Action Surfaces**: Existing surfaces are read-only updates only. No new tenant-facing or platform-facing action workflow is introduced in this reduced scope.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing.
## Phase 1: Setup
**Purpose**: Shared test utilities used across snapshot and sanitizer work.
- [X] T001 Create shared protected snapshot assertions in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Support/ProtectedSnapshotAssertions.php
---
## Phase 2: Foundational
**Purpose**: Core persistence and classifier infrastructure that MUST be complete before user-story work.
- [X] T002 Add `policy_versions.secret_fingerprints` and `policy_versions.redaction_version` in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/migrations/2026_03_07_000121_add_redaction_contract_to_policy_versions_table.php
- [X] T003 Update casts and factory defaults for the new redaction fields in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/PolicyVersion.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/factories/PolicyVersionFactory.php
- [X] T004 Implement the shared exact/path-based classifier and DTO in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/SecretClassificationService.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/ProtectedSnapshotResult.php
- [X] T005 Implement deterministic protected snapshot building with JSON Pointer fingerprint paths in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/PolicySnapshotRedactor.php
- [X] T006 Implement workspace-scoped fingerprint HMAC derivation in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/SecretFingerprintHasher.php
- [X] T007 Add a regression guard for forbidden substring-based storage and audit-path redaction patterns in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoBroadSecretRedactionPatternsTest.php
- [X] T008 Add an audit-path guard for forbidden broad redaction fallbacks in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/Spec120NoBroadAuditRedactionFallbacksTest.php
- [X] T009 Add a scope guard proving phase 1 does not require `GraphContractRegistry` secret metadata in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/Spec120NoGraphContractSecretMetadataTest.php
---
## Phase 3: User Story 1 - Preserve trustworthy drift and compare evidence (Priority: P1)
**Goal**: Persist protected snapshots without corrupting safe configuration fields and keep secret-only changes visible to drift/compare workflows.
### Tests for User Story 1
- [X] T010 Add classifier corpus coverage for safe fields, protected fields, and JSON Pointer paths in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Intune/SecretClassificationServiceTest.php
- [X] T011 Update snapshot persistence and secret-only version-change coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Intune/PolicySnapshotRedactionTest.php
- [X] T012 Add workspace-isolation fingerprint coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Intune/PolicySnapshotFingerprintIsolationTest.php
- [X] T013 Add compare/drift protected-change coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/BaselineCompareProtectedChangeTest.php
### Implementation for User Story 1
- [X] T014 Refactor snapshot persistence to write `[REDACTED]`, `secret_fingerprints`, and `redaction_version` in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/VersionService.php
- [X] T015 Remove duplicate pre-redaction and align version reuse with the protected snapshot contract in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/PolicyCaptureOrchestrator.php
- [X] T016 Integrate workspace-scoped fingerprint derivation into protected snapshot generation in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/PolicySnapshotRedactor.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/VersionService.php
- [X] T017 Update composite version identity hashing for secret-only changes in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Drift/DriftHasher.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/VersionService.php
- [X] T018 Surface protected-change evidence in compare and diff generation in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Drift/DriftFindingDiffBuilder.php
- [X] T019 Preserve restore fidelity while carrying redaction integrity metadata forward in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/RestoreService.php
---
## Phase 4: User Story 2 - Protect secrets consistently across operational surfaces (Priority: P2)
**Goal**: Reuse the same classification rules across audit, verification, monitoring, review/export, and viewer surfaces without hiding harmless configuration language.
### Tests for User Story 2
- [X] T020 Expand audit false-positive and audit-log persistence coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/AuditContextSanitizerTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Audit/WorkspaceAuditLoggerRedactionTest.php
- [X] T021 Expand verification sanitizer and viewer readability coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/VerificationReportSanitizerEvidenceKindsTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
- [X] T022 Add review/export artifact redaction-integrity coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/ReviewPack/ReviewPackRedactionIntegrityTest.php
- [X] T023 Expand failure-message and monitoring readability coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/OpsUx/RunFailureSanitizerTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/FailureSanitizationTest.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- [X] T024 Add explainability coverage for protected-value messaging in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/DriftFindingDetailTest.php
- [X] T025 Add notification payload sanitization coverage for redaction-safe terminal messages in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/OperationRunNotificationRedactionTest.php
### Implementation for User Story 2
- [X] T026 Switch audit sanitization to the shared classifier in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Audit/AuditContextSanitizer.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Audit/WorkspaceAuditLogger.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/AuditLogger.php
- [X] T027 Switch verification and ops-failure sanitizers to the shared classifier in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationReportSanitizer.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/RunFailureSanitizer.php
- [X] T028 Apply redaction-integrity rules to review/export artifacts and operation notification payloads in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/GenerateReviewPackJob.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Notifications/OperationRunCompleted.php
- [X] T029 Add protected-value messaging to evidence viewers in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/FindingResource/Pages/ViewFinding.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Support/VerificationReportViewer.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Tenant/TenantVerificationReport.php
- [X] T030 Update operations detail rendering and terminal notification copy for protected values in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/OperationUxPresenter.php
---
## Phase 5: Polish & Cross-Cutting Concerns
- [X] T031 Run the focused Spec 120 Pest suites covering /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Intune/PolicySnapshotRedactionTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Intune/PolicySnapshotFingerprintIsolationTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/ReviewPack/ReviewPackRedactionIntegrityTest.php, /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/OperationRunNotificationRedactionTest.php, and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
- [X] T032 Run formatting on touched PHP files with `vendor/bin/sail bin pint --dirty --format agent`
- [ ] T033 Validate the manual scenarios documented in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/120-secret-redaction-integrity/quickstart.md
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all story work.
- **User Story 1 (Phase 3)**: Starts after Foundational completion.
- **User Story 2 (Phase 4)**: Starts after Foundational completion and may be developed in parallel with US1 once the shared classifier, workspace-scoped hasher, and schema are in place.
- **Polish (Phase 5)**: Depends on the desired stories being complete.
### Within Each User Story
- Tests must be written first and fail before implementation.
- Persistence/model updates precede service refactors.
- Service refactors precede viewer/export integration.
- Ops-UX behavior and authorization semantics must be preserved before a story is considered done.
### Parallel Opportunities
- **Foundational**: T006, T007, T008, and T009 can run in parallel after T002 begins; T003 depends on T002.
- **US1**: T010, T011, T012, and T013 can run in parallel.
- **US2**: T020, T021, T022, T023, T024, and T025 can run in parallel.
- **Polish**: T031 and T033 can run in parallel before T032 finalizes formatting.

View File

@ -21,6 +21,7 @@
'metadata' => [
'access_token' => 'super-secret-token',
'client_secret' => 'super-secret-secret',
'passwordMinimumLength' => 12,
'nested' => [
'Authorization' => 'Bearer abc.def.ghi',
'safe' => 'ok',
@ -41,6 +42,7 @@
expect($log->metadata['access_token'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['client_secret'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['passwordMinimumLength'] ?? null)->toBe(12);
expect($log->metadata['nested']['Authorization'] ?? null)->toBe('[REDACTED]');
expect($log->metadata['nested']['safe'] ?? null)->toBe('ok');
});

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftFindingDiffBuilder;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicySnapshotRedactor;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates a drift finding for secret-only snapshot changes and surfaces protected-change evidence', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$baselineCapturedAt = CarbonImmutable::parse('2026-03-01 00:00:00');
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => $baselineCapturedAt,
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => 'policy-secret',
'platform' => 'windows',
'display_name' => 'Protected Policy',
]);
/** @var PolicySnapshotRedactor $redactor */
$redactor = app(PolicySnapshotRedactor::class);
/** @var DriftHasher $hasher */
$hasher = app(DriftHasher::class);
/** @var SettingsNormalizer $settingsNormalizer */
$settingsNormalizer = app(SettingsNormalizer::class);
/** @var AssignmentsNormalizer $assignmentsNormalizer */
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
/** @var ScopeTagsNormalizer $scopeTagsNormalizer */
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
$baselineProtected = $redactor->protect(
workspaceId: (int) $tenant->workspace_id,
payload: [
'wifi' => [
'ssid' => 'Corp',
'password' => 'baseline-secret',
],
],
);
$baselineHash = $hasher->hashNormalized([
'settings' => $settingsNormalizer->normalizeForDiff($baselineProtected->snapshot, 'deviceConfiguration', 'windows'),
'assignments' => $assignmentsNormalizer->normalizeForDiff([]),
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds([]),
'secret_fingerprints' => $baselineProtected->secretFingerprints,
'redaction_version' => $baselineProtected->redactionVersion,
]);
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
policyType: (string) $policy->policy_type,
subjectKey: (string) $subjectKey,
),
'subject_key' => (string) $subjectKey,
'policy_type' => (string) $policy->policy_type,
'baseline_hash' => $baselineHash,
'meta_jsonb' => [
'display_name' => (string) $policy->display_name,
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => $baselineCapturedAt->toIso8601String(),
'observed_operation_run_id' => null,
],
],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => (string) $policy->external_id,
'policy_type' => (string) $policy->policy_type,
'display_name' => (string) $policy->display_name,
'meta_jsonb' => [
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'W/"same-etag"',
'scope_tag_ids' => [],
'assignment_target_count' => 1,
],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$baselineVersion = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'policy_type' => (string) $policy->policy_type,
'platform' => (string) $policy->platform,
'captured_at' => $baselineCapturedAt,
'snapshot' => $baselineProtected->snapshot,
'secret_fingerprints' => $baselineProtected->secretFingerprints,
'redaction_version' => $baselineProtected->redactionVersion,
]);
$currentProtected = $redactor->protect(
workspaceId: (int) $tenant->workspace_id,
payload: [
'wifi' => [
'ssid' => 'Corp',
'password' => 'rotated-secret',
],
],
);
$currentVersion = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'policy_type' => (string) $policy->policy_type,
'platform' => (string) $policy->platform,
'captured_at' => $baselineCapturedAt->addHour(),
'snapshot' => $currentProtected->snapshot,
'secret_fingerprints' => $currentProtected->secretFingerprints,
'redaction_version' => $currentProtected->redactionVersion,
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('subject_external_id', (string) $policy->external_id)
->first();
expect($finding)->toBeInstanceOf(Finding::class);
expect($finding?->evidence_jsonb['summary']['kind'] ?? null)->toBe('policy_snapshot');
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
expect($diff['summary']['message'] ?? null)->toContain('protected value change');
expect($diff['changed'])->toHaveKey('Protected > /wifi/password (value changed)');
});

View File

@ -3,6 +3,8 @@
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
test('finding detail renders without Graph calls', function () {
bindFailHardGraphClient();
@ -45,3 +47,98 @@
->assertSee($finding->fingerprint)
->assertSee($inventoryItem->display_name);
});
test('finding detail explains protected changes without exposing hidden values', function () {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'manager');
$policy = Policy::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => 'policy-redaction-note',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Policy redaction note',
'platform' => 'windows',
]);
$baseline = PolicyVersion::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
'created_by' => 'spec120@example.com',
'captured_at' => now()->subMinute(),
'snapshot' => [
'passwordMinimumLength' => 14,
],
'assignments' => [],
'scope_tags' => [],
'metadata' => ['source' => 'spec120_capture'],
'redaction_version' => 1,
'secret_fingerprints' => [
'snapshot' => [],
'assignments' => [],
'scope_tags' => [],
],
]);
$protectedCurrent = PolicyVersion::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
'created_by' => 'spec120@example.com',
'captured_at' => now(),
'snapshot' => [
'passwordMinimumLength' => 14,
'clientSecret' => '[REDACTED]',
],
'assignments' => [],
'scope_tags' => [],
'metadata' => ['source' => 'spec120_capture'],
'redaction_version' => 1,
'secret_fingerprints' => [
'snapshot' => ['/clientSecret' => 'abc123'],
'assignments' => [],
'scope_tags' => [],
],
]);
$finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'subject_type' => 'deviceConfiguration',
'subject_external_id' => 'policy-redaction-note',
'evidence_jsonb' => [
'change_type' => 'modified',
'summary' => ['kind' => 'policy_snapshot'],
'baseline' => [
'policy_version_id' => (int) $baseline->getKey(),
'redaction_version' => 1,
'secret_fingerprints' => [
'snapshot' => [],
'assignments' => [],
'scope_tags' => [],
],
],
'current' => [
'policy_version_id' => (int) $protectedCurrent->getKey(),
'redaction_version' => 1,
'secret_fingerprints' => $protectedCurrent->secret_fingerprints,
],
],
]);
InventoryItem::factory()->for($tenant)->create([
'external_id' => $finding->subject_external_id,
'display_name' => 'Policy redaction note',
]);
$this->actingAs($user)
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSee('Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.');
});

View File

@ -40,12 +40,22 @@
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
'snapshot' => [
'id' => $policy->external_id,
'passwordMinimumLength' => 14,
'clientSecret' => '[REDACTED]',
],
'assignments' => [['intent' => 'apply']],
'scope_tags' => [
'ids' => ['st-1'],
'names' => ['Tag 1'],
],
'redaction_version' => 1,
'secret_fingerprints' => [
'snapshot' => ['/clientSecret' => 'abc123'],
'assignments' => [],
'scope_tags' => [],
],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
@ -64,6 +74,7 @@
expect($backupSet)->not->toBeNull();
expect($backupSet->tenant_id)->toBe($tenant->id);
expect($backupSet->metadata['policy_version_id'] ?? null)->toBe($version->id);
expect($backupSet->metadata['integrity_warning'] ?? null)->toContain('Protected values are intentionally hidden');
$backupItem = BackupItem::query()->where('backup_set_id', $backupSet->id)->first();
expect($backupItem)->not->toBeNull();
@ -71,6 +82,9 @@
expect($backupItem->policy_identifier)->toBe($policy->external_id);
expect($backupItem->metadata['scope_tag_ids'] ?? null)->toBe(['st-1']);
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']);
expect($backupItem->metadata['redaction_version'] ?? null)->toBe(1);
expect($backupItem->metadata['integrity_warning'] ?? null)->toContain('Protected values are intentionally hidden');
expect($backupItem->metadata['protected_paths_count'] ?? null)->toBe(1);
});
test('readonly users cannot open restore wizard via policy version row action', function () {

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
it('does not allow broad substring or regex secret redaction patterns in persisted snapshot paths', function (): void {
$files = [
'app/Services/Intune/PolicySnapshotRedactor.php',
'app/Support/Audit/AuditContextSanitizer.php',
'app/Support/Verification/VerificationReportSanitizer.php',
'app/Support/OpsUx/RunFailureSanitizer.php',
];
$forbiddenPatterns = [
'/password/i',
'/secret/i',
'/token/i',
"str_contains(\$key, 'password')",
"str_contains(\$key, 'secret')",
"str_contains(\$key, 'token')",
];
foreach ($files as $file) {
$contents = file_get_contents(base_path($file));
expect($contents)->not->toBeFalse();
foreach ($forbiddenPatterns as $pattern) {
expect($contents)->not->toContain($pattern);
}
}
});

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
it('does not allow broad audit-path redaction fallbacks', function (): void {
$files = [
'app/Support/Audit/AuditContextSanitizer.php',
'app/Support/Verification/VerificationReportSanitizer.php',
'app/Support/OpsUx/RunFailureSanitizer.php',
];
$forbiddenPatterns = [
'FORBIDDEN_KEY_SUBSTRINGS',
"str_ireplace(\n ['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer ']",
"str_contains(\$lower, 'password')",
"str_contains(\$lower, 'secret')",
"str_contains(\$lower, 'token')",
];
foreach ($files as $file) {
$contents = file_get_contents(base_path($file));
expect($contents)->not->toBeFalse();
foreach ($forbiddenPatterns as $pattern) {
expect($contents)->not->toContain($pattern);
}
}
});

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
it('does not require graph contract secret metadata for spec 120', function (): void {
$contents = file_get_contents(base_path('config/graph_contracts.php'));
expect($contents)->not->toBeFalse();
expect($contents)->not->toContain('secret_metadata');
expect($contents)->not->toContain('protected_fields');
expect($contents)->not->toContain('redaction_rules');
});

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Models\Policy;
use App\Services\Intune\VersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\ProtectedSnapshotAssertions;
uses(RefreshDatabase::class);
it('derives different secret fingerprints per workspace for the same protected value', function (): void {
[$userA, $tenantA] = createUserWithTenant(role: 'owner');
[$userB, $tenantB] = createUserWithTenant(role: 'owner');
$policyA = Policy::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'policy_type' => 'settingsCatalogPolicy',
]);
$policyB = Policy::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'policy_type' => 'settingsCatalogPolicy',
]);
/** @var VersionService $service */
$service = app(VersionService::class);
$versionA = $service->captureVersion(
policy: $policyA,
payload: [
'wifi' => [
'password' => 'same-secret',
],
],
createdBy: $userA->email,
);
$versionB = $service->captureVersion(
policy: $policyB,
payload: [
'wifi' => [
'password' => 'same-secret',
],
],
createdBy: $userB->email,
);
ProtectedSnapshotAssertions::assertFingerprint($versionA->secret_fingerprints, 'snapshot', '/wifi/password');
ProtectedSnapshotAssertions::assertFingerprint($versionB->secret_fingerprints, 'snapshot', '/wifi/password');
expect($versionA->secret_fingerprints['snapshot']['/wifi/password'])
->not->toBe($versionB->secret_fingerprints['snapshot']['/wifi/password']);
});

View File

@ -10,10 +10,11 @@
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Intune\VersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\ProtectedSnapshotAssertions;
uses(RefreshDatabase::class);
it('redacts secrets before persisting snapshots and hashing content', function (): void {
it('redacts secrets before persisting snapshots and keeps secret-only changes distinct', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$policy = Policy::factory()->create([
@ -56,10 +57,14 @@
$fresh1 = PolicyVersion::query()->findOrFail((int) $version1->getKey());
$fresh2 = PolicyVersion::query()->findOrFail((int) $version2->getKey());
expect($fresh1->snapshot['wifi']['password'])->toBe('[REDACTED]');
expect($fresh2->snapshot['wifi']['password'])->toBe('[REDACTED]');
ProtectedSnapshotAssertions::assertRedactedPath($fresh1->snapshot, '/wifi/password');
ProtectedSnapshotAssertions::assertRedactedPath($fresh2->snapshot, '/wifi/password');
ProtectedSnapshotAssertions::assertFingerprint($fresh1->secret_fingerprints, 'snapshot', '/wifi/password');
ProtectedSnapshotAssertions::assertFingerprint($fresh2->secret_fingerprints, 'snapshot', '/wifi/password');
expect($fresh1->snapshot['wifi']['ssid'])->toBe('Corp');
expect($fresh2->snapshot['wifi']['ssid'])->toBe('Corp');
expect($fresh1->redaction_version)->toBe(1);
expect($fresh2->redaction_version)->toBe(1);
$settingsNormalizer = app(SettingsNormalizer::class);
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
@ -74,6 +79,8 @@
),
'assignments' => $assignmentsNormalizer->normalizeForDiff($fresh1->assignments ?? []),
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($fresh1->scope_tags ?? []),
'secret_fingerprints' => $fresh1->secret_fingerprints,
'redaction_version' => $fresh1->redaction_version,
]);
$hash2 = $hasher->hashNormalized([
@ -84,7 +91,9 @@
),
'assignments' => $assignmentsNormalizer->normalizeForDiff($fresh2->assignments ?? []),
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($fresh2->scope_tags ?? []),
'secret_fingerprints' => $fresh2->secret_fingerprints,
'redaction_version' => $fresh2->redaction_version,
]);
expect($hash1)->toBe($hash2);
expect($hash1)->not->toBe($hash2);
});

View File

@ -26,7 +26,7 @@
outcome: 'failed',
failures: [[
'code' => 'graph_forbidden',
'message' => "Authorization: {$rawBearer} client_secret=supersecret user=test.user@example.com",
'message' => "Authorization: {$rawBearer} client_secret=supersecret passwordMinimumLength is still readable user=test.user@example.com",
]],
);
@ -52,6 +52,7 @@
expect($notificationJson)->not->toContain('client_secret=supersecret');
expect($notificationJson)->not->toContain($rawBearer);
expect($notificationJson)->not->toContain('test.user@example.com');
expect($notificationJson)->toContain('passwordMinimumLength');
$this->actingAs($user)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Filament\Facades\Filament;
it('keeps safe configuration phrases readable in terminal notifications while redacting true secrets', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
]);
app(OperationRunService::class)->updateRun(
$run,
status: 'completed',
outcome: 'failed',
failures: [[
'code' => 'provider.failure',
'message' => 'passwordMinimumLength remains visible while client_secret=super-secret must be hidden.',
]],
);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect((string) ($notification->data['body'] ?? ''))->toContain('passwordMinimumLength');
expect((string) ($notification->data['body'] ?? ''))->not->toContain('super-secret');
});

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateReviewPackJob;
use App\Models\StoredReport;
use App\Services\ReviewPackService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('redacts protected report fields while preserving safe configuration evidence in review-pack exports', function (): void {
[$user, $tenant] = createUserWithTenant();
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => [
'passwordMinimumLength' => 14,
'clientSecret' => 'super-secret-value',
],
]);
Notification::fake();
$pack = app(ReviewPackService::class)->generate($tenant, $user, [
'include_pii' => true,
'include_operations' => false,
]);
$job = new GenerateReviewPackJob(
reviewPackId: (int) $pack->getKey(),
operationRunId: (int) $pack->operation_run_id,
);
app()->call([$job, 'handle']);
$pack->refresh();
$zipContent = Storage::disk('exports')->get($pack->file_path);
$tempFile = tempnam(sys_get_temp_dir(), 'spec120-review-pack-');
file_put_contents($tempFile, $zipContent);
$zip = new ZipArchive;
$zip->open($tempFile);
$report = json_decode((string) $zip->getFromName('reports/permission_posture.json'), true, 512, JSON_THROW_ON_ERROR);
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR);
expect($report['passwordMinimumLength'] ?? null)->toBe(14);
expect($report['clientSecret'] ?? null)->toBe('[REDACTED]');
expect(data_get($metadata, 'redaction_integrity.protected_values_hidden'))->toBeTrue();
$zip->close();
unlink($tempFile);
});

View File

@ -58,6 +58,62 @@
Bus::assertNothingDispatched();
});
it('shows the protected-value note while keeping safe configuration phrases readable', function (): void {
Bus::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => [
'schema_version' => '1.5.0',
'flow' => 'provider.connection.check',
'generated_at' => now()->toIso8601String(),
'summary' => [
'overall' => 'needs_attention',
'counts' => [
'total' => 1,
'pass' => 0,
'fail' => 1,
'warn' => 0,
'skip' => 0,
'running' => 0,
],
],
'checks' => [[
'key' => 'safe_phrase',
'title' => 'Safe phrase',
'status' => 'fail',
'severity' => 'high',
'blocking' => false,
'reason_code' => 'provider_permission_denied',
'message' => 'passwordMinimumLength is readable while client_secret=supersecret must be hidden.',
'evidence' => [],
'next_steps' => [],
]],
],
],
]);
assertNoOutboundHttp(function () use ($run): void {
Livewire::test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('passwordMinimumLength')
->assertSee('Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.')
->assertDontSee('supersecret');
});
Bus::assertNothingDispatched();
});
it('renders onboarding verify surfaces DB-only (no outbound HTTP, no job/queue dispatch)', function (): void {
Bus::fake();
Queue::fake();

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PHPUnit\Framework\Assert;
final class ProtectedSnapshotAssertions
{
/**
* @param array<string, mixed> $payload
*/
public static function assertRedactedPath(array $payload, string $jsonPointer): void
{
Assert::assertSame('[REDACTED]', self::valueAtPointer($payload, $jsonPointer));
}
/**
* @param array<string, mixed> $secretFingerprints
*/
public static function assertFingerprint(array $secretFingerprints, string $bucket, string $jsonPointer): void
{
$bucketFingerprints = $secretFingerprints[$bucket] ?? null;
Assert::assertIsArray($bucketFingerprints);
Assert::assertArrayHasKey($jsonPointer, $bucketFingerprints);
Assert::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $bucketFingerprints[$jsonPointer]);
}
/**
* @param array<string, mixed> $payload
*/
private static function valueAtPointer(array $payload, string $jsonPointer): mixed
{
$segments = explode('/', ltrim($jsonPointer, '/'));
$value = $payload;
foreach ($segments as $segment) {
$segment = str_replace(['~1', '~0'], ['/', '~'], $segment);
if (! is_array($value) || ! array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
}

View File

@ -17,3 +17,14 @@
expect(AuditContextSanitizer::sanitize($jwt))
->toBe('[REDACTED]');
});
it('keeps safe configuration language visible', function (): void {
expect(AuditContextSanitizer::sanitize([
'passwordMinimumLength' => 12,
'password' => 'super-secret',
]))
->toBe([
'passwordMinimumLength' => 12,
'password' => '[REDACTED]',
]);
});

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use App\Services\Intune\SecretClassificationService;
it('protects exact secret fields and keeps safe configuration language visible', function (): void {
$service = app(SecretClassificationService::class);
expect($service->protectsField('snapshot', 'password'))->toBeTrue();
expect($service->protectsField('snapshot', 'clientSecret'))->toBeTrue();
expect($service->protectsField('snapshot', 'passwordMinimumLength'))->toBeFalse();
expect($service->protectsField('snapshot', 'tokenType'))->toBeFalse();
});
it('supports exact path-based protection decisions', function (): void {
$service = app(SecretClassificationService::class);
expect($service->protectsField('snapshot', 'password', '/wifi/password'))->toBeTrue();
expect($service->protectsField('snapshot', 'passwordMinimumLength', '/settings/passwordMinimumLength'))->toBeFalse();
});

View File

@ -28,3 +28,12 @@
->not->toContain('ghi')
->not->toContain('jkl');
});
it('keeps safe configuration language readable in failure messages', function (): void {
$message = 'passwordMinimumLength is 12 while password=super-secret should stay hidden.';
$sanitized = RunFailureSanitizer::sanitizeMessage($message);
expect($sanitized)->toContain('passwordMinimumLength');
expect($sanitized)->not->toContain('super-secret');
});

View File

@ -48,3 +48,41 @@
expect($evidence)->toContain(['kind' => 'observed_permissions_count', 'value' => 0]);
expect($evidence)->not->toContain(['kind' => 'client_secret', 'value' => 'nope']);
});
it('keeps safe configuration phrases in verification messages', function (): void {
$report = [
'schema_version' => '1',
'flow' => 'managed_tenant_onboarding',
'generated_at' => now()->toIso8601String(),
'summary' => [
'overall' => 'warn',
'counts' => [
'total' => 1,
'pass' => 0,
'fail' => 0,
'warn' => 1,
'skip' => 0,
'running' => 0,
],
],
'checks' => [
[
'key' => 'password.policy',
'title' => 'Password policy',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => 'password_policy_warning',
'message' => 'passwordMinimumLength remains visible while password=super-secret is hidden.',
'evidence' => [],
'next_steps' => [],
],
],
];
$sanitized = VerificationReportSanitizer::sanitizeReport($report);
$message = $sanitized['checks'][0]['message'] ?? null;
expect($message)->toContain('passwordMinimumLength');
expect($message)->not->toContain('super-secret');
});