Skip to content

Commit 7d9dcd1

Browse files
Merge pull request #11 from ginkelsoft-development/develop
Develop into Main | v1.0.6
2 parents 7802ba3 + d9bdaeb commit 7d9dcd1

File tree

6 files changed

+269
-35
lines changed

6 files changed

+269
-35
lines changed

.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
SEARCH_PEPPER=your-long-random-string-here
2-
ENCRYPTED_SEARCH_DRIVER=elasticsearch
3-
ELASTICSEARCH_HOSTS=https://search.example.com
2+
ENCRYPTED_SEARCH_ELASTIC_ENABLED=true
3+
ELASTICSEARCH_HOST=http://elasticsearch:9200
4+
ELASTICSEARCH_INDEX=encrypted_search

README.md

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
[![Latest Version on Packagist](https://img.shields.io/packagist/v/ginkelsoft/laravel-encrypted-search-index.svg?style=flat-square)](https://packagist.org/packages/ginkelsoft/laravel-encrypted-search-index)
55
[![Total Downloads](https://img.shields.io/packagist/dt/ginkelsoft/laravel-encrypted-search-index.svg?style=flat-square)](https://packagist.org/packages/ginkelsoft/laravel-encrypted-search-index)
66
[![License](https://img.shields.io/github/license/ginkelsoft-development/laravel-encrypted-search-index.svg?style=flat-square)](LICENSE.md)
7-
[![Laravel](https://img.shields.io/badge/Laravel-8--12-brightgreen?style=flat-square&logo=laravel)](https://laravel.com)
8-
[![PHP](https://img.shields.io/badge/PHP-8.1%20--%208.4-blue?style=flat-square&logo=php)](https://php.net)
7+
[![Laravel](https://img.shields.io/badge/Laravel-8--12-brightgreen?style=flat-square\&logo=laravel)](https://laravel.com)
8+
[![PHP](https://img.shields.io/badge/PHP-8.1%20--%208.4-blue?style=flat-square\&logo=php)](https://php.net)
99

1010
## Overview
1111

@@ -36,14 +36,15 @@ This package removes that trade-off by introducing a **detached searchable index
3636
* **Detached search index** — Tokens are stored separately from the main data, reducing exposure risk.
3737
* **Deterministic hashing with peppering** — Each token is derived from normalized text combined with a secret pepper.
3838
* **No blind indexes in primary tables** — Encrypted fields remain opaque; only hashed references are stored elsewhere.
39-
* **High scalability** — Efficient for millions of records through database indexing.
39+
* **High scalability** — Efficient for millions of records through database indexing or Elasticsearch.
40+
* **Elasticsearch integration** — Optionally store and query search tokens directly in an Elasticsearch index.
4041
* **Laravel-native integration** — Works directly with Eloquent models, query scopes, and model events.
4142

4243
---
4344

4445
## How It Works
4546

46-
Each model can declare specific fields as searchable. When the model is saved, the system normalizes the field value, generates one or more hashed tokens, and stores them in a separate table named `encrypted_search_index`.
47+
Each model can declare specific fields as searchable. When the model is saved, the system normalizes the field value, generates one or more hashed tokens, and stores them in a separate table named `encrypted_search_index` **or** in an Elasticsearch index if configured.
4748

4849
When you search, the package hashes your input using the same process and retrieves matching model IDs from the index.
4950

@@ -56,7 +57,9 @@ For each configured field:
5657

5758
### 2. Token Storage
5859

59-
All tokens are stored in `encrypted_search_index`:
60+
By default, all tokens are stored in the database table `encrypted_search_index`. When Elasticsearch is enabled, they are stored in the configured Elasticsearch index instead.
61+
62+
Example structure:
6063

6164
| model_type | model_id | field | type | token |
6265
| ----------------- | -------- | ---------- | ------ | ------ |
@@ -72,7 +75,72 @@ Client::encryptedExact('last_names', 'Vermeer')->get();
7275
Client::encryptedPrefix('first_names', 'Wie')->get();
7376
```
7477

75-
These use indexed lookups and remain performant even at scale.
78+
These use indexed lookups (DB or Elasticsearch) and remain performant even at scale.
79+
80+
---
81+
82+
## Elasticsearch Integration
83+
84+
### Enabling Elasticsearch
85+
86+
To enable Elasticsearch as the storage and query backend for encrypted tokens, set the following in your `.env` file:
87+
88+
```
89+
ENCRYPTED_SEARCH_DRIVER=elasticsearch
90+
ELASTICSEARCH_HOST=http://localhost:9200
91+
ELASTICSEARCH_INDEX=encrypted_search
92+
```
93+
94+
In `config/encrypted-search.php`:
95+
96+
```php
97+
return [
98+
'search_pepper' => env('SEARCH_PEPPER', ''),
99+
'max_prefix_depth' => 6,
100+
101+
'elasticsearch' => [
102+
'enabled' => env('ENCRYPTED_SEARCH_DRIVER', 'database') === 'elasticsearch',
103+
'host' => env('ELASTICSEARCH_HOST', 'http://localhost:9200'),
104+
'index' => env('ELASTICSEARCH_INDEX', 'encrypted_search'),
105+
],
106+
];
107+
```
108+
109+
When enabled, the package will **skip database writes** to `encrypted_search_index` and instead sync tokens directly to Elasticsearch via the `ElasticsearchService`.
110+
111+
### Searching via Elasticsearch
112+
113+
To manually query Elasticsearch for a specific token:
114+
115+
```bash
116+
curl -X GET "http://localhost:9200/encrypted_search/_search?pretty" \
117+
-H 'Content-Type: application/json' \
118+
-d '{
119+
"query": {
120+
"term": {
121+
"token.keyword": "<your-token-here>"
122+
}
123+
}
124+
}'
125+
```
126+
127+
For prefix-based queries, you can match multiple tokens:
128+
129+
```bash
130+
curl -X GET "http://localhost:9200/encrypted_search/_search?pretty" \
131+
-H 'Content-Type: application/json' \
132+
-d '{
133+
"query": {
134+
"bool": {
135+
"should": [
136+
{ "terms": { "token.keyword": ["token1", "token2", "token3"] } }
137+
]
138+
}
139+
}
140+
}'
141+
```
142+
143+
The same token-based hashing rules apply — plaintext values must first be converted into deterministic tokens.
76144

77145
---
78146

@@ -114,6 +182,11 @@ SEARCH_PEPPER=your-random-secret-string
114182
return [
115183
'search_pepper' => env('SEARCH_PEPPER', ''),
116184
'max_prefix_depth' => 6,
185+
'elasticsearch' => [
186+
'enabled' => env('ENCRYPTED_SEARCH_DRIVER', 'database') === 'elasticsearch',
187+
'host' => env('ELASTICSEARCH_HOST', 'http://localhost:9200'),
188+
'index' => env('ELASTICSEARCH_INDEX', 'encrypted_search'),
189+
],
117190
];
118191
```
119192

@@ -139,7 +212,7 @@ class Client extends Model
139212
}
140213
```
141214

142-
When a record is saved, searchable tokens are automatically generated in `encrypted_search_index`.
215+
When a record is saved, searchable tokens are automatically generated in `encrypted_search_index` or synced to Elasticsearch.
143216

144217
### Searching
145218

@@ -159,13 +232,16 @@ Rebuild indexes via Artisan:
159232
php artisan encryption:index-rebuild "App\\Models\\Client"
160233
```
161234

235+
If Elasticsearch is enabled, this will repopulate the Elasticsearch index instead of the database.
236+
162237
---
163238

164239
## Scalability and Performance
165240

166-
* **Indexed database lookups** for efficient token search.
241+
* **Indexed database or Elasticsearch lookups** for efficient token search.
167242
* **Chunked rebuilds** for large datasets (`--chunk` option).
168243
* **Queue-compatible** for asynchronous index rebuilds.
244+
* **Elasticsearch mode** scales horizontally for enterprise use.
169245

170246
The detached index structure scales linearly and supports millions of records efficiently.
171247

config/encrypted-search.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,10 @@
1919
| Bijv. "wietse" -> ["w","wi","wie"]
2020
*/
2121
'max_prefix_depth' => 6,
22+
23+
'elasticsearch' => [
24+
'enabled' => env('ENCRYPTED_SEARCH_ELASTIC_ENABLED', false),
25+
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
26+
'index' => env('ELASTICSEARCH_INDEX', 'encrypted_search'),
27+
],
2228
];

src/Console/RebuildIndex.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public function handle(): int
8585
});
8686

8787
$this->newLine();
88-
$this->info("Rebuilt index for {$count} records of {$class}.");
88+
$this->info("Rebuilt index for {$count} records of {$class}.");
8989

9090
return self::SUCCESS;
9191
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
namespace Ginkelsoft\EncryptedSearch\Services;
4+
5+
use Illuminate\Support\Facades\Http;
6+
7+
/**
8+
* Class ElasticsearchService
9+
*
10+
* Provides a lightweight integration layer between Laravel and Elasticsearch.
11+
* This service is used by the Encrypted Search Index package to store and
12+
* query deterministic encrypted tokens when Elasticsearch mode is enabled.
13+
*
14+
* It wraps basic HTTP interactions for indexing, deleting, and searching
15+
* documents, relying on Laravel's HTTP client for connection handling.
16+
*
17+
* Example usage:
18+
*
19+
* ```php
20+
* $es = app(\Ginkelsoft\EncryptedSearch\Services\ElasticsearchService::class);
21+
* $es->indexDocument('encrypted_search', 'unique-id', ['token' => 'abc123']);
22+
* $results = $es->search('encrypted_search', [
23+
* 'query' => ['term' => ['token.keyword' => 'abc123']]
24+
* ]);
25+
* ```
26+
*/
27+
class ElasticsearchService
28+
{
29+
/**
30+
* The base host URL of the Elasticsearch instance.
31+
*
32+
* @var string
33+
*/
34+
protected string $host;
35+
36+
/**
37+
* Create a new ElasticsearchService instance.
38+
*
39+
* @param string|null $host Optional custom Elasticsearch host URL.
40+
*/
41+
public function __construct(?string $host = null)
42+
{
43+
$this->host = $host ?? config('encrypted-search.elasticsearch.host', 'http://elasticsearch:9200');
44+
}
45+
46+
/**
47+
* Index or update a document in Elasticsearch.
48+
*
49+
* @param string $index The Elasticsearch index name.
50+
* @param string $id The unique document ID.
51+
* @param array<string, mixed> $body The document body to be stored.
52+
* @return bool True if successful, false otherwise.
53+
*/
54+
public function indexDocument(string $index, string $id, array $body): bool
55+
{
56+
$url = "{$this->host}/{$index}/_doc/{$id}";
57+
$response = Http::put($url, $body);
58+
59+
return $response->successful();
60+
}
61+
62+
/**
63+
* Delete a document from Elasticsearch by its ID.
64+
*
65+
* @param string $index The Elasticsearch index name.
66+
* @param string $id The document ID to delete.
67+
* @return bool True if successful, false otherwise.
68+
*/
69+
public function deleteDocument(string $index, string $id): bool
70+
{
71+
$url = "{$this->host}/{$index}/_doc/{$id}";
72+
$response = Http::delete($url);
73+
74+
return $response->successful();
75+
}
76+
77+
/**
78+
* Execute a search query against an Elasticsearch index.
79+
*
80+
* @param string $index The Elasticsearch index name.
81+
* @param array<string, mixed> $query The Elasticsearch query body.
82+
* @return array<int, mixed> The array of matching documents (hits).
83+
*/
84+
public function search(string $index, array $query): array
85+
{
86+
$url = "{$this->host}/{$index}/_search";
87+
$response = Http::post($url, $query);
88+
89+
return $response->json('hits.hits', []);
90+
}
91+
}

0 commit comments

Comments
 (0)