paper-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paper_core-0.1.0/PKG-INFO +372 -0
- paper_core-0.1.0/README.md +350 -0
- paper_core-0.1.0/paper/core/__init__.py +0 -0
- paper_core-0.1.0/paper/core/audit/__init__.py +4 -0
- paper_core-0.1.0/paper/core/audit/audit.py +70 -0
- paper_core-0.1.0/paper/core/audit/enums.py +19 -0
- paper_core-0.1.0/paper/core/auth/__init__.py +41 -0
- paper_core-0.1.0/paper/core/auth/enums.py +56 -0
- paper_core-0.1.0/paper/core/auth/jwt.py +85 -0
- paper_core-0.1.0/paper/core/auth/middleware.py +179 -0
- paper_core-0.1.0/paper/core/auth/models.py +43 -0
- paper_core-0.1.0/paper/core/auth/password.py +52 -0
- paper_core-0.1.0/paper/core/auth/utils.py +62 -0
- paper_core-0.1.0/paper/core/db/__init__.py +12 -0
- paper_core-0.1.0/paper/core/db/base.py +70 -0
- paper_core-0.1.0/paper/core/db/multitenant.py +63 -0
- paper_core-0.1.0/paper/core/db/postgres.py +179 -0
- paper_core-0.1.0/paper/core/email/__init__.py +19 -0
- paper_core-0.1.0/paper/core/email/base.py +43 -0
- paper_core-0.1.0/paper/core/email/enums.py +36 -0
- paper_core-0.1.0/paper/core/email/service.py +47 -0
- paper_core-0.1.0/paper/core/email/smtp.py +131 -0
- paper_core-0.1.0/paper/core/email/templates/__init__.py +3 -0
- paper_core-0.1.0/paper/core/email/templates/account_created.py +82 -0
- paper_core-0.1.0/paper/core/email/templates/member_invited.py +70 -0
- paper_core-0.1.0/paper/core/email/templates/reset_password.py +88 -0
- paper_core-0.1.0/paper/core/email/theme.py +25 -0
- paper_core-0.1.0/paper/core/errors/__init__.py +4 -0
- paper_core-0.1.0/paper/core/errors/enums.py +19 -0
- paper_core-0.1.0/paper/core/errors/handler.py +26 -0
- paper_core-0.1.0/paper/core/middleware/__init__.py +9 -0
- paper_core-0.1.0/paper/core/middleware/hipaa.py +33 -0
- paper_core-0.1.0/paper/core/middleware/logging.py +54 -0
- paper_core-0.1.0/paper/core/middleware/request_id.py +44 -0
- paper_core-0.1.0/paper/core/security/__init__.py +7 -0
- paper_core-0.1.0/paper/core/security/base.py +53 -0
- paper_core-0.1.0/paper/core/security/crypto.py +118 -0
- paper_core-0.1.0/paper/core/security/enums.py +9 -0
- paper_core-0.1.0/paper/core/security/hasher.py +15 -0
- paper_core-0.1.0/paper/core/security/pem.py +19 -0
- paper_core-0.1.0/paper_core.egg-info/PKG-INFO +372 -0
- paper_core-0.1.0/paper_core.egg-info/SOURCES.txt +52 -0
- paper_core-0.1.0/paper_core.egg-info/dependency_links.txt +1 -0
- paper_core-0.1.0/paper_core.egg-info/requires.txt +16 -0
- paper_core-0.1.0/paper_core.egg-info/top_level.txt +1 -0
- paper_core-0.1.0/pyproject.toml +38 -0
- paper_core-0.1.0/setup.cfg +4 -0
- paper_core-0.1.0/tests/test_audit.py +134 -0
- paper_core-0.1.0/tests/test_auth.py +305 -0
- paper_core-0.1.0/tests/test_db.py +153 -0
- paper_core-0.1.0/tests/test_email.py +241 -0
- paper_core-0.1.0/tests/test_errors.py +49 -0
- paper_core-0.1.0/tests/test_middleware.py +144 -0
- paper_core-0.1.0/tests/test_security.py +98 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paper-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core runtime library for the PaperDraft framework
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi
|
|
8
|
+
Requires-Dist: uvicorn[standard]
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: pydantic-settings
|
|
11
|
+
Requires-Dist: sqlalchemy[asyncio]
|
|
12
|
+
Requires-Dist: asyncpg
|
|
13
|
+
Requires-Dist: PyJWT[crypto]
|
|
14
|
+
Requires-Dist: argon2-cffi
|
|
15
|
+
Requires-Dist: cryptography
|
|
16
|
+
Requires-Dist: python-dotenv
|
|
17
|
+
Requires-Dist: python-multipart
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
21
|
+
Requires-Dist: httpx; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# paper-core
|
|
24
|
+
|
|
25
|
+
Runtime library for the PaperDraft framework. Provides auth, database, encryption, email, middleware, error handling, and audit logging — all importable under the `paper.core` namespace.
|
|
26
|
+
|
|
27
|
+
> **Status:** pre-release · v0.1 in progress · Paper Plane Consulting LLC
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Table of Contents
|
|
32
|
+
|
|
33
|
+
1. [Installation](#installation)
|
|
34
|
+
2. [Local Development](#local-development)
|
|
35
|
+
3. [Running Tests](#running-tests)
|
|
36
|
+
4. [Module Reference](#module-reference)
|
|
37
|
+
5. [Extending Core](#extending-core)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install paper-core
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Requirements:** Python 3.11+
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Local Development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone <repo-url>
|
|
55
|
+
cd paper-core
|
|
56
|
+
|
|
57
|
+
python -m venv .venv
|
|
58
|
+
source .venv/bin/activate # macOS/Linux
|
|
59
|
+
.venv\Scripts\activate # Windows
|
|
60
|
+
|
|
61
|
+
pip install -e ".[dev]"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `-e .` (editable install) is required — it registers the `paper` namespace package so that `from paper.core.X import Y` resolves correctly.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Running Tests
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Run a specific module:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pytest tests/test_auth.py -v
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Test suite coverage
|
|
81
|
+
|
|
82
|
+
| File | Covers |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `test_errors.py` | `ErrorHandler`, `ErrorMessage` |
|
|
85
|
+
| `test_security.py` | `Hasher`, `Crypto`, `RSACrypto`, `Pem`, `Encoding` |
|
|
86
|
+
| `test_audit.py` | `Audit`, `AuditAction`, `AuditOutcome` |
|
|
87
|
+
| `test_auth.py` | `Credentials`, `Signature`, `Password`, `Auth`, `Key`, `Claims`, `Authenticate`, `Authorize`, `LoginAttemptLimit` |
|
|
88
|
+
| `test_middleware.py` | `HipaaResponseHeaders`, `RequestIdMiddleware`, `RequestLoggingMiddleware` |
|
|
89
|
+
| `test_db.py` | `FilterType`, `ConnectionPoolConfig`, `_relationship_fields`, `MultiTenantPoolManager` |
|
|
90
|
+
| `test_email.py` | `EmailTheme`, `Info`, `Body`, `Message`, `Subject`, `MimeType` |
|
|
91
|
+
|
|
92
|
+
> **Note:** `Postgres` CRUD tests (create, retrieve, update, delete) require a live PostgreSQL connection and are not included. Run those as integration tests against a test database.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Module Reference
|
|
97
|
+
|
|
98
|
+
### `paper.core.auth`
|
|
99
|
+
|
|
100
|
+
JWT signing, password hashing, and FastAPI authentication dependencies.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from paper.core.auth import (
|
|
104
|
+
Auth, # signs JWT access + refresh token pairs
|
|
105
|
+
Password, # Argon2 hash and verify
|
|
106
|
+
Authenticate, # FastAPI dep — validates JWT from header or cookie
|
|
107
|
+
Authorize, # FastAPI dep — validates JWT + enforces RBAC roles
|
|
108
|
+
LoginAttemptLimit, # FastAPI dep — rate limits login attempts per IP
|
|
109
|
+
set_auth_params, # call once at startup
|
|
110
|
+
set_login_attempt_params,
|
|
111
|
+
Claims, Key, # token decode utilities
|
|
112
|
+
Credentials, Signature, # request/response models
|
|
113
|
+
Algorithm, TokenType, ClaimsKey, AuthErrorMessage,
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Startup wiring (in `dependencies.py`):**
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from paper.core.auth import set_auth_params, set_login_attempt_params
|
|
121
|
+
from paper.core.auth.enums import Algorithm
|
|
122
|
+
from datetime import timedelta
|
|
123
|
+
|
|
124
|
+
set_auth_params({
|
|
125
|
+
"public_key": config.ENCRYPTION.PUBLIC_KEY,
|
|
126
|
+
"excluded_paths": ["/auth/login", "/auth/refresh", "/health"],
|
|
127
|
+
"alg": Algorithm.RS256,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
set_login_attempt_params(max_attempts=5, lockout_duration=timedelta(minutes=15))
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Route usage:**
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from paper.core.auth import Authenticate, Authorize
|
|
137
|
+
|
|
138
|
+
@router.get("/me")
|
|
139
|
+
async def me(claims = Depends(Authenticate())):
|
|
140
|
+
return claims
|
|
141
|
+
|
|
142
|
+
@router.delete("/{id}")
|
|
143
|
+
async def delete(claims = Depends(Authorize(["admin"]))):
|
|
144
|
+
...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### `paper.core.db`
|
|
150
|
+
|
|
151
|
+
Async SQLAlchemy PostgreSQL repository with optional multi-tenant pool management.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from paper.core.db import (
|
|
155
|
+
Postgres, # async SQLAlchemy repository
|
|
156
|
+
ConnectionPoolConfig, # pool settings
|
|
157
|
+
Repository, # abstract base — extend to add engines
|
|
158
|
+
FilterType, # EQUAL, LIKE, IN, NOT_IN, etc.
|
|
159
|
+
MultiTenantPoolManager, # one pool per tenant DSN
|
|
160
|
+
MultiTenantDbDependency, # FastAPI dep — resolves tenant DB from JWT
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Single-tenant setup:**
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from paper.core.db import Postgres, ConnectionPoolConfig
|
|
168
|
+
|
|
169
|
+
db = Postgres(
|
|
170
|
+
connection_string = config.POSTGRES_CONN_STRING,
|
|
171
|
+
config = ConnectionPoolConfig(
|
|
172
|
+
future=True, size=10, max_overflow=5,
|
|
173
|
+
recycle_after=3600, timeout=30, pre_ping=True,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Filtering:**
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
results = await db.retrieve(
|
|
182
|
+
UserEntity, UserModel,
|
|
183
|
+
filter={FilterType.EQUAL: {"is_active": True}},
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### `paper.core.security`
|
|
190
|
+
|
|
191
|
+
RSA-OAEP encryption and random code generation.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from paper.core.security import Crypto, RSACrypto, Hasher, Pem, Encoding
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
# Static utility (key passed per call)
|
|
199
|
+
cipher = Crypto.encrypt_urlsafe(dsn, public_key_b64)
|
|
200
|
+
dsn = Crypto.decrypt_urlsafe(cipher, private_key_b64)
|
|
201
|
+
|
|
202
|
+
# Instance-based (keys injected once)
|
|
203
|
+
crypto = RSACrypto(public_key_b64, private_key_b64)
|
|
204
|
+
cipher = crypto.encrypt_urlsafe(dsn)
|
|
205
|
+
|
|
206
|
+
# Random codes (invites, resets)
|
|
207
|
+
code = Hasher.generate(8) # e.g. "aB3xZ9mK"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Encoding PEM keys for `.env` storage:**
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
openssl genrsa -out private.pem 2048
|
|
214
|
+
openssl rsa -in private.pem -pubout -out public.pem
|
|
215
|
+
base64 -w 0 private.pem # → ENCRYPTION_PRIVATE_KEY
|
|
216
|
+
base64 -w 0 public.pem # → ENCRYPTION_PUBLIC_KEY
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Or with `Pem`:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from paper.core.security import Pem
|
|
223
|
+
print(Pem.to_base64("private.pem"))
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
### `paper.core.middleware`
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from paper.core.middleware import (
|
|
232
|
+
HipaaResponseHeaders, # X-Frame-Options, CSP, HSTS, etc.
|
|
233
|
+
RequestIdMiddleware, # X-Request-ID on every request
|
|
234
|
+
RequestLoggingMiddleware, # structured request/response logging
|
|
235
|
+
)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Registration order in `main.py`:**
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
app.add_middleware(CORSMiddleware, ...)
|
|
242
|
+
app.add_middleware(HipaaResponseHeaders)
|
|
243
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
244
|
+
app.add_middleware(RequestIdMiddleware) # executes first
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Access the request ID downstream:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
request.state.request_id
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
### `paper.core.errors`
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from paper.core.errors import ErrorHandler, ErrorMessage
|
|
259
|
+
|
|
260
|
+
ErrorHandler.handle(404, f"{ErrorMessage.NOT_FOUND.value} user {id}")
|
|
261
|
+
ErrorHandler.handle(401, "Unauthorized", {"WWW-Authenticate": "Bearer"})
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
All service layers raise through `ErrorHandler` — never construct `HTTPException` directly.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
### `paper.core.audit`
|
|
269
|
+
|
|
270
|
+
Generic audit logger. Accepts any DB, entity, and model — no framework-level schema dependency.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from paper.core.audit import Audit, AuditAction, AuditOutcome
|
|
274
|
+
|
|
275
|
+
audit = Audit(db=db, entity=AuditLogEntity, model=AuditLogModel)
|
|
276
|
+
|
|
277
|
+
await audit.log(
|
|
278
|
+
event_type = LoginEvent.SUCCESS,
|
|
279
|
+
action = AuditAction.ATTEMPT,
|
|
280
|
+
outcome = AuditOutcome.SUCCESS,
|
|
281
|
+
email = credentials.email,
|
|
282
|
+
ip_address = request.client.host,
|
|
283
|
+
user_agent = request.headers.get("user-agent"),
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Audit failures are swallowed silently and logged — they never block the calling operation.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
### `paper.core.email`
|
|
292
|
+
|
|
293
|
+
SMTP email with framework lifecycle templates and themeable HTML.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from paper.core.email import (
|
|
297
|
+
Server, Info, Body, Message,
|
|
298
|
+
Subject, EmailTheme, EmailBodyParam,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
server = Server(host, port, username, password)
|
|
302
|
+
sender = Info("My App", "noreply@myapp.com")
|
|
303
|
+
|
|
304
|
+
body = Body(
|
|
305
|
+
subject = Subject.RESET_PASSWORD,
|
|
306
|
+
data = {
|
|
307
|
+
EmailBodyParam.REDIRECT_URL.value: reset_url,
|
|
308
|
+
EmailBodyParam.RESET_CODE.value: code,
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
msg = Message(
|
|
313
|
+
sender = sender,
|
|
314
|
+
recipient = Info(user.name, user.email),
|
|
315
|
+
subject = Subject.RESET_PASSWORD.value,
|
|
316
|
+
text = body.text,
|
|
317
|
+
html = body.html,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
server.send(msg)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Theming via environment variables:**
|
|
324
|
+
|
|
325
|
+
```env
|
|
326
|
+
EMAIL_THEME_PRIMARY_COLOR=#0057a8
|
|
327
|
+
EMAIL_THEME_BUTTON_COLOR=#0057a8
|
|
328
|
+
EMAIL_THEME_LOGO_URL=https://cdn.myapp.com/logo.png
|
|
329
|
+
EMAIL_THEME_COMPANY_NAME=My App
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Extending Core
|
|
335
|
+
|
|
336
|
+
All major components are built on abstract base classes — extend them to swap implementations without changing the calling code.
|
|
337
|
+
|
|
338
|
+
### Custom Database Engine
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
from paper.core.db.base import Repository, FilterType
|
|
342
|
+
|
|
343
|
+
class MySQLRepository(Repository[T, M]):
|
|
344
|
+
async def create(self, entity, model, data): ...
|
|
345
|
+
async def retrieve(self, entity, model, filter=None): ...
|
|
346
|
+
async def single(self, entity, model, id): ...
|
|
347
|
+
async def update(self, entity, model, id, data): ...
|
|
348
|
+
async def delete(self, entity, id): ...
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Custom Encryption Algorithm
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from paper.core.security.base import BaseCrypto
|
|
355
|
+
|
|
356
|
+
class AESCrypto(BaseCrypto):
|
|
357
|
+
def encrypt(self, value: str) -> str: ...
|
|
358
|
+
def decrypt(self, cipher: str) -> str: ...
|
|
359
|
+
def encrypt_urlsafe(self, value: str) -> str: ...
|
|
360
|
+
def decrypt_urlsafe(self, cipher: str) -> str: ...
|
|
361
|
+
def encrypt_raw(self, value: str) -> bytes: ...
|
|
362
|
+
def decrypt_raw(self, cipher_bytes: bytes) -> str: ...
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Custom Email Provider
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
from paper.core.email.base import BaseEmailService
|
|
369
|
+
|
|
370
|
+
class SendGridEmailService(BaseEmailService):
|
|
371
|
+
def send(self, subject, recipient_name, recipient_email, data) -> bool: ...
|
|
372
|
+
```
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# paper-core
|
|
2
|
+
|
|
3
|
+
Runtime library for the PaperDraft framework. Provides auth, database, encryption, email, middleware, error handling, and audit logging — all importable under the `paper.core` namespace.
|
|
4
|
+
|
|
5
|
+
> **Status:** pre-release · v0.1 in progress · Paper Plane Consulting LLC
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Installation](#installation)
|
|
12
|
+
2. [Local Development](#local-development)
|
|
13
|
+
3. [Running Tests](#running-tests)
|
|
14
|
+
4. [Module Reference](#module-reference)
|
|
15
|
+
5. [Extending Core](#extending-core)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install paper-core
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Requirements:** Python 3.11+
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Local Development
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone <repo-url>
|
|
33
|
+
cd paper-core
|
|
34
|
+
|
|
35
|
+
python -m venv .venv
|
|
36
|
+
source .venv/bin/activate # macOS/Linux
|
|
37
|
+
.venv\Scripts\activate # Windows
|
|
38
|
+
|
|
39
|
+
pip install -e ".[dev]"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The `-e .` (editable install) is required — it registers the `paper` namespace package so that `from paper.core.X import Y` resolves correctly.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Running Tests
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pytest
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Run a specific module:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pytest tests/test_auth.py -v
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Test suite coverage
|
|
59
|
+
|
|
60
|
+
| File | Covers |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `test_errors.py` | `ErrorHandler`, `ErrorMessage` |
|
|
63
|
+
| `test_security.py` | `Hasher`, `Crypto`, `RSACrypto`, `Pem`, `Encoding` |
|
|
64
|
+
| `test_audit.py` | `Audit`, `AuditAction`, `AuditOutcome` |
|
|
65
|
+
| `test_auth.py` | `Credentials`, `Signature`, `Password`, `Auth`, `Key`, `Claims`, `Authenticate`, `Authorize`, `LoginAttemptLimit` |
|
|
66
|
+
| `test_middleware.py` | `HipaaResponseHeaders`, `RequestIdMiddleware`, `RequestLoggingMiddleware` |
|
|
67
|
+
| `test_db.py` | `FilterType`, `ConnectionPoolConfig`, `_relationship_fields`, `MultiTenantPoolManager` |
|
|
68
|
+
| `test_email.py` | `EmailTheme`, `Info`, `Body`, `Message`, `Subject`, `MimeType` |
|
|
69
|
+
|
|
70
|
+
> **Note:** `Postgres` CRUD tests (create, retrieve, update, delete) require a live PostgreSQL connection and are not included. Run those as integration tests against a test database.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Module Reference
|
|
75
|
+
|
|
76
|
+
### `paper.core.auth`
|
|
77
|
+
|
|
78
|
+
JWT signing, password hashing, and FastAPI authentication dependencies.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from paper.core.auth import (
|
|
82
|
+
Auth, # signs JWT access + refresh token pairs
|
|
83
|
+
Password, # Argon2 hash and verify
|
|
84
|
+
Authenticate, # FastAPI dep — validates JWT from header or cookie
|
|
85
|
+
Authorize, # FastAPI dep — validates JWT + enforces RBAC roles
|
|
86
|
+
LoginAttemptLimit, # FastAPI dep — rate limits login attempts per IP
|
|
87
|
+
set_auth_params, # call once at startup
|
|
88
|
+
set_login_attempt_params,
|
|
89
|
+
Claims, Key, # token decode utilities
|
|
90
|
+
Credentials, Signature, # request/response models
|
|
91
|
+
Algorithm, TokenType, ClaimsKey, AuthErrorMessage,
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Startup wiring (in `dependencies.py`):**
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from paper.core.auth import set_auth_params, set_login_attempt_params
|
|
99
|
+
from paper.core.auth.enums import Algorithm
|
|
100
|
+
from datetime import timedelta
|
|
101
|
+
|
|
102
|
+
set_auth_params({
|
|
103
|
+
"public_key": config.ENCRYPTION.PUBLIC_KEY,
|
|
104
|
+
"excluded_paths": ["/auth/login", "/auth/refresh", "/health"],
|
|
105
|
+
"alg": Algorithm.RS256,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
set_login_attempt_params(max_attempts=5, lockout_duration=timedelta(minutes=15))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Route usage:**
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from paper.core.auth import Authenticate, Authorize
|
|
115
|
+
|
|
116
|
+
@router.get("/me")
|
|
117
|
+
async def me(claims = Depends(Authenticate())):
|
|
118
|
+
return claims
|
|
119
|
+
|
|
120
|
+
@router.delete("/{id}")
|
|
121
|
+
async def delete(claims = Depends(Authorize(["admin"]))):
|
|
122
|
+
...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### `paper.core.db`
|
|
128
|
+
|
|
129
|
+
Async SQLAlchemy PostgreSQL repository with optional multi-tenant pool management.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from paper.core.db import (
|
|
133
|
+
Postgres, # async SQLAlchemy repository
|
|
134
|
+
ConnectionPoolConfig, # pool settings
|
|
135
|
+
Repository, # abstract base — extend to add engines
|
|
136
|
+
FilterType, # EQUAL, LIKE, IN, NOT_IN, etc.
|
|
137
|
+
MultiTenantPoolManager, # one pool per tenant DSN
|
|
138
|
+
MultiTenantDbDependency, # FastAPI dep — resolves tenant DB from JWT
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Single-tenant setup:**
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from paper.core.db import Postgres, ConnectionPoolConfig
|
|
146
|
+
|
|
147
|
+
db = Postgres(
|
|
148
|
+
connection_string = config.POSTGRES_CONN_STRING,
|
|
149
|
+
config = ConnectionPoolConfig(
|
|
150
|
+
future=True, size=10, max_overflow=5,
|
|
151
|
+
recycle_after=3600, timeout=30, pre_ping=True,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Filtering:**
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
results = await db.retrieve(
|
|
160
|
+
UserEntity, UserModel,
|
|
161
|
+
filter={FilterType.EQUAL: {"is_active": True}},
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### `paper.core.security`
|
|
168
|
+
|
|
169
|
+
RSA-OAEP encryption and random code generation.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from paper.core.security import Crypto, RSACrypto, Hasher, Pem, Encoding
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Static utility (key passed per call)
|
|
177
|
+
cipher = Crypto.encrypt_urlsafe(dsn, public_key_b64)
|
|
178
|
+
dsn = Crypto.decrypt_urlsafe(cipher, private_key_b64)
|
|
179
|
+
|
|
180
|
+
# Instance-based (keys injected once)
|
|
181
|
+
crypto = RSACrypto(public_key_b64, private_key_b64)
|
|
182
|
+
cipher = crypto.encrypt_urlsafe(dsn)
|
|
183
|
+
|
|
184
|
+
# Random codes (invites, resets)
|
|
185
|
+
code = Hasher.generate(8) # e.g. "aB3xZ9mK"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Encoding PEM keys for `.env` storage:**
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
openssl genrsa -out private.pem 2048
|
|
192
|
+
openssl rsa -in private.pem -pubout -out public.pem
|
|
193
|
+
base64 -w 0 private.pem # → ENCRYPTION_PRIVATE_KEY
|
|
194
|
+
base64 -w 0 public.pem # → ENCRYPTION_PUBLIC_KEY
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Or with `Pem`:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from paper.core.security import Pem
|
|
201
|
+
print(Pem.to_base64("private.pem"))
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
### `paper.core.middleware`
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from paper.core.middleware import (
|
|
210
|
+
HipaaResponseHeaders, # X-Frame-Options, CSP, HSTS, etc.
|
|
211
|
+
RequestIdMiddleware, # X-Request-ID on every request
|
|
212
|
+
RequestLoggingMiddleware, # structured request/response logging
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Registration order in `main.py`:**
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
app.add_middleware(CORSMiddleware, ...)
|
|
220
|
+
app.add_middleware(HipaaResponseHeaders)
|
|
221
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
222
|
+
app.add_middleware(RequestIdMiddleware) # executes first
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Access the request ID downstream:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
request.state.request_id
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### `paper.core.errors`
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from paper.core.errors import ErrorHandler, ErrorMessage
|
|
237
|
+
|
|
238
|
+
ErrorHandler.handle(404, f"{ErrorMessage.NOT_FOUND.value} user {id}")
|
|
239
|
+
ErrorHandler.handle(401, "Unauthorized", {"WWW-Authenticate": "Bearer"})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
All service layers raise through `ErrorHandler` — never construct `HTTPException` directly.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
### `paper.core.audit`
|
|
247
|
+
|
|
248
|
+
Generic audit logger. Accepts any DB, entity, and model — no framework-level schema dependency.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from paper.core.audit import Audit, AuditAction, AuditOutcome
|
|
252
|
+
|
|
253
|
+
audit = Audit(db=db, entity=AuditLogEntity, model=AuditLogModel)
|
|
254
|
+
|
|
255
|
+
await audit.log(
|
|
256
|
+
event_type = LoginEvent.SUCCESS,
|
|
257
|
+
action = AuditAction.ATTEMPT,
|
|
258
|
+
outcome = AuditOutcome.SUCCESS,
|
|
259
|
+
email = credentials.email,
|
|
260
|
+
ip_address = request.client.host,
|
|
261
|
+
user_agent = request.headers.get("user-agent"),
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Audit failures are swallowed silently and logged — they never block the calling operation.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### `paper.core.email`
|
|
270
|
+
|
|
271
|
+
SMTP email with framework lifecycle templates and themeable HTML.
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from paper.core.email import (
|
|
275
|
+
Server, Info, Body, Message,
|
|
276
|
+
Subject, EmailTheme, EmailBodyParam,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
server = Server(host, port, username, password)
|
|
280
|
+
sender = Info("My App", "noreply@myapp.com")
|
|
281
|
+
|
|
282
|
+
body = Body(
|
|
283
|
+
subject = Subject.RESET_PASSWORD,
|
|
284
|
+
data = {
|
|
285
|
+
EmailBodyParam.REDIRECT_URL.value: reset_url,
|
|
286
|
+
EmailBodyParam.RESET_CODE.value: code,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
msg = Message(
|
|
291
|
+
sender = sender,
|
|
292
|
+
recipient = Info(user.name, user.email),
|
|
293
|
+
subject = Subject.RESET_PASSWORD.value,
|
|
294
|
+
text = body.text,
|
|
295
|
+
html = body.html,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
server.send(msg)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Theming via environment variables:**
|
|
302
|
+
|
|
303
|
+
```env
|
|
304
|
+
EMAIL_THEME_PRIMARY_COLOR=#0057a8
|
|
305
|
+
EMAIL_THEME_BUTTON_COLOR=#0057a8
|
|
306
|
+
EMAIL_THEME_LOGO_URL=https://cdn.myapp.com/logo.png
|
|
307
|
+
EMAIL_THEME_COMPANY_NAME=My App
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Extending Core
|
|
313
|
+
|
|
314
|
+
All major components are built on abstract base classes — extend them to swap implementations without changing the calling code.
|
|
315
|
+
|
|
316
|
+
### Custom Database Engine
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
from paper.core.db.base import Repository, FilterType
|
|
320
|
+
|
|
321
|
+
class MySQLRepository(Repository[T, M]):
|
|
322
|
+
async def create(self, entity, model, data): ...
|
|
323
|
+
async def retrieve(self, entity, model, filter=None): ...
|
|
324
|
+
async def single(self, entity, model, id): ...
|
|
325
|
+
async def update(self, entity, model, id, data): ...
|
|
326
|
+
async def delete(self, entity, id): ...
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Custom Encryption Algorithm
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
from paper.core.security.base import BaseCrypto
|
|
333
|
+
|
|
334
|
+
class AESCrypto(BaseCrypto):
|
|
335
|
+
def encrypt(self, value: str) -> str: ...
|
|
336
|
+
def decrypt(self, cipher: str) -> str: ...
|
|
337
|
+
def encrypt_urlsafe(self, value: str) -> str: ...
|
|
338
|
+
def decrypt_urlsafe(self, cipher: str) -> str: ...
|
|
339
|
+
def encrypt_raw(self, value: str) -> bytes: ...
|
|
340
|
+
def decrypt_raw(self, cipher_bytes: bytes) -> str: ...
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Custom Email Provider
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
from paper.core.email.base import BaseEmailService
|
|
347
|
+
|
|
348
|
+
class SendGridEmailService(BaseEmailService):
|
|
349
|
+
def send(self, subject, recipient_name, recipient_email, data) -> bool: ...
|
|
350
|
+
```
|
|
File without changes
|