diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue index 7c47ce664b03b..75281611d8d7c 100644 --- a/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue +++ b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue @@ -11,7 +11,7 @@ import { computed, useTemplateRef, watch } from 'vue' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcRadioGroup from '@nextcloud/vue/components/NcRadioGroup' -import { AREA_OPTIONS, AREAS, areaState, GRANT_LABELS, PRESET_GRANTS, setGrant } from '../utils/accessLevel.ts' +import { AREA_OPTIONS, AREAS, areaState, GRANT_LABELS, PRESET_GRANTS, SERVICES, setGrant } from '../utils/accessLevel.ts' /** What the app password can reach */ const selection = defineModel({ required: true }) @@ -64,31 +64,39 @@ function toggle(grant: Grant, on: boolean) { -
- {{ t('settings', 'This app password also has access set outside of Settings. It stays when you save.') }} diff --git a/apps/settings/src/authtokens/utils/accessLevel.spec.ts b/apps/settings/src/authtokens/utils/accessLevel.spec.ts index 1f593b4459e0e..ca580922f7c8a 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.spec.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.spec.ts @@ -18,6 +18,8 @@ describe('accessLevel', () => { ['calendar and contacts', { preset: 'calendarContacts', grants: ['calendar', 'calendarWrite', 'contacts', 'contactsWrite'] }], ['files without sharing', { preset: 'custom', grants: ['files'] }], ['read-only calendar', { preset: 'custom', grants: ['calendar'] }], + ['files with search', { preset: 'custom', grants: ['files', 'filesShare', 'search'] }], + ['people lookup alone', { preset: 'custom', grants: ['people'] }], ])('round-trips %s', (_, selection) => { const scopes = toScopes(selection, [])! expect(fromScopes(scopes)).toEqual({ selection, kept: [] }) @@ -55,6 +57,11 @@ describe('accessLevel', () => { expect(setGrant(['calendar', 'calendarWrite', 'files'], 'calendar', false)).toEqual(['files']) }) + it('ticks a service on its own', () => { + expect(setGrant(['files'], 'search', true)).toEqual(['files', 'search']) + expect(setGrant(['files', 'search'], 'search', false)).toEqual(['files']) + }) + it('ticks the area with a sub-option, and unticks only the sub-option', () => { expect(setGrant([], 'contactsWrite', true)).toEqual(['contacts', 'contactsWrite']) expect(setGrant(['contacts', 'contactsWrite'], 'contactsWrite', false)).toEqual(['contacts']) @@ -71,6 +78,7 @@ describe('accessLevel', () => { [{ filesystem: false, scopes: ['app:deck'] }, 'Other access'], [{ filesystem: true, scopes: [...files, 'app:deck'] }, 'Files and other access'], [{ filesystem: false, scopes: [] }, 'No access'], + [{ filesystem: true, scopes: [...files, 'core:search:read', 'core:people:read'] }, 'Files, Search across all apps, and People lookup across all apps'], ])('labels %o as %s', (scope, label) => { expect(accessLabel(scope)).toBe(label) }) diff --git a/apps/settings/src/authtokens/utils/accessLevel.ts b/apps/settings/src/authtokens/utils/accessLevel.ts index 5f64fb186615a..aa5abebe0e6f7 100644 --- a/apps/settings/src/authtokens/utils/accessLevel.ts +++ b/apps/settings/src/authtokens/utils/accessLevel.ts @@ -8,7 +8,10 @@ import type { ITokenScope } from '../store/authtoken.ts' import { getLanguage, t } from '@nextcloud/l10n' export type Area = 'files' | 'calendar' | 'contacts' -export type Grant = Area | 'filesShare' | 'filesTrash' | 'filesVersions' | 'calendarWrite' | 'contactsWrite' +/** Services that mix every app's data */ +export const SERVICES = ['search', 'people'] as const +export type Service = typeof SERVICES[number] +export type Grant = Area | Service | 'filesShare' | 'filesTrash' | 'filesVersions' | 'calendarWrite' | 'contactsWrite' export type Preset = 'all' | 'files' | 'calendarContacts' | 'custom' type AreaState = 'all' | 'partial' | 'none' @@ -28,6 +31,8 @@ const GRANT_SCOPES: Record = { calendarWrite: ['dav:calendar:write'], contacts: ['dav:contacts:read'], contactsWrite: ['dav:contacts:write'], + search: ['core:search:read'], + people: ['core:people:read'], } export const AREA_OPTIONS: Record = { @@ -52,6 +57,8 @@ export const GRANT_LABELS: Record = { calendarWrite: t('settings', 'Can edit calendars'), contacts: t('settings', 'Contacts'), contactsWrite: t('settings', 'Can edit contacts'), + search: t('settings', 'Search across all apps'), + people: t('settings', 'People lookup across all apps'), } const GRANTS = Object.keys(GRANT_SCOPES) as Grant[] @@ -68,7 +75,7 @@ export function fromScopes(scopes?: string[]): { selection: Selection, kept: str } const held = GRANTS.filter((grant) => GRANT_SCOPES[grant].every((scope) => scopes.includes(scope))) - const grants = held.filter((grant) => held.includes(areaOf(grant))) + const grants = held.filter((grant) => held.includes(parentOf(grant))) const shown = grants.flatMap((grant) => GRANT_SCOPES[grant]) const kept = scopes.filter((scope) => !shown.includes(scope)) @@ -113,7 +120,7 @@ export function setGrant(grants: Grant[], grant: Grant, on: boolean): Grant[] { if (!on) { return grants.filter((held) => !changed.includes(held)) } - return [...new Set([...grants, areaOf(grant), ...changed])] + return [...new Set([...grants, parentOf(grant), ...changed])] } /** @@ -141,6 +148,7 @@ export function accessLabel(scope: ITokenScope): string { } return [(state === 'partial' && readOnly[area]) || GRANT_LABELS[area]] }) + parts.push(...SERVICES.filter((service) => selection.grants.includes(service)).map((service) => GRANT_LABELS[service])) if (kept.length > 0) { // TRANSLATORS Last item of a list such as "Files and other access" parts.push(parts.length === 0 ? t('settings', 'Other access') : t('settings', 'other access')) @@ -152,10 +160,10 @@ export function accessLabel(scope: ITokenScope): string { } /** - * @param grant An area or sub-option + * @param grant An area, sub-option or service */ -function areaOf(grant: Grant): Area { - return AREAS.find((area) => area === grant || AREA_OPTIONS[area].includes(grant))! +function parentOf(grant: Grant): Grant { + return AREAS.find((area) => AREA_OPTIONS[area].includes(grant)) ?? grant } /** diff --git a/build/integration/features/auth.feature b/build/integration/features/auth.feature index c366385086a15..0bdf65cd839cc 100644 --- a/build/integration/features/auth.feature +++ b/build/integration/features/auth.feature @@ -135,6 +135,16 @@ Feature: auth When requesting "/ocs/v2.php/search/providers" with "GET" using scoped basic token auth Then the HTTP status code should be "403" + Scenario: a files token with search reaches unified search + Given a new client token limited to "files:files:read files:files:write core:search:read" is added + When requesting "/ocs/v2.php/search/providers" with "GET" using scoped basic token auth + Then the HTTP status code should be "200" + + Scenario: a files token with people lookup reaches the hovercard + Given a new client token limited to "files:files:read files:files:write core:people:read" is added + When requesting "/ocs/v2.php/hovercard/v1/user0" with "GET" using scoped basic token auth + Then the HTTP status code should be "200" + Scenario: a files token gets no page render Given a new client token limited to "files:files:read files:files:write" is added When requesting "/index.php/apps/files" with "GET" using scoped basic token auth diff --git a/core/Controller/AutoCompleteController.php b/core/Controller/AutoCompleteController.php index 88a31dd1857e9..9e67ff6657cec 100644 --- a/core/Controller/AutoCompleteController.php +++ b/core/Controller/AutoCompleteController.php @@ -9,8 +9,10 @@ namespace OC\Core\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\AppFramework\Http\PaginationTrait; use OC\Core\ResponseDefinitions; +use OC\Core\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -25,6 +27,7 @@ /** * @psalm-import-type CoreAutocompleteResult from ResponseDefinitions */ +#[TokenScope(scope: TokenScopes::PEOPLE_READ)] class AutoCompleteController extends OCSController { use PaginationTrait; diff --git a/core/Controller/ContactsMenuController.php b/core/Controller/ContactsMenuController.php index 40869e5ffd806..0642b6cbef13c 100644 --- a/core/Controller/ContactsMenuController.php +++ b/core/Controller/ContactsMenuController.php @@ -8,7 +8,9 @@ namespace OC\Core\Controller; use Exception; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\Contacts\ContactsMenu\Manager; +use OC\Core\TokenScopes; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; @@ -19,6 +21,7 @@ use OCP\IUserSession; use OCP\Teams\ITeamManager; +#[TokenScope(scope: TokenScopes::PEOPLE_READ)] class ContactsMenuController extends Controller { public function __construct( IRequest $request, diff --git a/core/Controller/HoverCardController.php b/core/Controller/HoverCardController.php index 9ef33df64ea04..423146ae74dab 100644 --- a/core/Controller/HoverCardController.php +++ b/core/Controller/HoverCardController.php @@ -8,8 +8,10 @@ namespace OC\Core\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\Contacts\ContactsMenu\Manager; use OC\Core\ResponseDefinitions; +use OC\Core\TokenScopes; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -22,6 +24,7 @@ /** * @psalm-import-type CoreContactsAction from ResponseDefinitions */ +#[TokenScope(scope: TokenScopes::PEOPLE_READ)] class HoverCardController extends OCSController { public function __construct( IRequest $request, diff --git a/core/Controller/ProfileApiController.php b/core/Controller/ProfileApiController.php index 97c5682b1b70e..1a96a8b0f9f85 100644 --- a/core/Controller/ProfileApiController.php +++ b/core/Controller/ProfileApiController.php @@ -9,8 +9,10 @@ namespace OC\Core\Controller; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\Core\Db\ProfileConfigMapper; use OC\Core\ResponseDefinitions; +use OC\Core\TokenScopes; use OC\Profile\ProfileManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -106,6 +108,7 @@ public function setVisibility(string $targetUserId, string $paramId, string $vis #[ApiRoute(verb: 'GET', url: '/{targetUserId}', root: '/profile')] #[BruteForceProtection(action: 'user')] #[UserRateLimit(limit: 30, period: 120)] + #[TokenScope(scope: TokenScopes::PEOPLE_READ)] public function getProfileFields(string $targetUserId): DataResponse { $targetUser = $this->userManager->get($targetUserId); if (!$targetUser instanceof IUser) { diff --git a/core/Controller/UnifiedSearchController.php b/core/Controller/UnifiedSearchController.php index d418d58b25ef0..a9cac020d5c0d 100644 --- a/core/Controller/UnifiedSearchController.php +++ b/core/Controller/UnifiedSearchController.php @@ -10,9 +10,11 @@ namespace OC\Core\Controller; use InvalidArgumentException; +use NCU\AppFramework\Http\Attribute\TokenScope; use OC\Core\AppInfo\Application; use OC\Core\AppInfo\ConfigLexicon; use OC\Core\ResponseDefinitions; +use OC\Core\TokenScopes; use OC\Search\SearchComposer; use OC\Search\SearchQuery; use OC\Search\UnsupportedFilter; @@ -35,6 +37,7 @@ * @psalm-import-type CoreUnifiedSearchProvider from ResponseDefinitions * @psalm-import-type CoreUnifiedSearchResult from ResponseDefinitions */ +#[TokenScope(scope: TokenScopes::SEARCH_READ)] class UnifiedSearchController extends OCSController { public function __construct( IRequest $request, diff --git a/core/TokenScopes.php b/core/TokenScopes.php new file mode 100644 index 0000000000000..ec18ecbd3e40f --- /dev/null +++ b/core/TokenScopes.php @@ -0,0 +1,18 @@ + $baseDir . '/core/Sharing/Recipient/TeamShareRecipientType.php', 'OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType' => $baseDir . '/core/Sharing/Recipient/TokenShareRecipientType.php', 'OC\\Core\\Sharing\\Recipient\\UserShareRecipientType' => $baseDir . '/core/Sharing/Recipient/UserShareRecipientType.php', + 'OC\\Core\\TokenScopes' => $baseDir . '/core/TokenScopes.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php', 'OC\\DB\\AdapterOCI8' => $baseDir . '/lib/private/DB/AdapterOCI8.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index d830c2f16413d..b79bcea7ebdfa 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1817,6 +1817,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/TeamShareRecipientType.php', 'OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/TokenShareRecipientType.php', 'OC\\Core\\Sharing\\Recipient\\UserShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/UserShareRecipientType.php', + 'OC\\Core\\TokenScopes' => __DIR__ . '/../../..' . '/core/TokenScopes.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php', 'OC\\DB\\AdapterOCI8' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterOCI8.php', diff --git a/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php index b708ce8fbdb2e..572b597f0f09d 100644 --- a/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/TokenScopeMiddlewareTest.php @@ -16,7 +16,13 @@ use OC\AppFramework\Utility\ControllerMethodReflector; use OC\Authentication\Token\PublicKeyToken; use OC\Authentication\Token\TokenScopes; +use OC\Core\Controller\AutoCompleteController; +use OC\Core\Controller\ContactsMenuController; +use OC\Core\Controller\HoverCardController; use OC\Core\Controller\PreviewController; +use OC\Core\Controller\ProfileApiController; +use OC\Core\Controller\UnifiedSearchController; +use OC\Core\TokenScopes as CoreTokenScopes; use OC\Lockdown\LockdownManager; use OCA\DAV\Controller\DirectController; use OCA\DAV\TokenScopes as DavTokenScopes; @@ -165,13 +171,29 @@ public static function pinnedScopes(): array { ]; } + public static function sharedServices(): array { + return [ + 'unified search' => [UnifiedSearchController::class, 'search', CoreTokenScopes::SEARCH_READ], + 'autocomplete' => [AutoCompleteController::class, 'get', CoreTokenScopes::PEOPLE_READ], + 'contacts menu' => [ContactsMenuController::class, 'index', CoreTokenScopes::PEOPLE_READ], + 'hovercard' => [HoverCardController::class, 'getUser', CoreTokenScopes::PEOPLE_READ], + 'profile fields' => [ProfileApiController::class, 'getProfileFields', CoreTokenScopes::PEOPLE_READ], + ]; + } + #[DataProvider('pinnedScopes')] + #[DataProvider('sharedServices')] public function testPinnedScopes(string $class, string $method, string $scope): void { $attribute = (new \ReflectionMethod($class, $method))->getAttributes(TokenScope::class)[0] ?? (new \ReflectionClass($class))->getAttributes(TokenScope::class)[0]; $this->assertSame($scope, $attribute->newInstance()->scope); } + public function testProfileVisibilityStaysClosed(): void { + $this->assertSame([], (new \ReflectionMethod(ProfileApiController::class, 'setVisibility'))->getAttributes(TokenScope::class)); + $this->assertSame([], (new \ReflectionClass(ProfileApiController::class))->getAttributes(TokenScope::class)); + } + /** * Page renders are refused anyway, so they must not run for a scoped token first */