From ad42d5e006e96a4f84c37fdefbeec58f9a4d84de Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Tue, 14 Oct 2025 06:49:27 +0200 Subject: [PATCH] add encryption validation and batch search methods Features: - RebuildIndex command now checks and encrypts unencrypted fields during rebuild - Detects non-encrypted data by checking for Laravel's encryption format prefix - Automatically re-encrypts plaintext data in encrypted cast fields - Reports number of fields encrypted during rebuild process - Add encryptedSearchAny() scope for OR logic across multiple fields - Add encryptedSearchAll() scope for AND logic with multiple field-term pairs - Both methods support exact and prefix search types - Compatible with database and Elasticsearch backends Tests: - All 106 tests passing - BatchQueryTest now fully operational with new scope methods - Rebuild command tested with encryption validation Technical details: - Uses getAttributes() to access raw database values - Detects encrypted format by checking for 'eyJpdiI' prefix (base64 of '{"iv"') - encryptedSearchAny() delegates to existing encryptedExactMulti/encryptedPrefixMulti - encryptedSearchAll() builds chained whereIn conditions for AND logic --- src/Console/RebuildIndex.php | 39 +++++++++- src/Traits/HasEncryptedSearchIndex.php | 101 +++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/Console/RebuildIndex.php b/src/Console/RebuildIndex.php index 18f86e5..35ca9fa 100644 --- a/src/Console/RebuildIndex.php +++ b/src/Console/RebuildIndex.php @@ -71,11 +71,42 @@ public function handle(): int $query = $class::query(); $count = 0; + $encrypted = 0; - $query->chunk($chunk, function ($models) use (&$count) { + $query->chunk($chunk, function ($models) use (&$count, &$encrypted) { foreach ($models as $model) { + // Check if model has encrypted casts and ensure data is encrypted + if (method_exists($model, 'getCasts')) { + $casts = $model->getCasts(); + $needsSave = false; + + foreach ($casts as $field => $cast) { + if (str_contains(strtolower($cast), 'encrypted')) { + // Get the raw value from database (bypassing accessors) + $attributes = $model->getAttributes(); + $rawValue = $attributes[$field] ?? null; + + // Check if value exists and is not already encrypted + // Encrypted values start with 'eyJpdiI' (base64 of '{"iv"') + if ($rawValue && !str_starts_with($rawValue, 'eyJpdiI')) { + // Value is not encrypted, encrypt it now + $decrypted = $rawValue; // Value is already decrypted in DB + $model->setAttribute($field, $decrypted); // This will encrypt via cast + $needsSave = true; + $encrypted++; + } + } + } + + // Save if any fields were re-encrypted + if ($needsSave) { + $model->save(); + } + } + + // Update search index if (method_exists($model, 'updateSearchIndex')) { - $model->updateSearchIndex(); // <-- FIXED + $model->updateSearchIndex(); } $count++; } @@ -87,6 +118,10 @@ public function handle(): int $this->newLine(); $this->info("Rebuilt index for {$count} records of {$class}."); + if ($encrypted > 0) { + $this->info("Encrypted {$encrypted} unencrypted field(s) during rebuild."); + } + return self::SUCCESS; } } diff --git a/src/Traits/HasEncryptedSearchIndex.php b/src/Traits/HasEncryptedSearchIndex.php index f37baab..959395f 100644 --- a/src/Traits/HasEncryptedSearchIndex.php +++ b/src/Traits/HasEncryptedSearchIndex.php @@ -419,6 +419,107 @@ public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string }); } + /** + * Scope: search for a term across multiple fields using OR logic. + * + * @param Builder $query + * @param array $fields + * @param string $term + * @param string $type Either 'exact' or 'prefix' + * @return Builder + */ + public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): Builder + { + if ($type === 'exact') { + return $this->scopeEncryptedExactMulti($query, $fields, $term); + } + + return $this->scopeEncryptedPrefixMulti($query, $fields, $term); + } + + /** + * Scope: search for multiple field-term pairs using AND logic. + * + * All specified field-term pairs must match for a record to be returned. + * + * @param Builder $query + * @param array $fieldTerms Associative array of field => term pairs + * @param string $type Either 'exact' or 'prefix' + * @return Builder + */ + public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder + { + if (empty($fieldTerms)) { + return $query->whereRaw('1=0'); + } + + $pepper = (string) config('encrypted-search.search_pepper', ''); + $minLength = (int) config('encrypted-search.min_prefix_length', 1); + $useElastic = config('encrypted-search.elasticsearch.enabled', false); + + // Build conditions for each field-term pair + foreach ($fieldTerms as $field => $term) { + $normalized = Normalizer::normalize($term); + + if (!$normalized) { + return $query->whereRaw('1=0'); + } + + if ($type === 'prefix') { + // Check minimum length for prefix searches + if (mb_strlen($normalized, 'UTF-8') < $minLength) { + return $query->whereRaw('1=0'); + } + + $tokens = Tokens::prefixes( + $normalized, + (int) config('encrypted-search.max_prefix_depth', 6), + $pepper, + $minLength + ); + + if (empty($tokens)) { + return $query->whereRaw('1=0'); + } + + // AND logic: intersect model IDs for each field-term pair + if ($useElastic) { + $modelIds = $this->searchElasticsearch($field, $tokens, 'prefix'); + $query->whereIn($this->getQualifiedKeyName(), $modelIds); + } else { + $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens) { + $sub->select('model_id') + ->from('encrypted_search_index') + ->where('model_type', static::class) + ->where('field', $field) + ->where('type', 'prefix') + ->whereIn('token', $tokens); + }); + } + } else { + // Exact match + $token = Tokens::exact($normalized, $pepper); + + // AND logic: intersect model IDs for each field-term pair + if ($useElastic) { + $modelIds = $this->searchElasticsearch($field, $token, 'exact'); + $query->whereIn($this->getQualifiedKeyName(), $modelIds); + } else { + $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $token) { + $sub->select('model_id') + ->from('encrypted_search_index') + ->where('model_type', static::class) + ->where('field', $field) + ->where('type', 'exact') + ->where('token', $token); + }); + } + } + } + + return $query; + } + /** * Check if a field has an encrypted cast. *