> */ function aafm_allowlist_overrides(): array { $views = aafm_read_option_views( 'aafm_ability_allowlist_overrides' ); if ( ! $views['db_found'] ) { return array(); } $rows = $views['db_value']; return is_array( $rows ) ? $rows : array(); } /** * The override rows for the admin display, alongside whether the read that produced them can be * trusted - unlike aafm_allowlist_overrides() above, which collapses "genuinely no rows" and "the * query itself failed" to the same empty array. * * That collapse is wrong here, for a display the operator can * then edit and Save from - it is also wrong for authorization, which is exactly why * aafm_ability_allowed_for_principal() below does not use aafm_allowlist_overrides() either, and * instead reads the database view directly so a failed read denies rather than grants * unrestricted access (see that function's own docblock). A failed read rendered here as "No * scopes narrowed yet", with Add and Save still available, is not cosmetic: if the database * recovers before the operator clicks Save, the empty editor submits a full replacement and * silently erases every existing restriction. The caller here must be told the read failed, not * handed an empty state that looks identical to a genuinely unrestricted site. * * @return array{ok: bool, rows: array>, malformed: bool} */ function aafm_allowlist_overrides_for_display(): array { $views = aafm_read_option_views( 'aafm_ability_allowlist_overrides' ); // A found row that is not a list denies every call (aafm_ability_allowed_for_principal()), so it // is never shown as an unrestricted site. It is flagged apart from a failed read: a reload can // clear a failed read, and only a save replaces a malformed row. $malformed = ! $views['db_error'] && $views['db_found'] && ! is_array( $views['db_value'] ); if ( $views['db_error'] || $malformed ) { return array( 'ok' => false, 'rows' => array(), 'malformed' => $malformed, ); } return array( 'ok' => true, 'rows' => $views['db_found'] ? $views['db_value'] : array(), 'malformed' => false, ); } /** * The effective allowed set for one scope match: an array of ability names, or the literal * string 'all' meaning "no restriction from this row." * * Returns null for a malformed allowed_abilities value, so the caller can skip the row entirely * (fail-closed: a bad row is treated as absent, never as all-permissive). * * Per 228-allowlist-design.md section 6's own fail-closed statement: * "an override row that references an ability slug no longer in the registry ... is never * interpreted as allow everything. The exact fallback: skip the row entirely." A row whose * allowed_abilities lists ONLY a stale or mistyped name (the admin save no longer accepts one at * write time - see aafm_allowlist_sanitize_row() - but an ability can still be renamed/removed by * a later plugin upgrade after the row was saved) matched no real ability name, so * aafm_allowlist_set_permits() denied every call for that scope instead of degrading to * unrestricted. Checked against aafm_get_abilities_registry_full() (every registered ability, * including an inactive integration's) rather than the live registry, so a row saved while an * integration was active does not spuriously look stale the moment that host is deactivated. * * @param mixed $allowed_abilities Raw value from a stored row. * @return array|string|null */ function aafm_allowlist_normalize_allowed( $allowed_abilities ) { if ( 'all' === $allowed_abilities ) { return 'all'; } if ( is_array( $allowed_abilities ) ) { $names = array_values( array_map( 'strval', $allowed_abilities ) ); $registry = aafm_get_abilities_registry_full(); foreach ( $names as $name ) { if ( ! array_key_exists( $name, $registry ) ) { return null; // Row references an unknown ability: skip the whole row, per design. } } return $names; } return null; } /** * Whether ability $ability_name is allowed under one already-normalized set. * * @param array|string $set A normalized allowed-abilities value ('all' or a name list). * @param string $ability_name Ability name, e.g. 'aafm/update-post'. * @return bool */ function aafm_allowlist_set_permits( $set, string $ability_name ): bool { if ( ! is_array( $set ) ) { return 'all' === $set; } return in_array( $ability_name, $set, true ); } /** * Whether ability $ability_name is allowed for principal ($user_id, $oauth_client_id) by this * layer ALONE - callers must still clear the global enabled-abilities list and every other * existing floor themselves; this function only evaluates the new override option. * * This is a LIVE AUTHORIZATION read, not a migration * certification read, so it must fail in the OPPOSITE direction from * aafm_oauth_dcr_adopt_on_by_default() (includes/oauth/discovery.php). aafm_allowlist_overrides() * collapses "genuinely no rows" and "the query itself failed" to the same empty array, and an * empty array here means "unrestricted" - so a transient read failure would silently grant every * call through this layer instead of enforcing whatever restriction is actually stored. Reading * the database view directly here, rather than through that lenient helper, lets this deny the * call when the restriction state cannot be determined. * * A failed read has a security consequence for the admin display too * (above) - it is not exempt from this problem, it has its own dedicated failure-aware reader, * aafm_allowlist_overrides_for_display(). Neither production caller of this option still goes * through the lenient aafm_allowlist_overrides() helper; it survives only as the plain raw read * described on its own docblock. * * @param string $ability_name Ability name, e.g. 'aafm/update-post'. * @param int $user_id The calling user id (0 for none). * @param string|null $oauth_client_id The current request's OAuth client id, or null when the * request is not OAuth-authenticated. * @return bool */ function aafm_ability_allowed_for_principal( string $ability_name, int $user_id, ?string $oauth_client_id ): bool { // The connection's access preset can only narrow. It rides on the grant the bearer resolved, so it // never applies to an Application Password or cookie request, and a stale grant for another client // is ignored. $grant = aafm_oauth_current_grant(); if ( null !== $oauth_client_id && $grant['client_id'] === $oauth_client_id && '' !== $grant['preset'] && ! aafm_preset_permits( (string) $grant['preset'], $ability_name ) ) { return false; } $row = aafm_policy_row( 'aafm_ability_allowlist_overrides' ); if ( ! $row['ok'] || ( $row['found'] && ! is_array( $row['value'] ) ) ) { return false; // Cannot certify the restriction state, or it is not a list of rows: deny rather than fail open. } $rows = $row['found'] ? $row['value'] : array(); if ( array() === $rows ) { return true; // No override rows at all: identical to today's behavior. } // The current user's roles come from the object the capability check already used, so both // decisions rest on one load. Any other user is loaded inside the checked-read scope, and a load // that fails denies rather than reading as a user with no roles. $roles = array(); if ( $user_id > 0 && get_current_user_id() === $user_id ) { $roles = (array) wp_get_current_user()->roles; } elseif ( $user_id > 0 ) { $loaded = aafm_with_checked_reads( static fn(): array => array( 'user' => aafm_exact_object( 'user', $user_id ) ), aafm_generic_error() ); $user = is_wp_error( $loaded ) ? null : $loaded['user']; if ( ! $user instanceof WP_User ) { return false; } $roles = (array) $user->roles; } $role_matched = false; $role_union = array(); $role_all = false; $client_matched = false; $client_set = 'all'; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $scope_type = isset( $row['scope_type'] ) ? (string) $row['scope_type'] : ''; $scope_id = isset( $row['scope_id'] ) ? (string) $row['scope_id'] : ''; $normalized = aafm_allowlist_normalize_allowed( $row['allowed_abilities'] ?? null ); if ( null === $normalized || '' === $scope_id ) { continue; // Malformed row: skip, never treat as permissive. } if ( 'role' === $scope_type && in_array( $scope_id, $roles, true ) ) { $role_matched = true; if ( is_array( $normalized ) ) { $role_union = array_merge( $role_union, $normalized ); } else { $role_all = true; } } elseif ( 'oauth_client' === $scope_type && null !== $oauth_client_id && $scope_id === $oauth_client_id ) { // Multiple rows for the same client id are not a supported shape; the first // (evaluation-order) match wins rather than silently widening via a second union. if ( ! $client_matched ) { $client_matched = true; $client_set = $normalized; } } } if ( $role_matched && ! $role_all && ! aafm_allowlist_set_permits( $role_union, $ability_name ) ) { return false; } if ( $client_matched && ! aafm_allowlist_set_permits( $client_set, $ability_name ) ) { return false; } return true; }