diff --git a/composer.json b/composer.json index 43fb40a..9cbda28 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "php tests/authorization-smoke.php", "php tests/autonomous-capability-ceiling-smoke.php", "php tests/agents-access-ability-smoke.php", + "php tests/agents-access-write-ability-smoke.php", "php tests/agents-conversation-session-abilities-smoke.php", "php tests/conversation-session-authz-smoke.php", "php tests/conversation-session-store-contract-smoke.php", diff --git a/docs/auth-consent-context-memory.md b/docs/auth-consent-context-memory.md index 652e045..feb0165 100644 --- a/docs/auth-consent-context-memory.md +++ b/docs/auth-consent-context-memory.md @@ -40,6 +40,20 @@ raw bearer token `WP_Agent_Access_Grant` models a role-based grant between a WordPress user and an agent. Roles are ordered from lowest to highest privilege: `viewer`, `operator`, `admin`. `role_meets()` compares a grant with a required role. Concrete stores implement `WP_Agent_Access_Store`. +### Agent access abilities + +The access model is exposed as Abilities API abilities (category `agents-api`, `show_in_rest: true`). Authorization uses the agent-role model, not WordPress capabilities: every ability requires the current request principal to hold a grant on the target agent at or above a minimum role, filtered through the `agents_access_permission` hook. + +| Ability | Min role | Input | Output | Annotations | +| --- | --- | --- | --- | --- | +| `agents/can-access-agent` | any (requested) | `{ agent, minimum_role?, workspace_id?, client_id? }` | `{ allowed, agent, minimum_role }` | idempotent | +| `agents/list-accessible-agents` | any (requested) | `{ minimum_role?, workspace_id?, client_id? }` | `{ agents: [...] }` | idempotent | +| `agents/grant-agent-access` | `admin` | `{ agent, user_id, role? (default viewer), workspace_id?, metadata? }` | `{ granted, grant }` | idempotent (stores upsert) | +| `agents/revoke-agent-access` | `admin` | `{ agent, user_id, workspace_id? }` | `{ revoked }` | destructive | +| `agents/list-agent-users` | `operator` | `{ agent, workspace_id? }` | `{ users: [grant] }` | idempotent, readonly | + +Write abilities return `WP_Error` for an unknown agent, an invalid role or user id, a missing access store, and — for revocation — `agents_access_last_admin` when the revoke would remove the agent's last remaining `admin` grant. A grant export shape is `{ grant_id, agent_id, user_id, role, workspace_id, granted_by_user_id, granted_at, metadata, audience_id }`. + `WP_Agent_Capability_Ceiling` intersects token/client restrictions with a user's WordPress capabilities. `WP_Agent_WordPress_Authorization_Policy` denies unless the ceiling allows the requested capability and `user_can()` allows it for the acting/owner user. ## Caller context headers diff --git a/src/Auth/register-agent-access-abilities.php b/src/Auth/register-agent-access-abilities.php index d7914cd..bcca155 100644 --- a/src/Auth/register-agent-access-abilities.php +++ b/src/Auth/register-agent-access-abilities.php @@ -11,6 +11,9 @@ const AGENTS_CAN_ACCESS_AGENT_ABILITY = 'agents/can-access-agent'; const AGENTS_LIST_ACCESSIBLE_AGENTS_ABILITY = 'agents/list-accessible-agents'; +const AGENTS_GRANT_AGENT_ACCESS_ABILITY = 'agents/grant-agent-access'; +const AGENTS_REVOKE_AGENT_ACCESS_ABILITY = 'agents/revoke-agent-access'; +const AGENTS_LIST_AGENT_USERS_ABILITY = 'agents/list-agent-users'; add_action( 'wp_abilities_api_categories_init', @@ -69,6 +72,66 @@ static function (): void { ) ); } + + if ( ! wp_has_ability( AGENTS_GRANT_AGENT_ACCESS_ABILITY ) ) { + wp_register_ability( + AGENTS_GRANT_AGENT_ACCESS_ABILITY, + array( + 'label' => 'Grant Agent Access', + 'description' => 'Grant a WordPress user access to a registered agent at a role (viewer, operator, or admin). Requires the current principal to hold an admin role grant on the target agent.', + 'category' => 'agents-api', + 'input_schema' => agents_grant_agent_access_input_schema(), + 'output_schema' => agents_grant_agent_access_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_grant_agent_access', + 'permission_callback' => __NAMESPACE__ . '\\agents_access_admin_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( 'idempotent' => true ), + ), + ) + ); + } + + if ( ! wp_has_ability( AGENTS_REVOKE_AGENT_ACCESS_ABILITY ) ) { + wp_register_ability( + AGENTS_REVOKE_AGENT_ACCESS_ABILITY, + array( + 'label' => 'Revoke Agent Access', + 'description' => "Revoke a WordPress user's access grant for a registered agent. Requires the current principal to hold an admin role grant on the target agent. Refuses to revoke the last remaining admin grant on the agent so it never loses all of its administrators.", + 'category' => 'agents-api', + 'input_schema' => agents_revoke_agent_access_input_schema(), + 'output_schema' => agents_revoke_agent_access_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_revoke_agent_access', + 'permission_callback' => __NAMESPACE__ . '\\agents_access_admin_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( 'destructive' => true ), + ), + ) + ); + } + + if ( ! wp_has_ability( AGENTS_LIST_AGENT_USERS_ABILITY ) ) { + wp_register_ability( + AGENTS_LIST_AGENT_USERS_ABILITY, + array( + 'label' => 'List Agent Users', + 'description' => 'List access grants (users, roles, and workspace scopes) for a registered agent. Requires the current principal to hold at least an operator role grant on the target agent.', + 'category' => 'agents-api', + 'input_schema' => agents_list_agent_users_input_schema(), + 'output_schema' => agents_list_agent_users_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_list_agent_users', + 'permission_callback' => __NAMESPACE__ . '\\agents_access_operator_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'idempotent' => true, + 'readonly' => true, + ), + ), + ) + ); + } } ); @@ -105,6 +168,137 @@ function agents_list_accessible_agents( array $input ): array { return array( 'agents' => $agents ); } +/** + * Grant a user access to a registered agent. + * + * Requires the current principal to hold an admin role grant on the target + * agent (enforced by the permission callback). The store contract upserts, so + * granting an existing grant updates its role. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_grant_agent_access( array $input ) { + $agent = agents_access_agent_input( $input ); + if ( is_wp_error( $agent ) ) { + return $agent; + } + + $user_id = agents_access_positive_int_input( $input, 'user_id' ); + if ( null === $user_id ) { + return new \WP_Error( 'agents_access_invalid_user', 'user_id must be a positive integer.' ); + } + + $role = agents_access_string_input( $input, 'role', \WP_Agent_Access_Grant::ROLE_VIEWER ); + if ( ! \WP_Agent_Access_Grant::is_valid_role( $role ) ) { + return new \WP_Error( 'agents_access_invalid_role', 'role must be admin, operator, or viewer.' ); + } + + $scope = agents_access_request_scope( $input ); + $store = agents_access_store_for_scope( $scope ); + if ( is_wp_error( $store ) ) { + return $store; + } + + $principal = \WP_Agent_Access::get_current_principal( $scope ); + $granted_by = null !== $principal && $principal->acting_user_id > 0 ? $principal->acting_user_id : null; + + $grant = \WP_Agent_Access_Grant::from_array( + array( + 'agent_id' => $agent, + 'user_id' => $user_id, + 'role' => $role, + 'workspace_id' => agents_access_nullable_string_input( $input, 'workspace_id' ), + 'granted_by_user_id' => $granted_by, + 'metadata' => agents_access_metadata_input( $input ), + ) + ); + + $saved = $store->grant_access( $grant ); + + return array( + 'granted' => true, + 'grant' => $saved->to_array(), + ); +} + +/** + * Revoke a user's access grant for a registered agent. + * + * Refuses to revoke the last remaining admin grant on the agent so an agent + * never loses all of its administrators. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_revoke_agent_access( array $input ) { + $agent = agents_access_agent_input( $input ); + if ( is_wp_error( $agent ) ) { + return $agent; + } + + $user_id = agents_access_positive_int_input( $input, 'user_id' ); + if ( null === $user_id ) { + return new \WP_Error( 'agents_access_invalid_user', 'user_id must be a positive integer.' ); + } + + $scope = agents_access_request_scope( $input ); + $store = agents_access_store_for_scope( $scope ); + if ( is_wp_error( $store ) ) { + return $store; + } + + $workspace_id = agents_access_nullable_string_input( $input, 'workspace_id' ); + $admin_count = 0; + $target_admin = false; + + foreach ( $store->get_users_for_agent( $agent, $workspace_id ) as $grant ) { + if ( \WP_Agent_Access_Grant::ROLE_ADMIN !== $grant->role ) { + continue; + } + + ++$admin_count; + + if ( $grant->user_id === $user_id ) { + $target_admin = true; + } + } + + if ( $target_admin && $admin_count <= 1 ) { + return new \WP_Error( 'agents_access_last_admin', 'Cannot revoke the last admin grant on an agent.' ); + } + + return array( + 'revoked' => (bool) $store->revoke_access( $agent, $user_id, $workspace_id ), + ); +} + +/** + * List access grants for a registered agent. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_list_agent_users( array $input ) { + $agent = agents_access_agent_input( $input ); + if ( is_wp_error( $agent ) ) { + return $agent; + } + + $store = agents_access_store_for_scope( agents_access_request_scope( $input ) ); + if ( is_wp_error( $store ) ) { + return $store; + } + + $users = array(); + + foreach ( $store->get_users_for_agent( $agent, agents_access_nullable_string_input( $input, 'workspace_id' ) ) as $grant ) { + $users[] = $grant->to_array(); + } + + return array( 'users' => $users ); +} + /** * Shared permission gate for access read abilities. * @@ -116,6 +310,102 @@ function agents_access_permission( array $input ): bool { return (bool) apply_filters( 'agents_access_permission', $allowed, $input ); } +/** + * Shared permission gate for access admin abilities (grant/revoke). + * + * Requires the current principal to hold an admin role grant on the target + * agent. Agent-role — not WordPress capabilities — is the substrate's + * authorization model for access management. The decision runs through the + * same {@see 'agents_access_permission'} filter as the read abilities so + * hosts can tighten or widen it. + * + * @param array $input Ability input. + */ +function agents_access_admin_permission( array $input ): bool { + return agents_access_role_permission( $input, \WP_Agent_Access_Grant::ROLE_ADMIN ); +} + +/** + * Shared permission gate for the agent user listing ability. + * + * Requires the current principal to hold at least an operator role grant on + * the target agent, filtered through {@see 'agents_access_permission'}. + * + * @param array $input Ability input. + */ +function agents_access_operator_permission( array $input ): bool { + return agents_access_role_permission( $input, \WP_Agent_Access_Grant::ROLE_OPERATOR ); +} + +/** + * Gate an access ability behind a minimum agent role. + * + * @param array $input Ability input. + * @param string $minimum_role Minimum agent role. + */ +function agents_access_role_permission( array $input, string $minimum_role ): bool { + $agent_id = sanitize_title( agents_access_string_input( $input, 'agent' ) ); + + $allowed = '' !== $agent_id + && \WP_Agent_Access::can_current_principal_access_agent( $agent_id, $minimum_role, agents_access_request_scope( $input ) ); + + return (bool) apply_filters( 'agents_access_permission', $allowed, $input ); +} + +/** + * Resolve and validate the agent identifier for access write abilities. + * + * @param array $input Ability input. + * @return string|\WP_Error + */ +function agents_access_agent_input( array $input ) { + $agent_id = sanitize_title( agents_access_string_input( $input, 'agent' ) ); + + if ( '' === $agent_id ) { + return new \WP_Error( 'agents_access_invalid_agent', 'agent must be a non-empty string.' ); + } + + if ( ! agents_access_agent_registered( $agent_id ) ) { + return new \WP_Error( 'agents_access_unknown_agent', sprintf( 'Agent "%s" is not registered.', $agent_id ) ); + } + + return $agent_id; +} + +/** + * Whether an agent slug is currently registered. + * + * Falls back to true when the registry is unavailable so the store remains + * the final authority. + * + * @param string $agent_id Agent slug/id. + */ +function agents_access_agent_registered( string $agent_id ): bool { + if ( ! class_exists( '\WP_Agents_Registry' ) ) { + return true; + } + + $registry = \WP_Agents_Registry::get_instance(); + + return ! $registry instanceof \WP_Agents_Registry || $registry->is_registered( $agent_id ); +} + +/** + * Resolve the host access store for a request scope. + * + * @param array $scope Request scope. + * @return \WP_Agent_Access_Store|\WP_Error + */ +function agents_access_store_for_scope( array $scope ) { + $store = \WP_Agent_Access::get_store( $scope ); + + if ( ! $store instanceof \WP_Agent_Access_Store ) { + return new \WP_Error( 'agents_access_store_missing', 'No agent access store is available for this request.' ); + } + + return $store; +} + /** * Extract request scope fields forwarded to access helpers. * @@ -162,6 +452,52 @@ function agents_access_nullable_string_input( array $input, string $key ): ?stri return agents_access_string_input( $input, $key ); } +/** + * Read a positive integer from ability input. + * + * @param array $input Ability input. + * @param string $key Input key. + */ +function agents_access_positive_int_input( array $input, string $key ): ?int { + $value = $input[ $key ] ?? null; + + if ( ! is_int( $value ) && ! is_string( $value ) ) { + return null; + } + + if ( is_string( $value ) && ! preg_match( '/^\d+$/', $value ) ) { + return null; + } + + $int_value = (int) $value; + + return $int_value > 0 ? $int_value : null; +} + +/** + * Read a metadata map from ability input. + * + * @param array $input Ability input. + * @return array + */ +function agents_access_metadata_input( array $input ): array { + $value = $input['metadata'] ?? null; + + if ( ! is_array( $value ) ) { + return array(); + } + + $metadata = array(); + + foreach ( $value as $metadata_key => $metadata_value ) { + if ( is_string( $metadata_key ) ) { + $metadata[ $metadata_key ] = $metadata_value; + } + } + + return $metadata; +} + /** * Input schema for `agents/can-access-agent`. * @return array @@ -251,3 +587,139 @@ function agents_access_role_schema(): array { 'description' => 'Minimum access role required for the check.', ); } + +/** + * Input schema for `agents/grant-agent-access`. + * @return array + */ +function agents_grant_agent_access_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'agent', 'user_id' ), + 'properties' => array( + 'agent' => array( + 'type' => 'string', + 'description' => 'Registered agent slug/id to grant access to.', + ), + 'user_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'WordPress user ID receiving access.', + ), + 'role' => agents_access_role_schema(), + 'workspace_id' => array( 'type' => array( 'string', 'null' ) ), + 'metadata' => array( + 'type' => 'object', + 'description' => 'Optional host-owned metadata stored with the grant.', + ), + ), + ); +} + +/** + * Output schema for `agents/grant-agent-access`. + * @return array + */ +function agents_grant_agent_access_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'granted', 'grant' ), + 'properties' => array( + 'granted' => array( 'type' => 'boolean' ), + 'grant' => agents_access_grant_schema(), + ), + ); +} + +/** + * Input schema for `agents/revoke-agent-access`. + * @return array + */ +function agents_revoke_agent_access_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'agent', 'user_id' ), + 'properties' => array( + 'agent' => array( + 'type' => 'string', + 'description' => 'Registered agent slug/id to revoke access from.', + ), + 'user_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'WordPress user ID whose access is revoked.', + ), + 'workspace_id' => array( 'type' => array( 'string', 'null' ) ), + ), + ); +} + +/** + * Output schema for `agents/revoke-agent-access`. + * @return array + */ +function agents_revoke_agent_access_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'revoked' ), + 'properties' => array( + 'revoked' => array( 'type' => 'boolean' ), + ), + ); +} + +/** + * Input schema for `agents/list-agent-users`. + * @return array + */ +function agents_list_agent_users_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'agent' ), + 'properties' => array( + 'agent' => array( + 'type' => 'string', + 'description' => 'Registered agent slug/id to list access grants for.', + ), + 'workspace_id' => array( 'type' => array( 'string', 'null' ) ), + ), + ); +} + +/** + * Output schema for `agents/list-agent-users`. + * @return array + */ +function agents_list_agent_users_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'users' ), + 'properties' => array( + 'users' => array( + 'type' => 'array', + 'items' => agents_access_grant_schema(), + ), + ), + ); +} + +/** + * JSON schema fragment for an exported access grant. + * @return array + */ +function agents_access_grant_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'grant_id' => array( 'type' => array( 'integer', 'null' ) ), + 'agent_id' => array( 'type' => 'string' ), + 'user_id' => array( 'type' => 'integer' ), + 'role' => agents_access_role_schema(), + 'workspace_id' => array( 'type' => array( 'string', 'null' ) ), + 'granted_by_user_id' => array( 'type' => array( 'integer', 'null' ) ), + 'granted_at' => array( 'type' => array( 'string', 'null' ) ), + 'metadata' => array( 'type' => 'object' ), + 'audience_id' => array( 'type' => array( 'string', 'null' ) ), + ), + ); +} diff --git a/tests/agents-access-write-ability-smoke.php b/tests/agents-access-write-ability-smoke.php new file mode 100644 index 0000000..244feeb --- /dev/null +++ b/tests/agents-access-write-ability-smoke.php @@ -0,0 +1,397 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +$GLOBALS['__agents_api_smoke_current_user_id'] = 1; +$GLOBALS['__agents_api_smoke_abilities'] = array(); +$GLOBALS['__agents_api_smoke_categories'] = array(); +$GLOBALS['__agents_access_write_smoke_store'] = null; +$GLOBALS['__agents_access_write_smoke_store_hidden'] = false; + +/** + * Switch the acting user for both backends. The pure-PHP shim reads the smoke + * global directly; under real WordPress the execution-principal filter below + * synthesizes the user-session principal from this id. + */ +function agents_access_write_smoke_set_user( int $user_id ): void { + $GLOBALS['__agents_api_smoke_current_user_id'] = $user_id; +} + +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id(): int { + return (int) $GLOBALS['__agents_api_smoke_current_user_id']; + } +} + +if ( ! function_exists( 'is_user_logged_in' ) ) { + function is_user_logged_in(): bool { + return get_current_user_id() > 0; + } +} + +if ( ! function_exists( 'wp_has_ability_category' ) ) { + function wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); + } +} + +if ( ! function_exists( 'wp_register_ability_category' ) ) { + function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__agents_api_smoke_categories'][ $category ] = $args; + } +} + +if ( ! function_exists( 'wp_has_ability' ) ) { + function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__agents_api_smoke_abilities'][ $ability ] ); + } +} + +if ( ! function_exists( 'wp_register_ability' ) ) { + function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__agents_api_smoke_abilities'][ $ability ] = $args; + } +} + +agents_access_write_smoke_set_user( 1 ); + +agents_api_smoke_require_module(); + +// Under real WordPress, synthesize the user-session principal from the smoke +// user id through the resolution filter; the pure-PHP shim already follows +// the smoke global through get_current_user_id(). +if ( function_exists( 'wp_set_current_user' ) ) { + add_filter( + 'agents_api_execution_principal', + static function ( $principal, array $context ) { + $smoke_user = (int) ( $GLOBALS['__agents_api_smoke_current_user_id'] ?? 0 ); + if ( $smoke_user <= 0 ) { + return $principal; + } + + return AgentsAPI\AI\WP_Agent_Execution_Principal::user_session( + $smoke_user, + 'current-user', + $context['request_context'] ?? AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array( 'source' => 'smoke-test' ), + $context['workspace_id'] ?? null, + $context['client_id'] ?? null + ); + }, + 5, + 2 + ); +} + +add_action( + 'wp_agents_api_init', + static function (): void { + wp_register_agent( + 'editor-agent', + array( + 'label' => 'Editor Agent', + 'description' => 'Edits posts.', + ) + ); + + wp_register_agent( + 'solo-agent', + array( + 'label' => 'Solo Agent', + 'description' => 'Has a single admin.', + ) + ); + } +); + +do_action( 'init' ); + +$GLOBALS['__agents_access_write_smoke_store'] = new class implements WP_Agent_Access_Store { + /** @var array */ + private array $grants = array(); + + /** + * @param string $agent_id Agent slug. + * @param int $user_id User ID. + * @param string|null $workspace_id Workspace scope. + */ + private function key( string $agent_id, int $user_id, ?string $workspace_id ): string { + return $agent_id . '|' . $user_id . '|' . ( $workspace_id ?? '' ); + } + + public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant { + $this->grants[ $this->key( $grant->agent_id, $grant->user_id, $grant->workspace_id ) ] = $grant; + return $grant; + } + + public function revoke_access( string $agent_id, int $user_id, ?string $workspace_id = null ): bool { + $key = $this->key( $agent_id, $user_id, $workspace_id ); + if ( ! isset( $this->grants[ $key ] ) ) { + return false; + } + + unset( $this->grants[ $key ] ); + return true; + } + + public function get_access( string $agent_id, int $user_id, ?string $workspace_id = null ): ?WP_Agent_Access_Grant { + return $this->grants[ $this->key( $agent_id, $user_id, $workspace_id ) ] ?? null; + } + + public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = null, ?string $workspace_id = null ): array { + $agent_ids = array(); + foreach ( $this->grants as $grant ) { + if ( $grant->user_id !== $user_id ) { + continue; + } + + if ( null !== $minimum_role && ! $grant->role_meets( $minimum_role ) ) { + continue; + } + + $agent_ids[] = $grant->agent_id; + } + + return $agent_ids; + } + + public function get_users_for_agent( string $agent_id, ?string $workspace_id = null ): array { + $grants = array(); + foreach ( $this->grants as $grant ) { + if ( $grant->agent_id === $agent_id && $grant->workspace_id === $workspace_id ) { + $grants[] = $grant; + } + } + + return $grants; + } +}; + +$access_store = $GLOBALS['__agents_access_write_smoke_store']; + +add_filter( + 'wp_agent_access_store', + static function ( $store ) { + if ( ! empty( $GLOBALS['__agents_access_write_smoke_store_hidden'] ) ) { + return null; + } + + return $store instanceof WP_Agent_Access_Store ? $store : $GLOBALS['__agents_access_write_smoke_store']; + } +); + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +agents_api_smoke_assert_equals( true, wp_has_ability( AgentsAPI\AI\Auth\AGENTS_GRANT_AGENT_ACCESS_ABILITY ), 'grant ability registers with Abilities API', $failures, $passes ); +agents_api_smoke_assert_equals( true, wp_has_ability( AgentsAPI\AI\Auth\AGENTS_REVOKE_AGENT_ACCESS_ABILITY ), 'revoke ability registers with Abilities API', $failures, $passes ); +agents_api_smoke_assert_equals( true, wp_has_ability( AgentsAPI\AI\Auth\AGENTS_LIST_AGENT_USERS_ABILITY ), 'list-agent-users ability registers with Abilities API', $failures, $passes ); + +// Seed: user 1 is admin on both agents, user 2 is operator on editor-agent. +$access_store->grant_access( new WP_Agent_Access_Grant( 'editor-agent', 1, WP_Agent_Access_Grant::ROLE_ADMIN ) ); +$access_store->grant_access( new WP_Agent_Access_Grant( 'solo-agent', 1, WP_Agent_Access_Grant::ROLE_ADMIN ) ); +$access_store->grant_access( new WP_Agent_Access_Grant( 'editor-agent', 2, WP_Agent_Access_Grant::ROLE_OPERATOR ) ); + +// Permission model: agent-role, not WordPress capabilities. +agents_access_write_smoke_set_user( 1 ); +agents_api_smoke_assert_equals( true, AgentsAPI\AI\Auth\agents_access_admin_permission( array( 'agent' => 'editor-agent' ) ), 'admin principal passes admin permission gate', $failures, $passes ); +agents_api_smoke_assert_equals( true, AgentsAPI\AI\Auth\agents_access_operator_permission( array( 'agent' => 'editor-agent' ) ), 'admin principal passes operator permission gate', $failures, $passes ); + +agents_access_write_smoke_set_user( 2 ); +agents_api_smoke_assert_equals( false, AgentsAPI\AI\Auth\agents_access_admin_permission( array( 'agent' => 'editor-agent' ) ), 'non-admin principal denied on grant permission gate', $failures, $passes ); +agents_api_smoke_assert_equals( true, AgentsAPI\AI\Auth\agents_access_operator_permission( array( 'agent' => 'editor-agent' ) ), 'operator principal passes operator permission gate for list-agent-users', $failures, $passes ); + +agents_access_write_smoke_set_user( 3 ); +agents_api_smoke_assert_equals( false, AgentsAPI\AI\Auth\agents_access_admin_permission( array( 'agent' => 'editor-agent' ) ), 'principal without any grant denied on both write gates', $failures, $passes ); + +// Grant as admin (user 1). +agents_access_write_smoke_set_user( 1 ); +$grant_result = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 9, + 'role' => WP_Agent_Access_Grant::ROLE_OPERATOR, + 'metadata' => array( 'source' => 'smoke-test' ), + ) +); +agents_api_smoke_assert_equals( true, is_array( $grant_result ) && true === ( $grant_result['granted'] ?? null ), 'grant ability returns granted true', $failures, $passes ); +agents_api_smoke_assert_equals( WP_Agent_Access_Grant::ROLE_OPERATOR, is_array( $grant_result ) ? ( $grant_result['grant']['role'] ?? null ) : null, 'granted grant carries requested role', $failures, $passes ); +agents_api_smoke_assert_equals( 1, is_array( $grant_result ) ? ( $grant_result['grant']['granted_by_user_id'] ?? null ) : null, 'granted grant records granting principal user id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'source' => 'smoke-test' ), is_array( $grant_result ) ? ( $grant_result['grant']['metadata'] ?? null ) : null, 'granted grant carries metadata', $failures, $passes ); + +// Granting without a role defaults to viewer. +$default_role_result = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 11, + ) +); +agents_api_smoke_assert_equals( WP_Agent_Access_Grant::ROLE_VIEWER, is_array( $default_role_result ) ? ( $default_role_result['grant']['role'] ?? null ) : null, 'grant ability defaults to viewer role', $failures, $passes ); + +// List shows the new grant. +$list_result = AgentsAPI\AI\Auth\agents_list_agent_users( array( 'agent' => 'editor-agent' ) ); +$list_users = is_array( $list_result ) ? ( $list_result['users'] ?? array() ) : array(); +agents_api_smoke_assert_equals( 4, count( $list_users ), 'list-agent-users returns all grants for the agent', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 9, array_column( $list_users, 'user_id' ), true ), 'list-agent-users shows the granted user', $failures, $passes ); + +// Re-granting the same user upserts instead of duplicating. +AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 9, + 'role' => WP_Agent_Access_Grant::ROLE_ADMIN, + ) +); +$list_after_upsert = AgentsAPI\AI\Auth\agents_list_agent_users( array( 'agent' => 'editor-agent' ) ); +$upsert_users = is_array( $list_after_upsert ) ? ( $list_after_upsert['users'] ?? array() ) : array(); +agents_api_smoke_assert_equals( 4, count( $upsert_users ), 're-granting an existing user upserts instead of duplicating', $failures, $passes ); + +$upsert_roles = array_column( $upsert_users, 'role', 'user_id' ); +agents_api_smoke_assert_equals( WP_Agent_Access_Grant::ROLE_ADMIN, $upsert_roles[9] ?? null, 're-grant updates the stored role', $failures, $passes ); + +// Revoke the updated grant and confirm the list empties for that user. +$revoke_result = AgentsAPI\AI\Auth\agents_revoke_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 9, + ) +); +agents_api_smoke_assert_equals( array( 'revoked' => true ), $revoke_result, 'revoke ability returns revoked true', $failures, $passes ); + +$list_after_revoke = AgentsAPI\AI\Auth\agents_list_agent_users( array( 'agent' => 'editor-agent' ) ); +$remaining_users = is_array( $list_after_revoke ) ? ( $list_after_revoke['users'] ?? array() ) : array(); +agents_api_smoke_assert_equals( false, in_array( 9, array_column( $remaining_users, 'user_id' ), true ), 'list-agent-users no longer shows the revoked user', $failures, $passes ); + +// Revoking a grant that does not exist reports revoked false. +$revoke_missing = AgentsAPI\AI\Auth\agents_revoke_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 9, + ) +); +agents_api_smoke_assert_equals( array( 'revoked' => false ), $revoke_missing, 'revoke of a missing grant returns revoked false', $failures, $passes ); + +// Last-admin protection: user 1 is the only admin on solo-agent. +$last_admin_revoke = AgentsAPI\AI\Auth\agents_revoke_agent_access( + array( + 'agent' => 'solo-agent', + 'user_id' => 1, + ) +); +agents_api_smoke_assert_equals( true, is_wp_error( $last_admin_revoke ), 'revoking the last admin grant returns WP_Error', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_access_last_admin', is_wp_error( $last_admin_revoke ) ? $last_admin_revoke->get_error_code() : '', 'last-admin revoke uses agents_access_last_admin code', $failures, $passes ); + +// Once a second admin exists, the first can be revoked. +AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'solo-agent', + 'user_id' => 5, + 'role' => WP_Agent_Access_Grant::ROLE_ADMIN, + ) +); +$second_admin_revoke = AgentsAPI\AI\Auth\agents_revoke_agent_access( + array( + 'agent' => 'solo-agent', + 'user_id' => 1, + ) +); +agents_api_smoke_assert_equals( array( 'revoked' => true ), $second_admin_revoke, 'revoking one of several admins succeeds', $failures, $passes ); + +// Revoking a non-admin grant is never blocked by last-admin protection. +$operator_revoke = AgentsAPI\AI\Auth\agents_revoke_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 2, + ) +); +agents_api_smoke_assert_equals( array( 'revoked' => true ), $operator_revoke, 'revoking a non-admin grant is allowed', $failures, $passes ); + +// Workspace scoping. +$workspace_grant = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 21, + 'role' => WP_Agent_Access_Grant::ROLE_VIEWER, + 'workspace_id' => 'site:42', + ) +); +agents_api_smoke_assert_equals( 'site:42', is_array( $workspace_grant ) ? ( $workspace_grant['grant']['workspace_id'] ?? null ) : null, 'workspace grant carries workspace scope', $failures, $passes ); + +$unscoped_list = AgentsAPI\AI\Auth\agents_list_agent_users( array( 'agent' => 'editor-agent' ) ); +agents_api_smoke_assert_equals( false, in_array( 21, array_column( is_array( $unscoped_list ) ? ( $unscoped_list['users'] ?? array() ) : array(), 'user_id' ), true ), 'unscoped list omits workspace-scoped grant', $failures, $passes ); + +$scoped_list = AgentsAPI\AI\Auth\agents_list_agent_users( + array( + 'agent' => 'editor-agent', + 'workspace_id' => 'site:42', + ) +); +$scoped_users = is_array( $scoped_list ) ? ( $scoped_list['users'] ?? array() ) : array(); +agents_api_smoke_assert_equals( array( 21 ), array_column( $scoped_users, 'user_id' ), 'workspace-scoped list returns only that workspace grant', $failures, $passes ); + +// Error paths. +$invalid_role = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 9, + 'role' => 'superadmin', + ) +); +agents_api_smoke_assert_equals( true, is_wp_error( $invalid_role ), 'invalid role returns WP_Error', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_access_invalid_role', is_wp_error( $invalid_role ) ? $invalid_role->get_error_code() : '', 'invalid role uses agents_access_invalid_role code', $failures, $passes ); + +$invalid_user = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 0, + ) +); +agents_api_smoke_assert_equals( 'agents_access_invalid_user', is_wp_error( $invalid_user ) ? $invalid_user->get_error_code() : '', 'non-positive user_id returns agents_access_invalid_user', $failures, $passes ); + +$unknown_agent = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'missing-agent', + 'user_id' => 9, + ) +); +agents_api_smoke_assert_equals( 'agents_access_unknown_agent', is_wp_error( $unknown_agent ) ? $unknown_agent->get_error_code() : '', 'unknown agent returns agents_access_unknown_agent', $failures, $passes ); + +$GLOBALS['__agents_access_write_smoke_store_hidden'] = true; +$missing_store = AgentsAPI\AI\Auth\agents_grant_agent_access( + array( + 'agent' => 'editor-agent', + 'user_id' => 9, + ) +); +agents_api_smoke_assert_equals( 'agents_access_store_missing', is_wp_error( $missing_store ) ? $missing_store->get_error_code() : '', 'missing store returns agents_access_store_missing on grant', $failures, $passes ); + +$missing_store_list = AgentsAPI\AI\Auth\agents_list_agent_users( array( 'agent' => 'editor-agent' ) ); +agents_api_smoke_assert_equals( 'agents_access_store_missing', is_wp_error( $missing_store_list ) ? $missing_store_list->get_error_code() : '', 'missing store returns agents_access_store_missing on list', $failures, $passes ); +$GLOBALS['__agents_access_write_smoke_store_hidden'] = false; + +agents_api_smoke_finish( 'Agents API access write abilities', $failures, $passes );