Skip to content

Commit 01ff1f6

Browse files
committed
Initial commit
0 parents  commit 01ff1f6

14 files changed

Lines changed: 881 additions & 0 deletions

File tree

.gitignore

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
wheels/
23+
share/python-wheels/
24+
*.egg-info/
25+
.installed.cfg
26+
*.egg
27+
MANIFEST
28+
29+
# Installer logs
30+
pip-log.txt
31+
pip-delete-this-directory.txt
32+
33+
# Unit test / coverage reports
34+
htmlcov/
35+
.tox/
36+
.nox/
37+
.coverage
38+
.coverage.*
39+
.cache
40+
nosetests.xml
41+
coverage.xml
42+
*.cover
43+
*.py,cover
44+
.hypothesis/
45+
.pytest_cache/
46+
47+
# Translations
48+
*.mo
49+
*.pot
50+
51+
# Django stuff:
52+
*.log
53+
local_settings.py
54+
55+
# Flask stuff:
56+
instance/
57+
.webassets-cache
58+
59+
# Scrapy stuff:
60+
.scrapy
61+
62+
# Sphinx documentation
63+
build/
64+
65+
# PyBuilder
66+
.pybuilder/
67+
68+
# Jupyter Notebook
69+
.ipynb_checkpoints
70+
71+
# IPython
72+
profile_default/
73+
ipython_config.py
74+
75+
# pyenv
76+
.python-version
77+
78+
# pipenv
79+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
80+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
81+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
82+
# install all needed dependencies.
83+
# Therefore, this is a recommendation and not a requirement.
84+
# Pipfile.lock
85+
86+
# poetry
87+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
88+
# This is especially true for binary packages to ensure reproducibility, and is more commonly
89+
# ignored for libraries.
90+
# poetry.lock
91+
92+
# pdm
93+
.pdm-build/
94+
.pdm-python
95+
.pdm.toml
96+
97+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
98+
__pypackages__/
99+
100+
# Celery stuff
101+
celerybeat-schedule
102+
celerybeat.pid
103+
104+
# SageMath parsed files
105+
*.sage.py
106+
107+
# Environments
108+
.env
109+
.venv
110+
env/
111+
venv/
112+
ENV/
113+
env.bak/
114+
venv.bak/
115+
116+
# Spyder project settings
117+
.spyderproject
118+
.spyproject
119+
120+
# Rope project settings
121+
.ropeproject
122+
123+
# mkdocs documentation
124+
/site
125+
126+
# mypy
127+
.mypy_cache/
128+
.dmypy.json
129+
dmypy.json
130+
131+
# Pyre type checker
132+
.pyre/
133+
134+
# pytype type checker
135+
.pytype/
136+
137+
# Cython debug symbols
138+
cython_debug/
139+
140+
# Ruff
141+
.ruff_cache/
142+
143+
# PyCharm
144+
.idea/
145+
146+
# VS Code
147+
.vscode/
148+
149+
# MacOS
150+
.DS_Store
151+
152+
# Windows
153+
Thumbs.db
154+
155+
# Local tooling
156+
.pytest_cache/

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# SQLModel Encrypted Fields
2+
3+
Encrypt SQLModel fields with Tink AEAD in a few lines, and keep ciphertext out of sight.
4+
5+
## Install
6+
7+
```bash
8+
pip install sqlmodel-encrypted-fields
9+
```
10+
11+
## Quickstart
12+
13+
```python
14+
from sqlalchemy import Column
15+
from sqlmodel import Field, SQLModel
16+
17+
from sqlmodel_encrypted_fields import (
18+
configure_keysets,
19+
EncryptedString,
20+
DeterministicEncryptedString,
21+
)
22+
23+
configure_keysets(
24+
{
25+
"default": {"path": "/path/to/aead_keyset.json", "cleartext": True},
26+
"searchable": {"path": "/path/to/daead_keyset.json", "cleartext": True},
27+
}
28+
)
29+
30+
31+
class Customer(SQLModel, table=True):
32+
id: int | None = Field(default=None, primary_key=True)
33+
email: str = Field(sa_column=Column(EncryptedString()))
34+
email_lookup: str = Field(sa_column=Column(DeterministicEncryptedString(keyset="searchable")))
35+
```
36+
37+
## Notes
38+
39+
Regular AEAD fields change ciphertext on every write. Use deterministic fields only when you need equality lookups.
40+
41+
## Supported Fields
42+
43+
- `EncryptedType` (custom serializer/deserializer)
44+
- `EncryptedString`
45+
- `EncryptedJSON`
46+
- `EncryptedBytes`
47+
- `DeterministicEncryptedType` (custom serializer/deserializer)
48+
- `DeterministicEncryptedString`
49+
- `DeterministicEncryptedJSON`
50+
- `DeterministicEncryptedBytes`
51+
52+
## Custom Serialization
53+
54+
```python
55+
from datetime import date
56+
57+
from sqlmodel_encrypted_fields import EncryptedType
58+
59+
60+
def serialize_date(value: date) -> str:
61+
return value.isoformat()
62+
63+
64+
def deserialize_date(value: str) -> date:
65+
return date.fromisoformat(value)
66+
67+
68+
class User(SQLModel, table=True):
69+
id: int | None = Field(default=None, primary_key=True)
70+
birthday: date = Field(sa_column=EncryptedType(serializer=serialize_date, deserializer=deserialize_date))
71+
```

