Skip to content

SecretStr and SecretBytes fields save as '**********' and hydrate as plain str #760

Closed

Description

@0x054

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 with model_dump(mode="json"), which masks secrets by design. bulk_create uses the same function. update_bind_payload and canonicalize_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) builds cls._enum_fields, and apply_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) resolves SecretBytes to a string type. It should resolve to binary, the same as bytes.

The fix

  1. One unwrap rule for every write door. save(), bulk_create(), Query.update() and query literals bind get_secret_value() for a SecretStr / SecretBytes value, through one helper that save_bind_payload, update_bind_payload and canonicalize_wire_scalar all call (I-13: a query literal binds what save() wrote). An unwrapped SecretBytes takes the existing bytes path. A secret field binds its raw value even if the model declares a field serializer for it.
  2. 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). None stays None. A loaded instance must match one built with Account(token=SecretStr(...)) (I-2): same field types, and the same model_dump() and model_dump(mode="json") output.
  3. SecretBytes stores as binary. It resolves to the same storage type as bytes on both dialects, decided by _logical_type and pinned in tests/test_db_type_cross_emitter_parity.py alongside the other derived annotations (I-1). SecretStr keeps its string type.

Out of scope

Acceptance criteria

  • save(), bulk_create() and Query.update() store the raw value of SecretStr, SecretBytes, SecretStr | None and SecretBytes | None fields on SQLite and Postgres, checked by reading the column with raw SQL.
  • where(lambda account: account.token == SecretStr("hunter2")) matches the saved row, and where(lambda account: account.token == "hunter2") matches it too.
  • get, all, first and include hydrate secret fields as SecretStr / SecretBytes, and an optional secret field that is NULL hydrates as None.
  • A hydrated instance and one built with the constructor give equal model_dump() and model_dump(mode="json") output.
  • SecretBytes resolves to the binary storage type on both dialects. The type parity test covers SecretStr and SecretBytes.
  • 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 in src/operations.rs if the conversion map's construction moves)
  • Modify: tests/test_db_type_cross_emitter_parity.py
  • Create: tests/test_secret_fields.py

Interfaces

Test scenarios

  • Happy path: save → raw SQL read → get round trip for each secret type, required and optional, on both dialects.
  • Happy path: bulk_create and update store the raw value.
  • Happy path: equality lookup with a SecretStr literal and with a plain string literal.
  • Edge case: an optional secret field saved as None stores NULL and hydrates as None.
  • Edge case: a secret field with a field_serializer still binds the raw value.
  • Edge case: hydrated vs constructed instance, compared by field type and by both model_dump modes.
  • 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:cacheprovider

Set FERRO_POSTGRES_URL so the Postgres cases run rather than skip.

Related

Activity

  1. added
    bugSomething isn't working
    needs-triageNeeds evaluation by a maintainer
    on Oct 20, 2026
  2. changed the title [-]SecretStr fields save as '**********' and hydrate as plain str[/-] [+]SecretStr and SecretBytes fields save as '**********' and hydrate as plain str[/+] on Oct 20, 2026
  3. added
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:MNeeds a plan; one or two local workers
    and removed
    needs-triageNeeds evaluation by a maintainer
    on Oct 20, 2026
  4. self-assigned this
    on Oct 21, 2026
  5. 0x054 commented on Oct 21, 2026

    @0x054
    ContributorAuthor

    Status: pr-open
    Worker: local worker (Claude)
    Branch: sdd/760-secretstr-secretbytes
    PR: #762
    Verify: passed dd0899b

    Verify ran against a local Postgres 18 (FERRO_POSTGRES_URL set; targeted lines also run with --db-backends=sqlite,postgres). cargo test --workspace doesn't link on macOS with the default extension-module feature, so it ran in CI's form (--no-default-features --features testing). Code review is done and its findings are addressed in dd0899b; the PR body has the details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingready-for-agentUnblocked and fully specified; an agent may claim itsize:MNeeds a plan; one or two local workers

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions