$data 정책 데이터 (key/scope/target/purpose 등) * @return IdentityPolicy 동기화된 정책 */ public function syncPolicy(array $data): IdentityPolicy { $policy = IdentityPolicy::syncOrCreateFromUpgrade( ['key' => $data['key']], [ 'scope' => $data['scope'] ?? 'route', 'target' => $data['target'] ?? $data['key'], 'purpose' => $data['purpose'] ?? 'sensitive_action', 'provider_id' => $data['provider_id'] ?? null, 'grace_minutes' => (int) ($data['grace_minutes'] ?? 0), 'enabled' => (bool) ($data['enabled'] ?? true), 'priority' => (int) ($data['priority'] ?? 100), 'conditions' => $data['conditions'] ?? null, 'source_type' => $data['source_type'] ?? 'core', 'source_identifier' => $data['source_identifier'] ?? 'core', 'applies_to' => $data['applies_to'] ?? 'both', 'fail_mode' => $data['fail_mode'] ?? 'block', ] ); // scope=hook 정책이 새로 적재되면 그 target 에 enforce 구독을 멱등 (재)바인딩한다. // 코어 리스너 자동발견은 부팅 전반부에 1회만 일어나 그 시점에 없던 모듈 hook target 을 // 놓치므로, 정책이 DB 에 적재되는 이 시점에 보충한다(이미 바인딩된 target 은 멱등 스킵). if (($policy->scope instanceof \BackedEnum ? $policy->scope->value : $policy->scope) === 'hook') { EnforceIdentityPolicyListener::syncDynamicHookSubscriptions(); } return $policy; } /** * seed/정의에 없는 stale 정책을 삭제합니다 (완전 동기화 원칙). * * 운영자가 S1d 에서 직접 생성한 정책(source_type='admin')은 영향받지 않습니다. * * @param string $sourceType 확장 타입 (core|module|plugin) * @param string $sourceIdentifier 확장 식별자 * @param array $currentKeys 현재 유효한 정책 key 목록 * @return int 삭제된 정책 수 */ public function cleanupStalePolicies( string $sourceType, string $sourceIdentifier, array $currentKeys, ): int { $targets = $this->repository->findStale($sourceType, $sourceIdentifier, $currentKeys); foreach ($targets as $policy) { // per-model delete — deleted 이벤트로 라우트 스코프 캐시가 flush 된다(bulk delete 와 차이). $policy->delete(); } $count = $targets->count(); if ($count > 0) { Log::info('IdentityPolicySyncHelper: stale 정책 정리', [ 'source_type' => $sourceType, 'source_identifier' => $sourceIdentifier, 'deleted_count' => $count, 'deleted_keys' => $targets->pluck('key')->all(), ]); } return $count; } }