example_app/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Minimal FastAPI + SQLModel example app."""

example_app/database.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from sqlmodel import Session, SQLModel, create_engine
4+
5+
engine = create_engine("sqlite:///./example_app.db", echo=False)
6+
7+
8+
def init_db() -> None:
9+
SQLModel.metadata.create_all(engine)
10+
11+
12+
def get_session() -> Session:
13+
return Session(engine)

example_app/main.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
from contextlib import asynccontextmanager
4+
5+
from fastapi import Depends, FastAPI, HTTPException
6+
from sqlmodel import Session, select
7+
8+
from example_app.database import get_session, init_db
9+
from example_app.models import Customer
10+
from sqlmodel_encrypted_fields import configure_keysets
11+
12+
@asynccontextmanager
13+
async def lifespan(_: FastAPI):
14+
configure_keysets(
15+
{
16+
"default": {"path": "./tests/fixtures/aead_keyset.json", "cleartext": True},
17+
"deterministic": {"path": "./tests/fixtures/daead_keyset.json", "cleartext": True},
18+
}
19+
)
20+
init_db()
21+
yield
22+
23+
24+
app = FastAPI(title="SQLModel Encrypted Fields Example", lifespan=lifespan)
25+
26+
27+
@app.post("/customers", response_model=Customer)
28+
def create_customer(customer: Customer, session: Session = Depends(get_session)) -> Customer:
29+
session.add(customer)
30+
session.commit()
31+
session.refresh(customer)
32+
return customer
33+
34+
35+
@app.get("/customers/{customer_id}", response_model=Customer)
36+
def get_customer(customer_id: int, session: Session = Depends(get_session)) -> Customer:
37+
customer = session.get(Customer, customer_id)
38+
if customer is None:
39+
raise HTTPException(status_code=404, detail="Customer not found")
40+
return customer
41+
42+
43+
@app.get("/customers/by-email/{email}", response_model=Customer)
44+
def get_customer_by_email(email: str, session: Session = Depends(get_session)) -> Customer:
45+
statement = select(Customer).where(Customer.email_lookup == email)
46+
customer = session.exec(statement).first()
47+
if customer is None:
48+
raise HTTPException(status_code=404, detail="Customer not found")
49+
return customer

example_app/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
5+
from sqlalchemy import Column
6+
from sqlmodel import Field, SQLModel
7+
8+
from sqlmodel_encrypted_fields import DeterministicEncryptedString, EncryptedString
9+
10+
11+
class Customer(SQLModel, table=True):
12+
id: Optional[int] = Field(default=None, primary_key=True)
13+
email: str = Field(sa_column=Column(EncryptedString()))
14+
email_lookup: str = Field(sa_column=Column(DeterministicEncryptedString(keyset="deterministic")))
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from fastapi.testclient import TestClient
6+
from sqlmodel import Session, SQLModel, create_engine, select
7+
8+
from example_app.database import get_session
9+
from example_app.main import app
10+
from example_app.models import Customer
11+
from sqlmodel_encrypted_fields import configure_keysets
12+
13+
14+
def _project_root() -> Path:
15+
return Path(__file__).resolve().parents[2]
16+
17+
18+
def _configure_keysets() -> None:
19+
root = _project_root()
20+
configure_keysets(
21+
{
22+
"default": {"path": str(root / "tests" / "fixtures" / "aead_keyset.json"), "cleartext": True},
23+
"deterministic": {"path": str(root / "tests" / "fixtures" / "daead_keyset.json"), "cleartext": True},
24+
}
25+
)
26+
27+
28+
def _test_engine(tmp_path: Path):
29+
return create_engine(f"sqlite:///{tmp_path / 'test.db'}", echo=False)
30+
31+
32+
def test_customer_create_and_lookup(tmp_path: Path) -> None:
33+
_configure_keysets()
34+
35+
engine = _test_engine(tmp_path)
36+
SQLModel.metadata.create_all(engine)
37+
38+
def _session_override():
39+
with Session(engine) as session:
40+
yield session
41+
42+
app.dependency_overrides[get_session] = _session_override
43+
client = TestClient(app)
44+
45+
payload = {"email": "alice@example.com", "email_lookup": "alice@example.com"}
46+
response = client.post("/customers", json=payload)
47+
assert response.status_code == 200
48+
customer_id = response.json()["id"]
49+
50+
response = client.get(f"/customers/{customer_id}")
51+
assert response.status_code == 200
52+
assert response.json()["email"] == payload["email"]
53+
54+
response = client.get(f"/customers/by-email/{payload['email']}")
55+
assert response.status_code == 200
56+
assert response.json()["id"] == customer_id
57+
58+
with engine.connect() as connection:
59+
raw = connection.exec_driver_sql(
60+
"select email from customer where id = ?",
61+
(customer_id,),
62+
).fetchone()[0]
63+
assert isinstance(raw, (bytes, memoryview))
64+
raw_bytes = raw.tobytes() if isinstance(raw, memoryview) else raw
65+
assert payload["email"].encode("utf-8") not in raw_bytes
66+
67+
with Session(engine) as session:
68+
statement = select(Customer).where(Customer.email_lookup == payload["email"])
69+
customer = session.exec(statement).first()
70+
assert customer is not None
71+
72+
app.dependency_overrides.clear()

0 commit comments

Comments
 (0)