Phase 0 milestone 0.5: Registrant vertical slice
Registrant model + schemas + router + migration 0003, exercising the vendored framework end-to-end (CRUDService, RBAC, audit, version history). - SSN app-layer encrypted (AES-256-GCM, HKDF key, column-bound AAD, versioned format) in security/column_crypto.py; write-only on the model; serializer exposes only ssn_masked (XXX-XX-1234). C.5.7.50 / SC-28. - pii_read audit on GET, list (one row per page with ids), and history. C.5.6.9 #5 / AU-14. - Portal-only callers (YOUTH/PARENT/HOST) limited to own row via Registrant.user_id; out-of-scope is 404. PARENT_USER sees nothing until Guardian lands. History gated on VersionHistory, not View. - minor_flag derived from dob at read time. Framework changes (flag to OWC on re-vendor): - crud._to_dict honors __audit_redact__, keeping ciphertext out of audit_log and version_history. - crud._to_dict serializes date (not only datetime) for JSON columns. - CRUDService.history annotation uses builtins.list (method shadowed it). 98 tests passing, 94.83% coverage; ruff + mypy clean. Coverage trace at docs/coverage/registrant.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
185b5d2af3
commit
5dd828f0e5
15 files changed
+1098
-3
No files matched your search
@@ -0,0 +1,108 @@
|
||||
"""Registrant request/response schemas.
|
||||
|
||||
The read schema has no `ssn` field — only `ssn_masked` (``XXX-XX-1234``).
|
||||
Masking happens here in the serializer, not the UI, so every API consumer sees
|
||||
the masked form (ARCHITECTURE.md §6.3; source RFP `C.5.7.50`).
|
||||
|
||||
`user_id` is deliberately absent from the write schemas: linking a registrant
|
||||
to a portal account is a Phase 1 identity flow, not a PATCH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
_ZIP = r"^\d{5}(-\d{4})?$"
|
||||
|
||||
|
||||
def normalize_ssn(value: str | None) -> str | None:
|
||||
"""Accept ``123-45-6789`` or ``123456789``; return 9 digits.
|
||||
|
||||
Rejects numbers the SSA never issues: area 000, 666, or 900–999; group 00;
|
||||
serial 0000. Error messages never echo the input.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
digits = re.sub(r"[\s-]", "", value)
|
||||
if not re.fullmatch(r"\d{9}", digits):
|
||||
raise ValueError("SSN must be 9 digits")
|
||||
area, group, serial = digits[:3], digits[3:5], digits[5:]
|
||||
if area in {"000", "666"} or area >= "900" or group == "00" or serial == "0000":
|
||||
raise ValueError("SSN is not a valid issued number")
|
||||
return digits
|
||||
|
||||
|
||||
def _not_future(value: date | None) -> date | None:
|
||||
if value is not None and value > date.today():
|
||||
raise ValueError("dob cannot be in the future")
|
||||
return value
|
||||
|
||||
|
||||
class RegistrantCreate(BaseModel):
|
||||
first_name: str = Field(min_length=1, max_length=100)
|
||||
middle_name: str | None = Field(default=None, max_length=100)
|
||||
last_name: str = Field(min_length=1, max_length=100)
|
||||
dob: date
|
||||
ssn: str | None = None
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(default=None, max_length=32)
|
||||
address_line1: str | None = Field(default=None, max_length=255)
|
||||
address_line2: str | None = Field(default=None, max_length=255)
|
||||
city: str | None = Field(default=None, max_length=100)
|
||||
state: str | None = Field(default=None, min_length=2, max_length=2)
|
||||
zip: str | None = Field(default=None, pattern=_ZIP)
|
||||
|
||||
_ssn = field_validator("ssn")(normalize_ssn)
|
||||
_dob = field_validator("dob")(_not_future)
|
||||
|
||||
|
||||
class RegistrantUpdate(BaseModel):
|
||||
first_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
middle_name: str | None = Field(default=None, max_length=100)
|
||||
last_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
dob: date | None = None
|
||||
ssn: str | None = None
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(default=None, max_length=32)
|
||||
address_line1: str | None = Field(default=None, max_length=255)
|
||||
address_line2: str | None = Field(default=None, max_length=255)
|
||||
city: str | None = Field(default=None, max_length=100)
|
||||
state: str | None = Field(default=None, min_length=2, max_length=2)
|
||||
zip: str | None = Field(default=None, pattern=_ZIP)
|
||||
|
||||
_ssn = field_validator("ssn")(normalize_ssn)
|
||||
_dob = field_validator("dob")(_not_future)
|
||||
|
||||
@field_validator("first_name", "last_name", "dob")
|
||||
@classmethod
|
||||
def _required_not_null(cls, v: object) -> object:
|
||||
# Omit to leave unchanged; explicit null would violate NOT NULL.
|
||||
if v is None:
|
||||
raise ValueError("cannot be null")
|
||||
return v
|
||||
|
||||
|
||||
class RegistrantRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
first_name: str
|
||||
middle_name: str | None
|
||||
last_name: str
|
||||
dob: date
|
||||
ssn_masked: str | None
|
||||
minor_flag: bool
|
||||
email: str | None
|
||||
phone: str | None
|
||||
address_line1: str | None
|
||||
address_line2: str | None
|
||||
city: str | None
|
||||
state: str | None
|
||||
zip: str | None
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None
|
||||
Reference in new issue
Block a user