Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 32 additions & 24 deletions apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Selection>({ required: true })
Expand Down Expand Up @@ -64,31 +64,39 @@ function toggle(grant: Grant, on: boolean) {
</NcCheckboxRadioSwitch>
</NcRadioGroup>

<div
v-if="preset === 'custom'"
ref="areas"
class="access-picker__areas"
role="group"
:aria-label="t('settings', 'Custom access')">
<template v-for="area in AREAS" :key="area">
<!-- Mixed counts as unticked, so a click grants the whole area -->
<NcCheckboxRadioSwitch
:modelValue="areaState(selection.grants, area) === 'all'"
:indeterminate="areaState(selection.grants, area) === 'partial'"
@update:modelValue="toggle(area, $event)">
{{ GRANT_LABELS[area] }}
</NcCheckboxRadioSwitch>
<div class="access-picker__options">
<template v-if="preset === 'custom'">
<div
ref="areas"
class="access-picker__areas"
role="group"
:aria-label="t('settings', 'Custom access')">
<template v-for="area in AREAS" :key="area">
<!-- Mixed counts as unticked, so a click grants the whole area -->
<NcCheckboxRadioSwitch
v-for="option in AREA_OPTIONS[area]"
:key="option"
:modelValue="selection.grants.includes(option)"
@update:modelValue="toggle(option, $event)">
{{ GRANT_LABELS[option] }}
:modelValue="areaState(selection.grants, area) === 'all'"
:indeterminate="areaState(selection.grants, area) === 'partial'"
@update:modelValue="toggle(area, $event)">
{{ GRANT_LABELS[area] }}
</NcCheckboxRadioSwitch>
</div>
</template>
</div>
<div class="access-picker__options">
<NcCheckboxRadioSwitch
v-for="option in AREA_OPTIONS[area]"
:key="option"
:modelValue="selection.grants.includes(option)"
@update:modelValue="toggle(option, $event)">
{{ GRANT_LABELS[option] }}
</NcCheckboxRadioSwitch>
</div>
</template>
<NcCheckboxRadioSwitch
v-for="service in SERVICES"
:key="service"
:modelValue="selection.grants.includes(service)"
@update:modelValue="toggle(service, $event)">
{{ GRANT_LABELS[service] }}
</NcCheckboxRadioSwitch>
</div>
</template>

<NcNoteCard v-if="kept.length > 0" type="info">
{{ t('settings', 'This app password also has access set outside of Settings. It stays when you save.') }}
Expand Down
8 changes: 8 additions & 0 deletions apps/settings/src/authtokens/utils/accessLevel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })
Expand Down Expand Up @@ -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'])
Expand All @@ -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)
})
Expand Down
20 changes: 14 additions & 6 deletions apps/settings/src/authtokens/utils/accessLevel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -28,6 +31,8 @@ const GRANT_SCOPES: Record<Grant, string[]> = {
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<Area, Grant[]> = {
Expand All @@ -52,6 +57,8 @@ export const GRANT_LABELS: Record<Grant, string> = {
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[]

Expand All @@ -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))

Expand Down Expand Up @@ -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])]
}

/**
Expand Down Expand Up @@ -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'))
Expand All @@ -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
}

/**
Expand Down
10 changes: 10 additions & 0 deletions build/integration/features/auth.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions core/Controller/AutoCompleteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +27,7 @@
/**
* @psalm-import-type CoreAutocompleteResult from ResponseDefinitions
*/
#[TokenScope(scope: TokenScopes::PEOPLE_READ)]
class AutoCompleteController extends OCSController {
use PaginationTrait;

Expand Down
3 changes: 3 additions & 0 deletions core/Controller/ContactsMenuController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions core/Controller/HoverCardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +24,7 @@
/**
* @psalm-import-type CoreContactsAction from ResponseDefinitions
*/
#[TokenScope(scope: TokenScopes::PEOPLE_READ)]
class HoverCardController extends OCSController {
public function __construct(
IRequest $request,
Expand Down
3 changes: 3 additions & 0 deletions core/Controller/ProfileApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions core/Controller/UnifiedSearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions core/TokenScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Core;

/**
* App password scopes for services that mix every app's data
*/
class TokenScopes {
public const SEARCH_READ = 'core:search:read';
public const PEOPLE_READ = 'core:people:read';
}
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,7 @@
'OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType' => $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',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
Loading