*/ public const GRANTED_CAPABILITIES = array( AdminCapability::CAP_VIEW_DIAGNOSTICS, AdminCapability::CAP_VIEW_LOGS, AdminCapability::CAP_RUN_SYNC, ); /** * Optional §10.1 audit-event writer. * * When non-null, a successful capability-grant pass emits one * `security_event` audit row through this writer summarizing * the grant target (`administrator` role) and the granted * capability set. When null (the default) the activator stays * silent — pure-unit tests and bootstrap paths that pre-date * the audit-writer port keep working without modification. * * Per the {@see AuditEventWriter} contract, audit emit * failures are tolerated: a {@see RuntimeException} from * `record()` is swallowed inside {@see self::emitAudit()} so * losing one audit row never masks the underlying capability * grant (which is itself idempotent and observable through * the persisted role definition). */ private ?AuditEventWriter $auditWriter; /** * Construct the activator with an optional audit-event writer. * * Empty / no-arg construction is the production default for * pure-unit tests and any bootstrap path that does not want * activation-time audit emission. Production wiring (the * plugin entry file) passes a {@see RepositoryAuditEventWriter} * built around the persistence-layer audit-event repository so * every successful capability grant lands an audit row in * `affilun_audit_events`. * * @param AuditEventWriter|null $auditWriter Optional §10.1 * audit writer; when * null, the activator * does not emit audit * rows. */ public function __construct( ?AuditEventWriter $auditWriter = null ) { $this->auditWriter = $auditWriter; } /** * No-op binding pass. * * The activator publishes no registry bindings; consumers do * not need to resolve anything from it. The two-phase * lifecycle still requires an implementation here so the * provider can subscribe to `affilun_activate` from * {@see self::boot()} after every other provider's * `register()` has run. * * Soft-resolve the {@see AuditEventRepository} from the * registry and self-construct a {@see RepositoryAuditEventWriter} * when no explicit writer was supplied at construction time. * * Resolution rules: * * - When the constructor was called with an explicit * {@see AuditEventWriter} (typical test path), this method * leaves it untouched. * * - Otherwise the registry is probed for the * {@see PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY} * binding. When present and of the expected type, the * activator self-wires a {@see RepositoryAuditEventWriter} * against it (using a fresh {@see SystemClock}). When the * binding is missing or carries an unexpected value, the * activator stays silent on the audit channel — pure-unit * bootstrap paths that only seed a subset of the * persistence bindings keep working without modification. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ public function register( ServiceProviderRegistry $registry ): void { if ( $this->auditWriter !== null ) { return; } $key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return; } $events = $registry->get( $key ); if ( ! $events instanceof AuditEventRepository ) { return; } $this->auditWriter = new RepositoryAuditEventWriter( $events, new SystemClock() ); } /** * Subscribe to `affilun_activate` so the role grant fires once * per (re)activation. * * The hook is wired through {@see HookRegistrar} (rather than * a direct `add_action()` call) so tests and the diagnostics * surface can introspect the registration the same way they * inspect every other AFFILUN hook. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { $hooks->addAction( 'affilun_activate', function (): void { $this->onActivate(); } ); $hooks->addAction( 'admin_init', function (): void { $this->onAdminInit(); }, 1, 0 ); } /** * Activation entry point: idempotently add every * {@see self::GRANTED_CAPABILITIES} entry to the * `administrator` role. * * Behavior: * * - When `get_role()` is not available (typical pure-unit * test path), the method silently returns without * touching anything. The Phase 9 OR-list fallback keeps * administrators authorized in that degraded state. * * - When `get_role( 'administrator' )` returns null (the * host site stripped the canonical role for some reason), * the method also returns silently — there is no role to * mutate. * * - Otherwise every capability is added through * `WP_Role::add_cap()`, which is idempotent: a capability * the role already carries is left untouched. * * Exposed as a public method so the plugin entry file's * direct activation hook (or a CLI helper) can drive the * grant outside the normal `affilun_activate` dispatch path, * matching the {@see SyncCronServiceProvider::onActivate()} * convention. */ public function onActivate(): void { if ( ! $this->grantCapabilitiesToAdministratorRole() ) { return; } $this->markCapabilitiesGranted(); $this->emitAudit(); } /** * Backfill capabilities on normal admin loads after manual plugin updates. */ public function onAdminInit(): void { if ( $this->capabilitiesGrantIsCurrent() ) { return; } if ( ! $this->grantCapabilitiesToAdministratorRole() ) { return; } $this->markCapabilitiesGranted(); } /** * Idempotently grant the current AFFILUN capability set to administrators. */ private function grantCapabilitiesToAdministratorRole(): bool { if ( ! function_exists( 'get_role' ) ) { // WordPress role API not loaded (pure-unit test path). return false; } $role = get_role( self::ADMINISTRATOR_ROLE ); if ( $role === null ) { // Host site stripped the canonical role; nothing to grant. return false; } if ( ! method_exists( $role, 'add_cap' ) ) { // Custom role implementation without the canonical // add_cap() surface; defensive guard so an unusual // host site cannot crash activation. return false; } foreach ( self::GRANTED_CAPABILITIES as $capability ) { // add_cap() is idempotent: capabilities the role // already carries are left untouched. $role->add_cap( $capability ); } return true; } /** * Report whether the persisted capability marker matches this build. */ private function capabilitiesGrantIsCurrent(): bool { if ( ! function_exists( 'get_option' ) ) { return false; } return get_option( self::CAPABILITY_GRANT_OPTION, '' ) === self::capabilityGrantMarker(); } /** * Persist the capability marker when WordPress options are available. */ private function markCapabilitiesGranted(): void { if ( ! function_exists( 'update_option' ) ) { return; } update_option( self::CAPABILITY_GRANT_OPTION, self::capabilityGrantMarker() ); } /** * Return a marker that changes when the granted capability set changes. */ private static function capabilityGrantMarker(): string { return 'admin-caps:' . implode( ',', self::GRANTED_CAPABILITIES ); } /** * Emit a `security_event` audit row describing the successful * capability grant. * * No-op when no audit writer was injected at construction * time. Failure to persist the row is swallowed per the * {@see AuditEventWriter} "tolerate audit emit failure" * contract — losing one activation audit row is preferable * to derailing the capability grant the operator just * triggered. * * The emitted draft uses {@see AuditEventDraft::forSystemAction()} * because activation runs without a human actor in scope (the * `affilun_activate` hook fires from `register_activation_hook()` * machinery, not from a logged-in admin's request). The * granted capability list is carried in the changed-fields * payload so the §12.4 envelope projection surfaces exactly * which capability literals were granted to the * `administrator` role. */ private function emitAudit(): void { if ( $this->auditWriter === null ) { return; } try { $draft = AuditEventDraft::forSystemAction( 'security.capabilities_granted', 'security_event', 'grant', AuditEventDraft::RESULT_SUCCESS, 'Granted AFFILUN admin capabilities to the administrator role.', 'role', self::ADMINISTRATOR_ROLE, array( 'granted_capabilities' => self::GRANTED_CAPABILITIES, ), array( 'capability_count' => count( self::GRANTED_CAPABILITIES ), ) ); $this->auditWriter->record( $draft ); } catch ( RuntimeException $unused ) { // Per the AuditEventWriter contract, the call site // log-and-continues on persistence failure; the // capability grant has already been applied above so // the operator-visible business outcome is preserved. unset( $unused ); } } }