From 226cf0160158465b9d88a2b32a8b16e5f5655a19 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:17:28 +0300 Subject: [PATCH] feat(model): add encrypted JSON casts - Add encrypted-json and encrypted-json-array cast types - Reuse existing JSON and encrypted cast handling - Cover nullable values, invalid payloads, DataConverter, and Entity usage - Update Model and Entity docs Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/DataCaster/Cast/EncryptedJsonCast.php | 59 ++++++++++ system/DataCaster/DataCaster.php | 42 ++++--- tests/system/DataCaster/EncryptedCastTest.php | 104 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 6 +- user_guide_src/source/models/entities.rst | 10 +- user_guide_src/source/models/model.rst | 70 +++++++----- 6 files changed, 238 insertions(+), 53 deletions(-) create mode 100644 system/DataCaster/Cast/EncryptedJsonCast.php diff --git a/system/DataCaster/Cast/EncryptedJsonCast.php b/system/DataCaster/Cast/EncryptedJsonCast.php new file mode 100644 index 000000000000..534fbf1c1554 --- /dev/null +++ b/system/DataCaster/Cast/EncryptedJsonCast.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\DataCaster\Cast; + +use SensitiveParameter; +use stdClass; + +/** + * Class EncryptedJsonCast + * + * (PHP) [array|stdClass --> encrypted JSON string] --> (DB driver) --> (DB column) string + * [ <-- decrypted JSON string] <-- (DB driver) <-- (DB column) encrypted JSON string + */ +class EncryptedJsonCast extends BaseCast +{ + /** + * @return array|stdClass|null + */ + public static function get( + #[SensitiveParameter] + mixed $value, + array $params = [], + ?object $helper = null, + ): array|stdClass|null { + if ($value === null) { + return null; + } + + $json = EncryptedCast::get($value, $params, $helper); + + return JsonCast::get($json, $params, $helper); + } + + public static function set( + #[SensitiveParameter] + mixed $value, + array $params = [], + ?object $helper = null, + ): ?string { + if ($value === null) { + return null; + } + + $json = JsonCast::set($value, $params, $helper); + + return EncryptedCast::set($json, $params, $helper); + } +} diff --git a/system/DataCaster/DataCaster.php b/system/DataCaster/DataCaster.php index 8e4669d6bed2..a737a33b6c60 100644 --- a/system/DataCaster/DataCaster.php +++ b/system/DataCaster/DataCaster.php @@ -19,6 +19,7 @@ use CodeIgniter\DataCaster\Cast\CSVCast; use CodeIgniter\DataCaster\Cast\DatetimeCast; use CodeIgniter\DataCaster\Cast\EncryptedCast; +use CodeIgniter\DataCaster\Cast\EncryptedJsonCast; use CodeIgniter\DataCaster\Cast\EnumCast; use CodeIgniter\DataCaster\Cast\FloatCast; use CodeIgniter\DataCaster\Cast\IntBoolCast; @@ -51,21 +52,22 @@ final class DataCaster * @var cast_handlers [type => classname] */ private array $castHandlers = [ - 'array' => ArrayCast::class, - 'bool' => BooleanCast::class, - 'boolean' => BooleanCast::class, - 'csv' => CSVCast::class, - 'datetime' => DatetimeCast::class, - 'encrypted' => EncryptedCast::class, - 'enum' => EnumCast::class, - 'double' => FloatCast::class, - 'float' => FloatCast::class, - 'int' => IntegerCast::class, - 'integer' => IntegerCast::class, - 'int-bool' => IntBoolCast::class, - 'json' => JsonCast::class, - 'timestamp' => TimestampCast::class, - 'uri' => URICast::class, + 'array' => ArrayCast::class, + 'bool' => BooleanCast::class, + 'boolean' => BooleanCast::class, + 'csv' => CSVCast::class, + 'datetime' => DatetimeCast::class, + 'encrypted' => EncryptedCast::class, + 'encrypted-json' => EncryptedJsonCast::class, + 'enum' => EnumCast::class, + 'double' => FloatCast::class, + 'float' => FloatCast::class, + 'int' => IntegerCast::class, + 'integer' => IntegerCast::class, + 'int-bool' => IntBoolCast::class, + 'json' => JsonCast::class, + 'timestamp' => TimestampCast::class, + 'uri' => URICast::class, ]; /** @@ -159,9 +161,13 @@ public function castAs(mixed $value, string $field, string $method = 'get'): mix } } - // In order not to create a separate handler for the - // json-array type, we transform the required one. - $type = ($type === 'json-array') ? 'json[array]' : $type; + // In order not to create separate handlers for these + // array variants, we transform the required ones. + $type = match ($type) { + 'json-array' => 'json[array]', + 'encrypted-json-array' => 'encrypted-json[array]', + default => $type, + }; $params = []; diff --git a/tests/system/DataCaster/EncryptedCastTest.php b/tests/system/DataCaster/EncryptedCastTest.php index 44e0dde8d84a..c40305579e72 100644 --- a/tests/system/DataCaster/EncryptedCastTest.php +++ b/tests/system/DataCaster/EncryptedCastTest.php @@ -23,6 +23,7 @@ use Config\Services; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use stdClass; /** * @internal @@ -109,6 +110,73 @@ public function testEncryptedCastCanDecryptPreviousKeyValues(): void $this->assertSame('old-secret', $dataCaster->castAs($oldEncryptedValue, 'secret')); } + public function testEncryptedJsonCastConvertsObjectToAndFromEncryptedJson(): void + { + $dataCaster = new DataCaster(types: ['settings' => 'encrypted-json']); + + $encrypted = $dataCaster->castAs((object) ['notifications' => true, 'theme' => 'dark'], 'settings', 'set'); + + $this->assertIsString($encrypted); + $this->assertNotSame('{"notifications":true,"theme":"dark"}', $encrypted); + $this->assertNotFalse(base64_decode($encrypted, true)); + + $settings = $dataCaster->castAs($encrypted, 'settings'); + + $this->assertInstanceOf(stdClass::class, $settings); + $this->assertTrue($settings->notifications); + $this->assertSame('dark', $settings->theme); + } + + public function testEncryptedJsonArrayCastConvertsArrayToAndFromEncryptedJson(): void + { + $dataCaster = new DataCaster(types: ['settings' => 'encrypted-json-array']); + + $encrypted = $dataCaster->castAs(['notifications' => true, 'theme' => 'dark'], 'settings', 'set'); + + $this->assertIsString($encrypted); + $this->assertNotSame('{"notifications":true,"theme":"dark"}', $encrypted); + $this->assertSame( + ['notifications' => true, 'theme' => 'dark'], + $dataCaster->castAs($encrypted, 'settings'), + ); + } + + public function testEncryptedJsonCastsSupportNullableValues(): void + { + $dataCaster = new DataCaster(types: [ + 'settings' => '?encrypted-json', + 'arraySettings' => '?encrypted-json-array', + ]); + + $this->assertNull($dataCaster->castAs(null, 'settings', 'set')); + $this->assertNull($dataCaster->castAs(null, 'settings')); + $this->assertNull($dataCaster->castAs(null, 'arraySettings', 'set')); + $this->assertNull($dataCaster->castAs(null, 'arraySettings')); + } + + public function testEncryptedJsonCastRejectsMalformedEncryptedPayload(): void + { + $this->expectException(CastException::class); + $this->expectExceptionMessage('Type casting "encrypted" expects a valid encrypted value.'); + + $dataCaster = new DataCaster(types: ['settings' => 'encrypted-json']); + + $dataCaster->castAs('@@not-base64@@', 'settings'); + } + + public function testEncryptedJsonCastRejectsDecryptedMalformedJson(): void + { + $this->expectException(CastException::class); + $this->expectExceptionMessage('Syntax error, malformed JSON.'); + + $encryptedCast = new DataCaster(types: ['settings' => 'encrypted']); + $encrypted = $encryptedCast->castAs('not-json', 'settings', 'set'); + + $dataCaster = new DataCaster(types: ['settings' => 'encrypted-json']); + + $dataCaster->castAs($encrypted, 'settings'); + } + public function testDataConverterConvertsEncryptedFieldToAndFromDataSource(): void { $converter = new DataConverter(['secret' => 'encrypted']); @@ -120,6 +188,21 @@ public function testDataConverterConvertsEncryptedFieldToAndFromDataSource(): vo $this->assertSame(['secret' => 'plain-secret'], $converter->fromDataSource($dataSourceData)); } + public function testDataConverterConvertsEncryptedJsonArrayFieldToAndFromDataSource(): void + { + $converter = new DataConverter(['settings' => 'encrypted-json-array']); + + $dataSourceData = $converter->toDataSource([ + 'settings' => ['notifications' => true, 'theme' => 'dark'], + ]); + + $this->assertIsString($dataSourceData['settings']); + $this->assertSame( + ['settings' => ['notifications' => true, 'theme' => 'dark']], + $converter->fromDataSource($dataSourceData), + ); + } + public function testEntityStoresEncryptedRawValueAndReturnsPlaintext(): void { $entity = new class () extends Entity { @@ -138,6 +221,27 @@ public function testEntityStoresEncryptedRawValueAndReturnsPlaintext(): void $this->assertSame(['secret' => 'plain-secret'], $entity->toArray()); } + public function testEntityStoresEncryptedJsonRawValueAndReturnsPlainValue(): void + { + $entity = new class () extends Entity { + protected $casts = [ + 'settings' => 'encrypted-json-array', + ]; + }; + + $entity->settings = ['notifications' => true, 'theme' => 'dark']; + + $raw = $entity->toRawArray(); + + $this->assertIsString($raw['settings']); + $this->assertNotSame('{"notifications":true,"theme":"dark"}', $raw['settings']); + $this->assertSame(['notifications' => true, 'theme' => 'dark'], $entity->settings); + $this->assertSame( + ['settings' => ['notifications' => true, 'theme' => 'dark']], + $entity->toArray(), + ); + } + /** * @param list $previousKeys */ diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 031d5e0b3a5a..e57ae42ce008 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -274,7 +274,7 @@ Model ===== - Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks. -- Added ``encrypted`` casting for Entity properties and Model fields using the Encryption service. See :ref:`model-field-casting-encrypted`. +- Added ``encrypted``, ``encrypted-json`` and ``encrypted-json-array`` casting for Entity properties and Model fields using the Encryption service. See :ref:`model-field-casting-encrypted`. - Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given attributes or inserts a new one. See :ref:`model-first-or-insert`. - Added ``$insertOnlyFields`` and ``setInsertOnlyFields()`` to ``CodeIgniter\Model`` to remove configured fields from Model update operations while allowing them during inserts. See :ref:`model-insert-only-fields`. - Added ``$throwOnDisallowedFields`` and ``throwOnDisallowedFields()`` to ``CodeIgniter\Model`` to throw a ``DataException`` when write data contains fields that would otherwise be discarded by ``$allowedFields``. See :ref:`model-throw-on-disallowed-fields`. @@ -300,8 +300,8 @@ Helpers and Functions object rows (including ``Entity``) in :php:func:`dot_array_search()`, :php:func:`dot_array_has()`, :php:func:`dot_array_only()`, :php:func:`dot_array_except()`, and :php:func:`array_group_by()`. - :php:func:`dot_array_only()` and :php:func:`dot_array_except()` still return arrays. If the source itself is an object, - it is read as an array-like value. Object values inside the source are kept unchanged when selected as a whole or left untouched. + :php:func:`dot_array_only()` and :php:func:`dot_array_except()` still return arrays. If the source itself is an object, + it is read as an array-like value. Object values inside the source are kept unchanged when selected as a whole or left untouched. Partial object paths are returned as arrays. HTTP diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index de88e34a0fb2..236096677847 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -256,12 +256,12 @@ Scalar Type Casting ------------------- Properties can be cast to any of the following data types: -**integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool**, **enum** and **encrypted**. +**integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool**, **enum**, **encrypted**, **encrypted-json** and **encrypted-json-array**. Add a question mark at the beginning of type to mark property as nullable, i.e., **?string**, **?integer**. .. note:: **int-bool** can be used since v4.3.0. .. note:: **enum** can be used since v4.7.0. -.. note:: **encrypted** can be used since v4.8.0. +.. note:: **encrypted**, **encrypted-json** and **encrypted-json-array** can be used since v4.8.0. .. note:: Since v4.8.0, you can also pass parameters to **float** and **double** types to specify the number of decimal places and rounding mode, i.e., **float[2,even]**. For example, if you had a User entity with an ``is_banned`` property, you can cast it as a boolean: @@ -349,6 +349,12 @@ for nullable values. .. literalinclude:: entities/029.php +The ``encrypted-json`` and ``encrypted-json-array`` types JSON encode values +before encryption and JSON decode them after decryption. The ``encrypted-json`` +type returns ``stdClass`` values, while ``encrypted-json-array`` returns arrays. +Use ``?encrypted-json`` and ``?encrypted-json-array`` for nullable values. These +values are still stored as encrypted text, not as queryable database JSON. + Encrypted values are stored as Base64-encoded ciphertext. Use a ``TEXT`` column or a sufficiently large string column because the stored value is longer than the plain text value. Avoid narrow columns like ``VARCHAR(255)`` unless you have diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 2eee0644b0f5..d3d93d7e56c0 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -399,35 +399,39 @@ Data Types The following types are provided by default. Add a question mark at the beginning of type to mark the field as nullable, i.e., ``?int``, ``?datetime``. -+---------------+----------------+---------------------------+ -| Type | PHP Value Type | DB Column Type | -+===============+================+===========================+ -|``int`` | int | int type | -+---------------+----------------+---------------------------+ -|``float`` | float | float (numeric) type | -+---------------+----------------+---------------------------+ -|``bool`` | bool | bool/int/string type | -+---------------+----------------+---------------------------+ -|``int-bool`` | bool | int type (1 or 0) | -+---------------+----------------+---------------------------+ -|``array`` | array | string type (serialized) | -+---------------+----------------+---------------------------+ -|``csv`` | array | string type (CSV) | -+---------------+----------------+---------------------------+ -|``json`` | stdClass | json/string type | -+---------------+----------------+---------------------------+ -|``json-array`` | array | json/string type | -+---------------+----------------+---------------------------+ -|``datetime`` | Time | datetime type | -+---------------+----------------+---------------------------+ -|``timestamp`` | Time | int type (UNIX timestamp) | -+---------------+----------------+---------------------------+ -|``uri`` | URI | string type | -+---------------+----------------+---------------------------+ -|``enum`` | Enum | string/int type | -+---------------+----------------+---------------------------+ -|``encrypted`` | string | string/text type | -+---------------+----------------+---------------------------+ ++--------------------------+----------------+---------------------------+ +| Type | PHP Value Type | DB Column Type | ++==========================+================+===========================+ +|``int`` | int | int type | ++--------------------------+----------------+---------------------------+ +|``float`` | float | float (numeric) type | ++--------------------------+----------------+---------------------------+ +|``bool`` | bool | bool/int/string type | ++--------------------------+----------------+---------------------------+ +|``int-bool`` | bool | int type (1 or 0) | ++--------------------------+----------------+---------------------------+ +|``array`` | array | string type (serialized) | ++--------------------------+----------------+---------------------------+ +|``csv`` | array | string type (CSV) | ++--------------------------+----------------+---------------------------+ +|``json`` | stdClass | json/string type | ++--------------------------+----------------+---------------------------+ +|``json-array`` | array | json/string type | ++--------------------------+----------------+---------------------------+ +|``datetime`` | Time | datetime type | ++--------------------------+----------------+---------------------------+ +|``timestamp`` | Time | int type (UNIX timestamp) | ++--------------------------+----------------+---------------------------+ +|``uri`` | URI | string type | ++--------------------------+----------------+---------------------------+ +|``enum`` | Enum | string/int type | ++--------------------------+----------------+---------------------------+ +|``encrypted`` | string | string/text type | ++--------------------------+----------------+---------------------------+ +|``encrypted-json`` | stdClass | string/text type | ++--------------------------+----------------+---------------------------+ +|``encrypted-json-array`` | array | string/text type | ++--------------------------+----------------+---------------------------+ float ----- @@ -505,7 +509,13 @@ encryption key before using it. The configured key is required for both writing new values and reading stored values back. The ``encrypted`` type accepts string values. Use ``?encrypted`` for nullable values. -.. literalinclude:: model/069.php +.. literalinclude:: model/070.php + +The ``encrypted-json`` and ``encrypted-json-array`` types JSON encode values +before encryption and JSON decode them after decryption. The ``encrypted-json`` +type returns ``stdClass`` values, while ``encrypted-json-array`` returns arrays. +Use ``?encrypted-json`` and ``?encrypted-json-array`` for nullable values. These +values are still stored as encrypted text, not as queryable database JSON. Encrypted values are stored as Base64-encoded ciphertext. Use a ``TEXT`` column or a sufficiently large string column because the stored value is longer than