feat: add support for sensitive Declarative settings values encryption

Signed-off-by: Andrey Borysenko <andrey18106x@gmail.com>
This commit is contained in:
Andrey Borysenko 2025-05-22 20:14:33 +03:00
parent 1d4b899244
commit 7994332338
No known key found for this signature in database
GPG key ID: 934CB29F9F59B0D1
13 changed files with 610 additions and 7 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,
],
],
];
}

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);