feat: scaffold Django multi-tenant project with 5 of 9 apps
Phase 1 scaffolding: config/, core/, base models, AES-256-GCM phone encryption, enums mirror apps.tenant: Tenant + Domain (django-tenants) apps.org: 11 models (OrgUnit hierarchy, Staff, audit logs) apps.account: 4 models (UserAccount as AUTH_USER_MODEL, login/password tracking) apps.permission: 7 models (RBAC + overrides + datascope + append-only changelog) apps.region: 5 models (District, BusinessArea, MetroLine, MetroStation, School) All migrations generated, manage.py check passes
This commit is contained in:
57
core/encryption.py
Normal file
57
core/encryption.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""PII encryption per AGENTS.md §4.4: AES-256-GCM (NOT Fernet)."""
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class PhoneEncryption:
|
||||
"""Encrypt phone numbers with AES-256-GCM, index via SHA-256 hash, display masked.
|
||||
|
||||
Storage: phone_encrypted = base64(nonce || ciphertext || tag)
|
||||
Index: phone_hash = sha256(plaintext) — used for equality lookups
|
||||
Display: 138****1234
|
||||
"""
|
||||
|
||||
NONCE_LEN = 12
|
||||
|
||||
@staticmethod
|
||||
def _key() -> bytes:
|
||||
key_b64 = settings.PHONE_ENCRYPTION_KEY
|
||||
if not key_b64:
|
||||
raise RuntimeError(
|
||||
"PHONE_ENCRYPTION_KEY is not configured. "
|
||||
"Generate with: python -c 'import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())'"
|
||||
)
|
||||
key = base64.b64decode(key_b64)
|
||||
if len(key) != 32:
|
||||
raise RuntimeError("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes (AES-256).")
|
||||
return key
|
||||
|
||||
@classmethod
|
||||
def encrypt(cls, phone: str) -> str:
|
||||
if phone is None:
|
||||
raise ValueError("phone cannot be None")
|
||||
aesgcm = AESGCM(cls._key())
|
||||
nonce = os.urandom(cls.NONCE_LEN)
|
||||
ct = aesgcm.encrypt(nonce, phone.encode("utf-8"), None)
|
||||
return base64.b64encode(nonce + ct).decode("ascii")
|
||||
|
||||
@classmethod
|
||||
def decrypt(cls, ciphertext_b64: str) -> str:
|
||||
raw = base64.b64decode(ciphertext_b64)
|
||||
nonce, ct = raw[: cls.NONCE_LEN], raw[cls.NONCE_LEN :]
|
||||
aesgcm = AESGCM(cls._key())
|
||||
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def hash(phone: str) -> str:
|
||||
return hashlib.sha256(phone.encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def mask(phone: str) -> str:
|
||||
if not phone or len(phone) < 7:
|
||||
return "***"
|
||||
return phone[:3] + "****" + phone[-4:]
|
||||
Reference in New Issue
Block a user