Skip to content

Commit ad42d5e

Browse files
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
1 parent 41bf9ac commit ad42d5e

File tree

2 files changed

+138
-2
lines changed

2 files changed

+138
-2
lines changed

src/Console/RebuildIndex.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,42 @@ public function handle(): int
7171
$query = $class::query();
7272

7373
$count = 0;
74+
$encrypted = 0;
7475

75-
$query->chunk($chunk, function ($models) use (&$count) {
76+
$query->chunk($chunk, function ($models) use (&$count, &$encrypted) {
7677
foreach ($models as $model) {
78+
// Check if model has encrypted casts and ensure data is encrypted
79+
if (method_exists($model, 'getCasts')) {
80+
$casts = $model->getCasts();
81+
$needsSave = false;
82+
83+
foreach ($casts as $field => $cast) {
84+
if (str_contains(strtolower($cast), 'encrypted')) {
85+
// Get the raw value from database (bypassing accessors)
86+
$attributes = $model->getAttributes();
87+
$rawValue = $attributes[$field] ?? null;
88+
89+
// Check if value exists and is not already encrypted
90+
// Encrypted values start with 'eyJpdiI' (base64 of '{"iv"')
91+
if ($rawValue && !str_starts_with($rawValue, 'eyJpdiI')) {
92+
// Value is not encrypted, encrypt it now
93+
$decrypted = $rawValue; // Value is already decrypted in DB
94+
$model->setAttribute($field, $decrypted); // This will encrypt via cast
95+
$needsSave = true;
96+
$encrypted++;
97+
}
98+
}
99+
}
100+
101+
// Save if any fields were re-encrypted
102+
if ($needsSave) {
103+
$model->save();
104+
}
105+
}
106+
107+
// Update search index
77108
if (method_exists($model, 'updateSearchIndex')) {
78-
$model->updateSearchIndex(); // <-- FIXED
109+
$model->updateSearchIndex();
79110
}
80111
$count++;
81112
}
@@ -87,6 +118,10 @@ public function handle(): int
87118
$this->newLine();
88119
$this->info("Rebuilt index for {$count} records of {$class}.");
89120

121+
if ($encrypted > 0) {
122+
$this->info("Encrypted {$encrypted} unencrypted field(s) during rebuild.");
123+
}
124+
90125
return self::SUCCESS;
91126
}
92127
}

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,107 @@ public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string
419419
});
420420
}
421421

422+
/**
423+
* Scope: search for a term across multiple fields using OR logic.
424+
*
425+
* @param Builder $query
426+
* @param array<int, string> $fields
427+
* @param string $term
428+
* @param string $type Either 'exact' or 'prefix'
429+
* @return Builder
430+
*/
431+
public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): Builder
432+
{
433+
if ($type === 'exact') {
434+
return $this->scopeEncryptedExactMulti($query, $fields, $term);
435+
}
436+
437+
return $this->scopeEncryptedPrefixMulti($query, $fields, $term);
438+
}
439+
440+
/**
441+
* Scope: search for multiple field-term pairs using AND logic.
442+
*
443+
* All specified field-term pairs must match for a record to be returned.
444+
*
445+
* @param Builder $query
446+
* @param array<string, string> $fieldTerms Associative array of field => term pairs
447+
* @param string $type Either 'exact' or 'prefix'
448+
* @return Builder
449+
*/
450+
public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder
451+
{
452+
if (empty($fieldTerms)) {
453+
return $query->whereRaw('1=0');
454+
}
455+
456+
$pepper = (string) config('encrypted-search.search_pepper', '');
457+
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
458+
$useElastic = config('encrypted-search.elasticsearch.enabled', false);
459+
460+
// Build conditions for each field-term pair
461+
foreach ($fieldTerms as $field => $term) {
462+
$normalized = Normalizer::normalize($term);
463+
464+
if (!$normalized) {
465+
return $query->whereRaw('1=0');
466+
}
467+
468+
if ($type === 'prefix') {
469+
// Check minimum length for prefix searches
470+
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
471+
return $query->whereRaw('1=0');
472+
}
473+
474+
$tokens = Tokens::prefixes(
475+
$normalized,
476+
(int) config('encrypted-search.max_prefix_depth', 6),
477+
$pepper,
478+
$minLength
479+
);
480+
481+
if (empty($tokens)) {
482+
return $query->whereRaw('1=0');
483+
}
484+
485+
// AND logic: intersect model IDs for each field-term pair
486+
if ($useElastic) {
487+
$modelIds = $this->searchElasticsearch($field, $tokens, 'prefix');
488+
$query->whereIn($this->getQualifiedKeyName(), $modelIds);
489+
} else {
490+
$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens) {
491+
$sub->select('model_id')
492+
->from('encrypted_search_index')
493+
->where('model_type', static::class)
494+
->where('field', $field)
495+
->where('type', 'prefix')
496+
->whereIn('token', $tokens);
497+
});
498+
}
499+
} else {
500+
// Exact match
501+
$token = Tokens::exact($normalized, $pepper);
502+
503+
// AND logic: intersect model IDs for each field-term pair
504+
if ($useElastic) {
505+
$modelIds = $this->searchElasticsearch($field, $token, 'exact');
506+
$query->whereIn($this->getQualifiedKeyName(), $modelIds);
507+
} else {
508+
$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $token) {
509+
$sub->select('model_id')
510+
->from('encrypted_search_index')
511+
->where('model_type', static::class)
512+
->where('field', $field)
513+
->where('type', 'exact')
514+
->where('token', $token);
515+
});
516+
}
517+
}
518+
}
519+
520+
return $query;
521+
}
522+
422523
/**
423524
* Check if a field has an encrypted cast.
424525
*

0 commit comments

Comments
 (0)