Skip to content

Commit 9670a15

Browse files
authored
feat: add support for data integrity/protection (#81)
1 parent de58c17 commit 9670a15

7 files changed

Lines changed: 1156 additions & 232 deletions

File tree

docs/CONFIGURATION.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,55 @@ Subdirectory for embedding vectors.
232232

233233
**Default:** `embeddings`
234234

235+
#### FILE_PROTECTION_LEVEL
236+
237+
Protection level for file-stored artifacts. Controls integrity checking and encryption.
238+
239+
**Default:** `none`
240+
241+
**Options:**
242+
243+
- `none` - No protection (current default behavior)
244+
- `hash` - SHA-512 hash verification: writes a `.hash` sidecar file alongside each artifact
245+
- `hmac` - HMAC-SHA-512 verification: writes a `.hmac` sidecar file using a keyed hash (requires `FILE_SECRET`)
246+
- `encrypt` - Fernet encryption: encrypts artifact bytes at rest using authenticated encryption (requires `FILE_SECRET`)
247+
248+
**Example:**
249+
250+
```bash
251+
FILE_PROTECTION_LEVEL=encrypt
252+
FILE_SECRET=my-strong-secret-key
253+
```
254+
255+
**Notes:**
256+
257+
- Only applies when `FILE_STORE_TARGET=fs` (filesystem storage)
258+
- `hmac` and `encrypt` modes require `FILE_SECRET` to be set
259+
- A single `FILE_SECRET` is used for both HMAC and encryption; purpose-specific keys are derived internally via HKDF-SHA256
260+
- Changing protection level does not retroactively modify existing files
261+
- `hash` mode detects accidental corruption; `hmac` mode detects tampering; `encrypt` mode provides confidentiality
262+
263+
#### FILE_SECRET
264+
265+
Master secret used for HMAC and encryption operations.
266+
267+
**Default:** None
268+
269+
**Example:**
270+
271+
```bash
272+
FILE_SECRET=my-strong-secret-key
273+
```
274+
275+
**Notes:**
276+
277+
- Required when `FILE_PROTECTION_LEVEL` is `hmac` or `encrypt`
278+
- Treated as a secret (stored via `SecretStr`, supports `/run/secrets` directory)
279+
- Recommended minimum length: 64 bytes (the HMAC-SHA-512 key size). Generate with: `openssl rand -hex 64`
280+
- Purpose-specific keys are derived internally via HKDF, so any sufficiently long secret works for both HMAC and encryption
281+
- Keep this value secure - do not commit to version control
282+
- Changing the secret will make previously protected files unreadable
283+
235284
---
236285

237286
### Vector Database
@@ -987,6 +1036,8 @@ env:
9871036
| `PARSED_JSON_STORE_DIR` | str | No | `json` | JSON subdir |
9881037
| `CHUNKS_STORE_DIR` | str | No | `chunks` | Chunks subdir |
9891038
| `EMBEDDINGS_STORE_DIR` | str | No | `embeddings` | Embeddings subdir |
1039+
| `FILE_PROTECTION_LEVEL` | str | No | `none` | File protection level (none/hash/hmac/encrypt) |
1040+
| `FILE_SECRET` | str | Conditional | - | Master secret for HMAC/encryption (required if protection is hmac or encrypt) |
9901041
| `INGEST_QUEUE_CONCURRENCY` | int | No | `20` | Queue concurrency |
9911042
| `INGEST_WORKER_CONCURRENCY` | int | No | `10` | Worker concurrency |
9921043
| `DOCLING_CONCURRENCY` | int | No | `3` | Docling concurrency |

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "soliplex-ingester"
3-
version = "0.5.3"
3+
version = "0.6.0"
44
description = "ingestion workflow manager for soliplex"
55
authors = [{ name = "Enfold Systems", email = "info@enfoldsystems.net" }]
66
readme = "README.md"
@@ -42,6 +42,7 @@ dependencies = [
4242
"torch>=2.10.0", # added due to missing transitive dependency
4343
"torchvision>=0.25.0", # added due to missing transitive dependency
4444
"aiofile>=3.9.0",
45+
"cryptography>=44.0.0",
4546
"sqlalchemy>=2.0.46",
4647
"pydantic>=2.12.5",
4748
]

src/soliplex/ingester/cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,5 +424,61 @@ def serve(
424424
uvicorn.run(app, **uvicorn_kw)
425425

426426

427+
async def _apply_protection(level: str):
428+
from .lib.config import ProtectionLevel
429+
from .lib.dal import apply_file_protection
430+
431+
try:
432+
target = ProtectionLevel(level)
433+
except ValueError:
434+
print(f"[red]Invalid protection level: {level}[/red]")
435+
print(f"Valid options: {', '.join(p.value for p in ProtectionLevel)}")
436+
raise typer.Exit(code=1) from None
437+
438+
settings = get_settings()
439+
secret = settings.file_secret.get_secret_value() if settings.file_secret else None
440+
441+
if target in (ProtectionLevel.HMAC, ProtectionLevel.ENCRYPT) and not secret:
442+
print(f"[red]FILE_SECRET is required for protection level '{level}'[/red]")
443+
raise typer.Exit(code=1)
444+
445+
# Secret is also needed to decrypt existing .enc files
446+
if target != ProtectionLevel.ENCRYPT and not secret:
447+
# Check if any .enc files exist that would need decryption
448+
base = Path(settings.file_store_dir)
449+
if base.exists():
450+
for _p in base.rglob("*.enc"):
451+
print("[red]FILE_SECRET is required to decrypt existing encrypted files[/red]")
452+
raise typer.Exit(code=1)
453+
454+
print(f"Applying protection level: [bold]{target.value}[/bold]")
455+
stats = await apply_file_protection(target, secret)
456+
print(f"Done. processed={stats['processed']} skipped={stats['skipped']} errors={stats['errors']}")
457+
if stats["errors"] > 0:
458+
raise typer.Exit(code=1)
459+
460+
461+
@app.command("protect")
462+
def protect(
463+
level: str = typer.Argument(
464+
help="Protection level to apply: none, hash, hmac, encrypt",
465+
),
466+
):
467+
"""Apply file protection to all artifacts in the file store.
468+
469+
Walks all artifact directories, reads each file (decrypting if needed),
470+
then writes it with the target protection level and cleans up old
471+
protection artifacts.
472+
473+
Examples:
474+
si-cli protect hash # add SHA-512 hash sidecars
475+
si-cli protect hmac # add HMAC-SHA-512 sidecars
476+
si-cli protect encrypt # encrypt files (requires FILE_SECRET)
477+
si-cli protect none # remove all protection, decrypt files
478+
"""
479+
validate_settings(dump=False)
480+
asyncio.run(_apply_protection(level))
481+
482+
427483
if __name__ == "__main__":
428484
app()

src/soliplex/ingester/lib/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import StrEnum
12
from functools import lru_cache
23
from pathlib import Path
34

@@ -7,6 +8,15 @@
78
from pydantic_settings import SettingsConfigDict
89

910

11+
class ProtectionLevel(StrEnum):
12+
"""Controls integrity/confidentiality protection for file-stored artifacts."""
13+
14+
NONE = "none"
15+
HASH = "hash"
16+
HMAC = "hmac"
17+
ENCRYPT = "encrypt"
18+
19+
1020
class S3Settings(BaseSettings):
1121
bucket: str = "default"
1222
endpoint_url: str = "default"
@@ -26,6 +36,8 @@ class Settings(BaseSettings):
2636
log_level: str = "INFO"
2737
file_store_target: str = "fs"
2838
file_store_dir: str = "file_store"
39+
file_protection_level: ProtectionLevel = ProtectionLevel.NONE
40+
file_secret: SecretStr | None = None
2941
lancedb_dir: str = "lancedb"
3042
document_store_dir: str = "raw"
3143
parsed_markdown_store_dir: str = "markdown"
@@ -80,6 +92,14 @@ def validate_production_auth(self) -> "Settings":
8092
)
8193
return self
8294

95+
@model_validator(mode="after")
96+
def validate_file_protection(self) -> "Settings":
97+
"""Ensure FILE_SECRET is set when protection level requires it."""
98+
if self.file_protection_level in (ProtectionLevel.HMAC, ProtectionLevel.ENCRYPT):
99+
if not self.file_secret or not self.file_secret.get_secret_value():
100+
raise ValueError(f"FILE_SECRET is required when FILE_PROTECTION_LEVEL={self.file_protection_level.value}")
101+
return self
102+
83103
worker_checkin_interval: int = 120
84104
worker_checkin_timeout: int = 600
85105
worker_task_count: int = 5

0 commit comments

Comments
 (0)