Repository navigation
SecretStr and SecretBytes fields save as '**********' and hydrate as plain str #760
Copy link
Copy link
Closed
Labels
bugSomething isn't workingSomething isn't workingready-for-agentUnblocked and fully specified; an agent may claim itUnblocked and fully specified; an agent may claim itsize:MNeeds a plan; one or two local workersNeeds a plan; one or two local workers
Description
What to build
SecretStr and SecretBytes are display protection only: they keep a value out of repr, tracebacks and logs. The database should store the real value, and a loaded record should hold the secret type again. Today Ferro does neither:
class Account(Model):
id: Annotated[int, Field(primary_key=True)]
token: SecretStr
blob: SecretBytes | None = None
await Account(id=1, token=SecretStr("hunter2"), blob=SecretBytes(b"k1")).save()What each path does on SQLite today (checked by reading the file with sqlite3, not through Ferro):
| Path | Today |
|---|---|
save() |
stores '**********' in token and blob |
bulk_create() |
stores '**********' |
Query.update(token=SecretStr("new")) |
stores '**********' |
where(lambda account: account.token == SecretStr("hunter2")) |
raises TypeError: Object of type SecretStr is not JSON serializable |
where(lambda account: account.token == "**********") |
matches every row |
Account.get(1) |
token and blob come back as plain str |
CREATE TABLE |
blob is varchar, not the binary type a bytes field gets |
Postgres wasn't run, but the masking happens in Python before the dialect is involved, so writes there store the same mask.
Causes
- Writes.
save_bind_payload(src/ferro/_bind_payload.py:68) builds the row withmodel_dump(mode="json"), which masks secrets by design.bulk_createuses the same function.update_bind_payloadandcanonicalize_wire_scalar(used for query literals) don't know the secret types at all. - Reads. Hydration writes column values straight into the instance dict (I-2) and never applies the declared type. Enums have the same problem and already have the fix:
_register_enum_fields(src/ferro/metaclass.py:751) buildscls._enum_fields, andapply_decoded_fields(src/hydration.rs:225) calls the enum class on each value. - Column type. Pydantic's JSON schema gives both secret types
{"type": "string", "format": "password"}, so_logical_type(src/ferro/columns.py:316) resolvesSecretBytesto a string type. It should resolve tobinary, the same asbytes.
The fix
- One unwrap rule for every write door.
save(),bulk_create(),Query.update()and query literals bindget_secret_value()for aSecretStr/SecretBytesvalue, through one helper thatsave_bind_payload,update_bind_payloadandcanonicalize_wire_scalarall call (I-13: a query literal binds whatsave()wrote). An unwrappedSecretBytestakes the existing bytes path. A secret field binds its raw value even if the model declares a field serializer for it. - Hydration rewraps, following the enum precedent. The metaclass records each secret field's type, and the Rust hydration boundary wraps the decoded value with it. This should be a single per-field conversion step at the hydration boundary that enums and secrets share, not a second mechanism beside the enum one (I-6).
NonestaysNone. A loaded instance must match one built withAccount(token=SecretStr(...))(I-2): same field types, and the samemodel_dump()andmodel_dump(mode="json")output. SecretBytesstores as binary. It resolves to the same storage type asbyteson both dialects, decided by_logical_typeand pinned intests/test_db_type_cross_emitter_parity.pyalongside the other derived annotations (I-1).SecretStrkeeps its string type.
Out of scope
- Redacting secrets in SQL logs. That's SQL inspection and logging: print or log the SQL Ferro renders and runs for a query or mutation #700, which builds on this.
- Encrypting the stored value. That's Encrypted fields: encrypt on write and decrypt on read with a developer-supplied cipher #761.
- Recovering rows already saved as
'**********'. The real value was never written, so nothing can bring it back. The release notes should say so.
Acceptance criteria
-
save(),bulk_create()andQuery.update()store the raw value ofSecretStr,SecretBytes,SecretStr | NoneandSecretBytes | Nonefields on SQLite and Postgres, checked by reading the column with raw SQL. -
where(lambda account: account.token == SecretStr("hunter2"))matches the saved row, andwhere(lambda account: account.token == "hunter2")matches it too. -
get,all,firstandincludehydrate secret fields asSecretStr/SecretBytes, and an optional secret field that isNULLhydrates asNone. - A hydrated instance and one built with the constructor give equal
model_dump()andmodel_dump(mode="json")output. -
SecretBytesresolves to the binary storage type on both dialects. The type parity test coversSecretStrandSecretBytes. - Every write path calls one unwrap helper: there is no second copy of the secret-type check.
- The enum and secret hydration conversions share one per-field mechanism.
Files owned
- Modify:
src/ferro/_bind_payload.py,src/ferro/columns.py,src/ferro/metaclass.py,src/ferro/models.py,src/hydration.rs(and its caller insrc/operations.rsif the conversion map's construction moves) - Modify:
tests/test_db_type_cross_emitter_parity.py - Create:
tests/test_secret_fields.py
Interfaces
- Consumes: the enum hydration precedent (
cls._enum_fields,enum_classes_for,apply_decoded_fields). - Produces: a single place where secret fields are unwrapped for binding. SQL inspection and logging: print or log the SQL Ferro renders and runs for a query or mutation #700's redaction attaches there.
Test scenarios
- Happy path: save → raw SQL read →
getround trip for each secret type, required and optional, on both dialects. - Happy path:
bulk_createandupdatestore the raw value. - Happy path: equality lookup with a
SecretStrliteral and with a plain string literal. - Edge case: an optional secret field saved as
NonestoresNULLand hydrates asNone. - Edge case: a secret field with a
field_serializerstill binds the raw value. - Edge case: hydrated vs constructed instance, compared by field type and by both
model_dumpmodes. - Error path: a stored value the secret type rejects on hydration raises with the model and field named, the same as an enum value the enum class rejects.
Verify
uv run --no-sync pytest tests/test_secret_fields.py tests/test_db_type_cross_emitter_parity.py -q --no-cov -p no:cacheprovider
uv run --no-sync pytest tests/test_cross_emitter_parity.py tests/test_hydration.py tests/test_hydration_equivalence.py tests/test_enum_cold_hydration.py tests/test_schema_enum_annotations.py -q --no-cov -p no:cacheprovider
cargo test --workspace
uv run --no-sync pytest -q --no-cov -p no:cacheproviderSet FERRO_POSTGRES_URL so the Postgres cases run rather than skip.
Related
- Blocks SQL inspection and logging: print or log the SQL Ferro renders and runs for a query or mutation #700 (SQL inspection and logging): its parameter log must redact secrets, which needs the stored value to be right first.
- Encrypted fields: encrypt on write and decrypt on read with a developer-supplied cipher #761: encrypted fields, the storage-protection counterpart.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workingready-for-agentUnblocked and fully specified; an agent may claim itUnblocked and fully specified; an agent may claim itsize:MNeeds a plan; one or two local workersNeeds a plan; one or two local workers
Activity
[-]SecretStr fields save as '**********' and hydrate as plain str[/-][+]SecretStr and SecretBytes fields save as '**********' and hydrate as plain str[/+]0x054 commentedon Oct 21, 2026
Status: pr-open
Worker: local worker (Claude)
Branch:
sdd/760-secretstr-secretbytesPR: #762
Verify: passed dd0899b
Verify ran against a local Postgres 18 (
FERRO_POSTGRES_URLset; targeted lines also run with--db-backends=sqlite,postgres).cargo test --workspacedoesn't link on macOS with the defaultextension-modulefeature, so it ran in CI's form (--no-default-features --features testing). Code review is done and its findings are addressed indd0899b; the PR body has the details.fix: keep secret defaults out of the schema; address review (#760)
fix: SecretStr and SecretBytes fields store the real value and hydrat…
docs: every page one kind, every block runs, the SQL beside every que…