Merge pull request #53121 from nextcloud/feat/sensitive-declarative-settings

feat(declarativeSettings): support encryption of sensitive values
This commit is contained in:
Andy Scherzinger 2025-05-28 20:21:30 +02:00 committed by GitHub
commit 0f5db1b53b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 619 additions and 13 deletions

View file

@ -67,6 +67,7 @@ return [
],
'ocs' => [
['name' => 'DeclarativeSettings#setValue', 'url' => '/settings/api/declarative/value', 'verb' => 'POST', 'root' => ''],
['name' => 'DeclarativeSettings#setSensitiveValue', 'url' => '/settings/api/declarative/value-sensitive', 'verb' => 'POST', 'root' => ''],
['name' => 'DeclarativeSettings#getForms', 'url' => '/settings/api/declarative/forms', 'verb' => 'GET', 'root' => ''],
],
];

View file

@ -144,6 +144,14 @@ trait CommonSettingsTrait {
$this->declarativeSettingsManager->loadSchemas();
$declarativeSettings = $this->declarativeSettingsManager->getFormsWithValues($user, $type, $section);
foreach ($declarativeSettings as &$form) {
foreach ($form['fields'] as &$field) {
if (isset($field['sensitive']) && $field['sensitive'] === true && !empty($field['value'])) {
$field['value'] = 'dummySecret';
}
}
}
if ($type === 'personal') {
$settings = array_values($this->settingsManager->getPersonalSettings($section));
if ($section === 'theming') {

View file

@ -15,6 +15,7 @@ use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
use OCA\Settings\ResponseDefinitions;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCSController;
@ -53,6 +54,45 @@ class DeclarativeSettingsController extends OCSController {
*/
#[NoAdminRequired]
public function setValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
return $this->saveValue($app, $formId, $fieldId, $value);
}
/**
* Sets a declarative settings value.
* Password confirmation is required for sensitive values.
*
* @param string $app ID of the app
* @param string $formId ID of the form
* @param string $fieldId ID of the field
* @param mixed $value Value to be saved
* @return DataResponse<Http::STATUS_OK, null, array{}>
* @throws NotLoggedInException Not logged in or not an admin user
* @throws NotAdminException Not logged in or not an admin user
* @throws OCSBadRequestException Invalid arguments to save value
*
* 200: Value set successfully
*/
#[NoAdminRequired]
#[PasswordConfirmationRequired]
public function setSensitiveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
return $this->saveValue($app, $formId, $fieldId, $value);
}
/**
* Sets a declarative settings value.
*
* @param string $app ID of the app
* @param string $formId ID of the form
* @param string $fieldId ID of the field
* @param mixed $value Value to be saved
* @return DataResponse<Http::STATUS_OK, null, array{}>
* @throws NotLoggedInException Not logged in or not an admin user
* @throws NotAdminException Not logged in or not an admin user
* @throws OCSBadRequestException Invalid arguments to save value
*
* 200: Value set successfully
*/
private function saveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
$user = $this->userSession->getUser();
if ($user === null) {
throw new NotLoggedInException();

View file

@ -20,6 +20,7 @@ namespace OCA\Settings;
* default: mixed,
* options?: list<string|array{name: string, value: mixed}>,
* value: string|int|float|bool|list<string>,
* sensitive?: boolean,
* }
*
* @psalm-type SettingsDeclarativeForm = array{

View file

@ -169,6 +169,9 @@
}
}
]
},
"sensitive": {
"type": "boolean"
}
}
},
@ -373,6 +376,140 @@
}
}
},
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
"post": {
"operationId": "declarative_settings-set-sensitive-value",
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
"description": "This endpoint requires password confirmation",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/forms": {
"get": {
"operationId": "declarative_settings-get-forms",

View file

@ -169,6 +169,9 @@
}
}
]
},
"sensitive": {
"type": "boolean"
}
}
},
@ -332,6 +335,140 @@
}
}
},
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
"post": {
"operationId": "declarative_settings-set-sensitive-value",
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
"description": "This endpoint requires password confirmation",
"tags": [
"declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/forms": {
"get": {
"operationId": "declarative_settings-get-forms",

View file

@ -119,6 +119,7 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { confirmPassword } from '@nextcloud/password-confirmation'
export default {
name: 'DeclarativeSection',
@ -202,9 +203,19 @@ export default {
}
},
updateDeclarativeSettingsValue(formField, value = null) {
async updateDeclarativeSettingsValue(formField, value = null) {
try {
return axios.post(generateOcsUrl('settings/api/declarative/value'), {
let url = generateOcsUrl('settings/api/declarative/value')
if (formField?.sensitive === true) {
url = generateOcsUrl('settings/api/declarative/value-sensitive')
try {
await confirmPassword()
} catch (err) {
showError(t('settings', 'Password confirmation is required'))
return
}
}
return axios.post(url, {
app: this.formApp,
formId: this.form.id.replace(this.formApp + '_', ''), // Remove app prefix to send clean form id
fieldId: formField.id,

View file

@ -20,6 +20,7 @@ interface DeclarativeFormField {
options: Array<unknown>|null,
value: unknown,
default: unknown,
sensitive: boolean,
}
interface DeclarativeForm {

View file

@ -169,6 +169,36 @@ class DeclarativeSettingsForm implements IDeclarativeSettingsForm {
],
],
],
[
'id' => 'test_sensitive_field',
'title' => 'Sensitive text field',
'description' => 'Set some secure value setting that is stored encrypted',
'type' => DeclarativeSettingsTypes::TEXT,
'label' => 'Sensitive field',
'placeholder' => 'Set secure value',
'default' => '',
'sensitive' => true, // only for TEXT, PASSWORD types
],
[
'id' => 'test_sensitive_field_2',
'title' => 'Sensitive password field',
'description' => 'Set some password setting that is stored encrypted',
'type' => DeclarativeSettingsTypes::PASSWORD,
'label' => 'Sensitive field',
'placeholder' => 'Set secure value',
'default' => '',
'sensitive' => true, // only for TEXT, PASSWORD types
],
[
'id' => 'test_non_sensitive_field',
'title' => 'Password field',
'description' => 'Set some password setting',
'type' => DeclarativeSettingsTypes::PASSWORD,
'label' => 'Password field',
'placeholder' => 'Set secure value',
'default' => '',
'sensitive' => false,
],
],
];
}

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,9 @@ This file is generated from multiple sources. Included packages:
- @nextcloud/logger
- version: 3.0.2
- license: GPL-3.0-or-later
- @nextcloud/password-confirmation
- version: 5.3.1
- license: MIT
- @nextcloud/router
- version: 3.0.1
- license: GPL-3.0-or-later

File diff suppressed because one or more lines are too long

View file

@ -15,6 +15,7 @@ use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\Security\ICrypto;
use OCP\Server;
use OCP\Settings\DeclarativeSettingsTypes;
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
@ -49,6 +50,7 @@ class DeclarativeManager implements IDeclarativeManager {
private IConfig $config,
private IAppConfig $appConfig,
private LoggerInterface $logger,
private ICrypto $crypto,
) {
}
@ -266,7 +268,7 @@ class DeclarativeManager implements IDeclarativeManager {
$this->eventDispatcher->dispatchTyped(new DeclarativeSettingsSetValueEvent($user, $app, $formId, $fieldId, $value));
break;
case DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL:
$this->saveInternalValue($user, $app, $fieldId, $value);
$this->saveInternalValue($user, $app, $formId, $fieldId, $value);
break;
default:
throw new Exception('Unknown storage type "' . $storageType . '"');
@ -290,18 +292,52 @@ class DeclarativeManager implements IDeclarativeManager {
private function getInternalValue(IUser $user, string $app, string $formId, string $fieldId): mixed {
$sectionType = $this->getSectionType($app, $fieldId);
$defaultValue = $this->getDefaultValue($app, $formId, $fieldId);
$field = $this->getSchemaField($app, $formId, $fieldId);
$isSensitive = $field !== null && isset($field['sensitive']) && $field['sensitive'] === true;
switch ($sectionType) {
case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
return $this->config->getAppValue($app, $fieldId, $defaultValue);
$value = $this->config->getAppValue($app, $fieldId, $defaultValue);
break;
case DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL:
return $this->config->getUserValue($user->getUID(), $app, $fieldId, $defaultValue);
$value = $this->config->getUserValue($user->getUID(), $app, $fieldId, $defaultValue);
break;
default:
throw new Exception('Unknown section type "' . $sectionType . '"');
}
if ($isSensitive && $value !== '') {
try {
$value = $this->crypto->decrypt($value);
} catch (Exception $e) {
$this->logger->warning('Failed to decrypt sensitive value for field {field} in app {app}: {message}', [
'field' => $fieldId,
'app' => $app,
'message' => $e->getMessage(),
]);
$value = $defaultValue;
}
}
return $value;
}
private function saveInternalValue(IUser $user, string $app, string $fieldId, mixed $value): void {
private function saveInternalValue(IUser $user, string $app, string $formId, string $fieldId, mixed $value): void {
$sectionType = $this->getSectionType($app, $fieldId);
$field = $this->getSchemaField($app, $formId, $fieldId);
if ($field !== null && isset($field['sensitive']) && $field['sensitive'] === true && $value !== '' && $value !== 'dummySecret') {
try {
$value = $this->crypto->encrypt($value);
} catch (Exception $e) {
$this->logger->warning('Failed to decrypt sensitive value for field {field} in app {app}: {message}', [
'field' => $fieldId,
'app' => $app,
'message' => $e->getMessage()]
);
throw new Exception('Failed to encrypt sensitive value');
}
}
switch ($sectionType) {
case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
$this->appConfig->setValueString($app, $fieldId, $value);
@ -314,6 +350,27 @@ class DeclarativeManager implements IDeclarativeManager {
}
}
private function getSchemaField(string $app, string $formId, string $fieldId): ?array {
$form = $this->getForm($app, $formId);
if ($form !== null) {
foreach ($form->getSchema()['fields'] as $field) {
if ($field['id'] === $fieldId) {
return $field;
}
}
}
foreach ($this->appSchemas[$app] ?? [] as $schema) {
if ($schema['id'] === $formId) {
foreach ($schema['fields'] as $field) {
if ($field['id'] === $fieldId) {
return $field;
}
}
}
}
return null;
}
private function getDefaultValue(string $app, string $formId, string $fieldId): mixed {
foreach ($this->appSchemas[$app] as $schema) {
if ($schema['id'] === $formId) {
@ -391,6 +448,12 @@ class DeclarativeManager implements IDeclarativeManager {
]);
return false;
}
if (isset($field['sensitive']) && $field['sensitive'] === true && !in_array($field['type'], [DeclarativeSettingsTypes::TEXT, DeclarativeSettingsTypes::PASSWORD])) {
$this->logger->warning('Declarative settings: sensitive field type is supported only for TEXT and PASSWORD types ({app}, {form_id}, {field_id})', [
'app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId,
]);
return false;
}
if (!$this->validateField($appId, $formId, $field)) {
return false;
}

View file

@ -27,6 +27,7 @@ namespace OCP\Settings;
* label?: string,
* default: mixed,
* options?: list<string|array{name: string, value: mixed}>,
* sensitive?: boolean,
* }
*
* @psalm-type DeclarativeSettingsFormFieldWithValue = DeclarativeSettingsFormField&array{

View file

@ -3981,6 +3981,9 @@
}
}
]
},
"sensitive": {
"type": "boolean"
}
}
},
@ -27197,6 +27200,140 @@
}
}
},
"/ocs/v2.php/settings/api/declarative/value-sensitive": {
"post": {
"operationId": "settings-declarative_settings-set-sensitive-value",
"summary": "Sets a declarative settings value. Password confirmation is required for sensitive values.",
"description": "This endpoint requires password confirmation",
"tags": [
"settings/declarative_settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"app",
"formId",
"fieldId",
"value"
],
"properties": {
"app": {
"type": "string",
"description": "ID of the app"
},
"formId": {
"type": "string",
"description": "ID of the form"
},
"fieldId": {
"type": "string",
"description": "ID of the field"
},
"value": {
"type": "object",
"description": "Value to be saved"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Value set successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"500": {
"description": "Not logged in or not an admin user",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid arguments to save value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/settings/api/declarative/forms": {
"get": {
"operationId": "settings-declarative_settings-get-forms",

View file

@ -18,6 +18,7 @@ use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\Security\ICrypto;
use OCP\Settings\DeclarativeSettingsTypes;
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
use OCP\Settings\IDeclarativeManager;
@ -50,6 +51,9 @@ class DeclarativeManagerTest extends TestCase {
/** @var LoggerInterface|MockObject */
private $logger;
/** @var ICrypto|MockObject */
private $crypto;
/** @var IUser|MockObject */
private $user;
@ -215,6 +219,36 @@ class DeclarativeManagerTest extends TestCase {
],
],
],
[
'id' => 'test_sensitive_field',
'title' => 'Sensitive text field',
'description' => 'Set some secure value setting that is stored encrypted',
'type' => DeclarativeSettingsTypes::TEXT,
'label' => 'Sensitive field',
'placeholder' => 'Set secure value',
'default' => '',
'sensitive' => true, // only for TEXT, PASSWORD types
],
[
'id' => 'test_sensitive_field_2',
'title' => 'Sensitive password field',
'description' => 'Set some password setting that is stored encrypted',
'type' => DeclarativeSettingsTypes::PASSWORD,
'label' => 'Sensitive field',
'placeholder' => 'Set secure value',
'default' => '',
'sensitive' => true, // only for TEXT, PASSWORD types
],
[
'id' => 'test_non_sensitive_field',
'title' => 'Password field',
'description' => 'Set some password setting',
'type' => DeclarativeSettingsTypes::PASSWORD,
'label' => 'Password field',
'placeholder' => 'Set secure value',
'default' => '',
'sensitive' => false,
],
],
];
@ -229,6 +263,7 @@ class DeclarativeManagerTest extends TestCase {
$this->config = $this->createMock(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->crypto = $this->createMock(ICrypto::class);
$this->declarativeManager = new DeclarativeManager(
$this->eventDispatcher,
@ -236,7 +271,8 @@ class DeclarativeManagerTest extends TestCase {
$this->coordinator,
$this->config,
$this->appConfig,
$this->logger
$this->logger,
$this->crypto,
);
$this->user = $this->createMock(IUser::class);