diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php new file mode 100644 index 0000000..6c7e172 --- /dev/null +++ b/src/Admin/AccessTab.php @@ -0,0 +1,211 @@ +terminate = $terminate ?? static function (): void { + exit; + }; + } + + public function slug(): string + { + return 'zugriff'; + } + + public function label(): string + { + return __('Zugriff', 'wp-lit-mcp'); + } + + public function registerSettings(): void + { + register_setting(self::GROUP, Access::OPTION, [ + 'type' => 'array', + 'sanitize_callback' => [self::class, 'sanitizeRoles'], + 'default' => ['administrator'], + ]); + } + + public function registerActions(): void + { + add_action('admin_post_' . self::REVOKE_ACTION, [$this, 'handleRevoke']); + } + + /** + * Keeps only existing role slugs. Runs twice on the very first save (update_option + * falls through to add_option, which sanitizes again), so each warning is added + * only once. The settings page itself needs manage_options, not lit_mcp_use, so + * removing the current user's own role is allowed – but warned about. + * + * @return list + */ + public static function sanitizeRoles(mixed $input): array + { + $existing = array_keys((array) wp_roles()->roles); + $roles = array_values(array_unique(array_intersect( + array_filter(is_array($input) ? $input : [], 'is_string'), + $existing + ))); + + if ($roles === []) { + self::warnOnce( + 'lit_mcp_no_roles', + __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp') + ); + } elseif (self::currentUserLosesAccess($roles)) { + self::warnOnce( + 'lit_mcp_own_role', + __( + 'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.', + 'wp-lit-mcp' + ) + ); + } + + return $roles; + } + + public function render(): void + { + $allowed = $this->access->allowedRoles(); + + echo '

' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '

'; + echo '

' . esc_html__( + 'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.', + 'wp-lit-mcp' + ) . '

'; + echo '
'; + settings_fields(self::GROUP); + // Makes an all-unchecked submission arrive as [''] instead of dropping the field. + printf('', esc_attr(Access::OPTION)); + foreach (wp_roles()->get_names() as $role => $name) { + printf( + '
', + esc_attr(Access::OPTION), + esc_attr((string) $role), + checked(in_array($role, $allowed, true), true, false), + esc_html(translate_user_role((string) $name)) + ); + } + submit_button(); + echo '
'; + + $this->renderConnections(); + } + + public function handleRevoke(): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]); + } + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below. + $uuid = isset($_POST['uuid']) ? sanitize_text_field(wp_unslash((string) $_POST['uuid'])) : ''; + $userId = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0; + // phpcs:enable WordPress.Security.NonceVerification.Missing + check_admin_referer(self::REVOKE_ACTION . '_' . $uuid); + + $revoked = $this->connections->revoke($userId, $uuid); + $notice = $revoked ? 'revoked' : 'revoke_failed'; + wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => $notice])); + ($this->terminate)(); + } + + private static function warnOnce(string $code, string $message): void + { + foreach ((array) get_settings_errors(Access::OPTION) as $error) { + if (is_array($error) && ($error['code'] ?? '') === $code) { + return; + } + } + + add_settings_error(Access::OPTION, $code, $message, 'warning'); + } + + /** @param list $roles */ + private static function currentUserLosesAccess(array $roles): bool + { + // Multisite super admins pass every capability check regardless of role. + if (is_multisite() && is_super_admin()) { + return false; + } + + $user = wp_get_current_user(); + + return array_intersect((array) $user->roles, $roles) === []; + } + + private function renderConnections(): void + { + echo '

' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '

'; + $connections = $this->connections->all(); + if ($connections === []) { + echo '

' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '

'; + return; + } + + echo ''; + $headings = [ + __('Benutzer', 'wp-lit-mcp'), + __('Client', 'wp-lit-mcp'), + __('Erstellt', 'wp-lit-mcp'), + __('Zuletzt genutzt', 'wp-lit-mcp'), + '', + ]; + foreach ($headings as $heading) { + echo ''; + } + echo ''; + + foreach ($connections as $connection) { + $this->renderConnectionRow($connection); + } + + echo '
' . esc_html($heading) . '
'; + } + + /** + * @param array{user_id: int, user_login: string, uuid: string, name: string, + * app_id: string, created: int, last_used: ?int} $connection + */ + private function renderConnectionRow(array $connection): void + { + echo ''; + echo '' . esc_html($connection['user_login']) . ''; + printf('%s
%s', esc_html($connection['name']), esc_html($connection['app_id'])); + echo '' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . ''; + $lastUsed = $connection['last_used'] === null + ? '–' + : (string) wp_date('Y-m-d H:i', $connection['last_used']); + echo '' . esc_html($lastUsed) . ''; + echo ''; + printf('
', esc_url(admin_url('admin-post.php'))); + printf('', esc_attr(self::REVOKE_ACTION)); + printf('', esc_attr((string) $connection['user_id'])); + printf('', esc_attr($connection['uuid'])); + wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']); + submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false); + echo '
'; + } +} diff --git a/src/Admin/ConnectionRepository.php b/src/Admin/ConnectionRepository.php new file mode 100644 index 0000000..1bb1dff --- /dev/null +++ b/src/Admin/ConnectionRepository.php @@ -0,0 +1,110 @@ +`. An application password counts as an LIT MCP connection + * only if BOTH hold: the marker meta exists and its app_id is an https URL. Any other + * application password of the user (other tools, manual ones) is neither listed nor + * revoked. + * + * Multisite: only users who are members of the current site are managed, and a + * non-super-admin never sees or revokes a super admin's connections. + * + * Not final so the access tab can be tested with a mock. + */ +class ConnectionRepository +{ + public function __construct(private ?\wpdb $db = null) + { + } + + /** + * @return list + */ + public function all(): array + { + $wpdb = $this->db ?? $GLOBALS['wpdb']; + $prefix = TokenEndpoint::REFRESH_JTI_META_PREFIX; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT user_id, meta_key FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id, umeta_id", + $wpdb->esc_like($prefix) . '%' + )); + + $connections = []; + foreach ((array) $rows as $row) { + $userId = (int) $row->user_id; + $uuid = substr((string) $row->meta_key, strlen($prefix)); + if ($uuid === '' || ! $this->manageable($userId)) { + continue; + } + + $item = \WP_Application_Passwords::get_user_application_password($userId, $uuid); + if (! self::isMcpOauthPassword($item)) { + continue; + } + + $user = get_userdata($userId); + $connections[] = [ + 'user_id' => $userId, + 'user_login' => is_object($user) ? (string) $user->user_login : '#' . $userId, + 'uuid' => $uuid, + 'name' => (string) ($item['name'] ?? ''), + 'app_id' => (string) $item['app_id'], + 'created' => (int) ($item['created'] ?? 0), + 'last_used' => isset($item['last_used']) ? (int) $item['last_used'] : null, + ]; + } + + return $connections; + } + + public function revoke(int $userId, string $uuid): bool + { + if ($userId <= 0 || $uuid === '' || ! $this->manageable($userId)) { + return false; + } + + $marker = (string) get_user_meta($userId, TokenEndpoint::REFRESH_JTI_META_PREFIX . $uuid, true); + if ($marker === '') { + return false; + } + + $item = \WP_Application_Passwords::get_user_application_password($userId, $uuid); + if (! self::isMcpOauthPassword($item)) { + return false; + } + + // Fires wp_delete_application_password, on which mcp-oauth purges its marker meta. + return \WP_Application_Passwords::delete_application_password($userId, $uuid) === true; + } + + /** Whether the current administrator may see and revoke this user's connections. */ + private function manageable(int $userId): bool + { + if (! is_multisite()) { + return true; + } + + if (is_super_admin($userId) && ! is_super_admin()) { + return false; + } + + return is_user_member_of_blog($userId, get_current_blog_id()); + } + + private static function isMcpOauthPassword(mixed $item): bool + { + return is_array($item) + && is_string($item['app_id'] ?? null) + && str_starts_with($item['app_id'], 'https://'); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 641486c..bcf72b1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -6,7 +6,9 @@ namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Abilities\ExposurePolicy; +use LIT\MCP\Admin\AccessTab; use LIT\MCP\Admin\AdminNotices; +use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; use LIT\MCP\Admin\SettingsPage; @@ -47,7 +49,8 @@ final class Plugin return; } - (new Access())->register(); + $access = new Access(); + $access->register(); (new RequestContext())->register(); (new VisibilityFilter())->register(); @@ -78,6 +81,7 @@ final class Plugin (new SiteHealth($status))->register(); self::$settingsPage = new SettingsPage([ new ConnectionTab($status), + new AccessTab($access, new ConnectionRepository()), ]); if (is_admin()) { self::$settingsPage->register(); diff --git a/tests/Stubs/WpApplicationPasswords.php b/tests/Stubs/WpApplicationPasswords.php new file mode 100644 index 0000000..3d7b844 --- /dev/null +++ b/tests/Stubs/WpApplicationPasswords.php @@ -0,0 +1,34 @@ +>> user ID => uuid => item */ + public static array $items = []; + + public static function get_user_application_password($user_id, $uuid) + { + return self::$items[(int) $user_id][(string) $uuid] ?? null; + } + + public static function get_user_application_passwords($user_id) + { + return array_values(self::$items[(int) $user_id] ?? []); + } + + public static function delete_application_password($user_id, $uuid) + { + if (! isset(self::$items[(int) $user_id][(string) $uuid])) { + return new WP_Error('application_password_not_found', 'Not found.'); + } + unset(self::$items[(int) $user_id][(string) $uuid]); + + return true; + } + } +} diff --git a/tests/Unit/Admin/AccessTabTest.php b/tests/Unit/Admin/AccessTabTest.php new file mode 100644 index 0000000..131d931 --- /dev/null +++ b/tests/Unit/Admin/AccessTabTest.php @@ -0,0 +1,217 @@ +roles = ['administrator' => [], 'editor' => []]; + $roles->allows('get_names')->andReturn(['administrator' => 'Administrator', 'editor' => 'Redakteur']); + Functions\when('wp_roles')->justReturn($roles); + Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path); + Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args)); + Functions\when('get_settings_errors')->justReturn([]); + Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['editor'])); + Functions\when('is_multisite')->justReturn(false); + } + + protected function tearDown(): void + { + $_POST = []; + parent::tearDown(); + } + + public function testRegistersTheRolesOptionWithSanitizer(): void + { + Functions\expect('register_setting')->once()->with( + 'lit_mcp_access', + 'lit_mcp_allowed_roles', + Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [AccessTab::class, 'sanitizeRoles']) + ); + + (new AccessTab(new Access(), Mockery::mock(ConnectionRepository::class)))->registerSettings(); + } + + public function testRegistersTheRevokeHandler(): void + { + $tab = new AccessTab(new Access(), Mockery::mock(ConnectionRepository::class)); + Functions\expect('add_action')->once()->with('admin_post_lit_mcp_revoke', [$tab, 'handleRevoke']); + + $tab->registerActions(); + self::assertSame('zugriff', $tab->slug()); + } + + public function testSanitizeKeepsExistingRolesOnlyAndWarnsWhenEmpty(): void + { + Functions\expect('add_settings_error')->once()->with( + 'lit_mcp_allowed_roles', + 'lit_mcp_no_roles', + Mockery::type('string'), + 'warning' + ); + + self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor', 'nope', '', 5, 'editor'])); + self::assertSame([], AccessTab::sanitizeRoles(null)); + } + + public function testSanitizeDoesNotRepeatAWarningOnTheSecondPassOfAFirstSave(): void + { + Functions\when('get_settings_errors')->justReturn([ + ['setting' => 'lit_mcp_allowed_roles', 'code' => 'lit_mcp_no_roles', 'message' => 'x', 'type' => 'warning'], + ]); + Functions\expect('add_settings_error')->never(); + + self::assertSame([], AccessTab::sanitizeRoles([''])); + } + + public function testSanitizeWarnsWhenTheCurrentUserLosesMcpAccess(): void + { + Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator'])); + Functions\expect('add_settings_error')->once()->with( + 'lit_mcp_allowed_roles', + 'lit_mcp_own_role', + Mockery::type('string'), + 'warning' + ); + + self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor'])); + } + + public function testSanitizeDoesNotWarnWhenTheCurrentUserKeepsAccess(): void + { + Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator'])); + Functions\expect('add_settings_error')->never(); + + self::assertSame(['administrator', 'editor'], AccessTab::sanitizeRoles(['administrator', 'editor'])); + } + + public function testSanitizeDoesNotWarnMultisiteSuperAdmins(): void + { + Functions\when('wp_get_current_user')->justReturn(new WP_User(1, [])); + Functions\when('is_multisite')->justReturn(true); + Functions\when('is_super_admin')->justReturn(true); + Functions\expect('add_settings_error')->never(); + + self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor'])); + } + + public function testRevokeChecksNonceAndRedirectsWithNotice(): void + { + $_POST = ['user_id' => '3', 'uuid' => 'uuid-1']; + Functions\when('current_user_can')->justReturn(true); + Functions\expect('check_admin_referer')->once()->with('lit_mcp_revoke_uuid-1'); + $connections = Mockery::mock(ConnectionRepository::class); + $connections->expects('revoke')->with(3, 'uuid-1')->andReturn(true); + Functions\expect('wp_safe_redirect')->once()->with(Mockery::on( + static fn (string $url): bool => str_contains($url, 'tab=zugriff') && str_contains($url, 'lit_mcp_notice=revoked') + )); + $terminated = false; + + (new AccessTab(new Access(), $connections, static function () use (&$terminated): void { + $terminated = true; + }))->handleRevoke(); + + self::assertTrue($terminated); + } + + public function testRevokeFailureRedirectsWithErrorNotice(): void + { + $_POST = ['user_id' => '3', 'uuid' => 'uuid-other']; + Functions\when('current_user_can')->justReturn(true); + Functions\when('check_admin_referer')->justReturn(1); + $connections = Mockery::mock(ConnectionRepository::class); + $connections->expects('revoke')->with(3, 'uuid-other')->andReturn(false); + Functions\expect('wp_safe_redirect')->once()->with(Mockery::on( + static fn (string $url): bool => str_contains($url, 'lit_mcp_notice=revoke_failed') + )); + + (new AccessTab(new Access(), $connections, static function (): void { + }))->handleRevoke(); + } + + public function testRevokeRequiresManageOptions(): void + { + $_POST = ['user_id' => '3', 'uuid' => 'uuid-1']; + Functions\expect('current_user_can')->once()->with('manage_options')->andReturn(false); + Functions\expect('wp_die')->once()->andThrow(new RuntimeException('died')); + Functions\expect('check_admin_referer')->never(); + $connections = Mockery::mock(ConnectionRepository::class); + $connections->expects('revoke')->never(); + + $this->expectException(RuntimeException::class); + (new AccessTab(new Access(), $connections))->handleRevoke(); + } + + public function testRenderShowsRolesAndConnections(): void + { + Functions\when('get_option')->justReturn(['editor']); + Functions\when('translate_user_role')->returnArg(); + Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : ''); + Functions\when('settings_fields')->justReturn(null); + Functions\when('submit_button')->alias(static function (): void { + echo ''; + }); + Functions\when('wp_nonce_field')->alias(static function (string $action): void { + echo ''; + }); + Functions\when('wp_date')->justReturn('25.09.2026 10:00'); + $connections = Mockery::mock(ConnectionRepository::class); + $connections->allows('all')->andReturn([[ + 'user_id' => 3, + 'user_login' => 'redaktion', + 'uuid' => 'uuid-1', + 'name' => 'Claude