Skip to content

Add value_type= to core columns; HashedPassword replaces PasswordField - #131

Draft
davegaeddert wants to merge 9 commits into
plain-formsfrom
value-type-hook
Draft

davegaeddert wants to merge 9 commits into
plain-formsfrom
value-type-hook

Conversation

@davegaeddert

Copy link
Copy Markdown
Member

Branches off plain-forms (#128). Base is plain-forms, so this diff also carries a merge of origin/masterplain-forms had not yet picked up #83, which this change is built on.

What this does

A core column constructor can now be handed a Python type it round-trips through:

password: Field[HashedPassword] = types.TextField(value_type=HashedPassword)

That lets a package or an app own a value's behavior without subclassing a field class — the one extension axis typed construction can support, because PEP 681 only honors field specifiers plain.postgres itself declares. plain-passwords is the first customer: PasswordField is deleted, and password becomes a required constructor argument for the first time. Under PasswordField, User(email=...) type-checked against a NOT NULL column — the one known hole #83 documented. That caveat is now gone from the docs because the hole is gone.

The protocol

class ValueType(Protocol):
    @classmethod
    def from_db(cls, raw: Any, /) -> Self: ...
    def to_db(self) -> Any: ...

Two methods, nothing else. Validation lives at whatever boundary constructs the value, not in the protocol — for a password that is the form field, the only place the raw input still exists.

What the hook guarantees

  • Round trip. A read rebuilds the value after the column's own converter (so a JSON column decodes first, then builds); a write unwraps it.
  • Refusal. Anything that isn't an instance of the declared type is a TypeError naming the field. One refusal in get_prep_value covers every path in: instance.update(), QuerySet.update(col="raw"), and filter(col="raw").
  • Strict, symmetric descriptor. Field[X] both gets and sets an X. Assigning a primitive is a type error at the call site as well as a TypeError at write time. There is no lenient setter, so "is this already hashed?" can't be asked — today's pre_save format sniff and write-back cease to exist rather than moving.
  • Invisible to migrations. The column is still text / jsonb, and value_type stays out of deconstruct(), so no migration file imports the type and changing one generates no migration.
  • Typed as Field[X], not the text-field subtype, so text-only condition methods don't leak onto an opaque value. (When the typed query API lands — [1b] Add typed where() with field-method conditions #84 — a value-typed field's condition set comes from X, not from str.)
  • Refused at the form boundary. model_field(Model.column) on a value-typed column raises rather than deriving a TextField that would hand the model a raw string.

plain-passwords

HashedPassword wraps the encoded hash: from_raw() hashes, check(raw) verifies, needs_rehash() reports a stale hasher, str() is the encoded hash, and repr() is deliberately opaque (<HashedPassword>) so a hash can't fall into a log line. validate_raw_password() — the three shipped rules, moved off the field — is what the new NewPasswordField form field runs before hashing.


Upgrade instructions

1. Model annotation

-from plain.passwords.types import PasswordField   # or plain.passwords.models
+from plain.passwords.values import HashedPassword

 @postgres.register_model
 class User(postgres.Model):
     email: Field[str] = types.EmailField()
-    password: Field[str] = PasswordField()
+    password: Field[HashedPassword] = types.TextField(value_type=HashedPassword)

plain.passwords.models and plain.passwords.types are deleted. There is no compatibility shim.

2. Migration files

Historical migrations reference PasswordField by import path and will fail to load. Rewrite each one to the plain text column it always was:

-import plain.passwords.models
-import plain.passwords.validators
-
-                (
-                    "password",
-                    plain.passwords.models.PasswordField(
-                        validators=[
-                            plain.passwords.validators.MinimumLengthValidator(),
-                            plain.passwords.validators.CommonPasswordValidator(),
-                            plain.passwords.validators.NumericPasswordValidator(),
-                        ]
-                    ),
-                ),
+                ("password", postgres.TextField()),

PasswordField.deconstruct() dropped its own max_length=128, so postgres.TextField() is the exact equivalent — the column definition is unchanged, no new migration is generated, and no data moves. (Writing max_length=128 here would generate a spurious AlterField, since the model no longer declares one.)

3. Writing a password

A raw string no longer hashes itself on save — it raises.

-user.password = raw_password
+user.password = HashedPassword.from_raw(raw_password)
 user.update()
-User.query.create(email=email, password=raw_password)
+User.query.create(email=email, password=HashedPassword.from_raw(raw_password))

Anywhere a password comes from user input, prefer routing it through the form layer (NewPasswordField) so the raw value is validated before it's hashed.

4. Reading a password

user.password is a HashedPassword, not a str. Anything that displays, serializes, or signs it needs the encoded form explicitly:

-signer.sign_object({"password": user.password})
+signer.sign_object({"password": str(user.password)})

Comparisons are better done with the value type than with strings:

-hmac.compare_digest(force_bytes(user.password), force_bytes(stored))
+user.password == HashedPassword(stored)      # constant-time via __eq__
-check_password(raw, user.password)
+user.password.check(raw)

5. Form fields

PasswordSetForm / PasswordChangeForm renamed new_password1 / new_password2 to new_password / confirm_password, matching the signup form's pairing. new_password cleans to a HashedPassword; confirm_password stays raw, because two hashes of the same password have different salts and can never be compared. Update any overridden templates.

6. Removed helpers

  • get_password_errors(user, password, field=) — gone. The password form field validates the raw value itself, so the error attaches to the field automatically.
  • set_user_password(user, password) now takes a HashedPassword, not a raw string.
  • check_user_password(user, raw) is unchanged in signature and still rehashes on a stale hasher — it just uses HashedPassword.check() / .needs_rehash() to do it.

Brings in #83 (typed model construction via @dataclass_transform), which
this branch builds on. plain-forms had not yet merged it.

Conflict in test_db_expression_defaults.py resolved in favor of the
plain-forms side: model_to_dict/construct_instance no longer exist.
#83 gave every model column a `Field[T]` annotation, which is what
synthesizes the typed constructor. That made the `ColumnField[T]` overload
on `model_field()` stop matching -- a column reference is typed by its
annotation, so every `model_field(Model.column)` fell through to the
`Any` overload and nothing downstream type-checked. Match the base
`Field[T]` instead.

Also narrows an optional foreign key in the example suite, which the same
annotations made visible.
A column constructor can now be handed a Python type it round-trips
through, so a package or an app owns a value's behavior without
subclassing a field class -- which PEP 681 can't see, because only
plain.postgres may declare field specifiers.

The `ValueType` protocol is two methods: `from_db(raw) -> Self` and
`to_db() -> raw`. Reads rebuild the value after the column's own
converter, writes unwrap it, and anything that isn't an instance of the
declared type is a TypeError naming the field -- one refusal covering
instance writes, `QuerySet.update()`, and `filter()` alike.

The column is unchanged: still text, still jsonb, and `value_type` stays
out of `deconstruct()` so no migration file ever imports the type.

The form layer refuses to derive a field from a value-typed column;
only the package owning the type knows how to parse raw input into one.
The stub's `value_type=` overloads come first so they win when the kwarg
is present, and they return the base `Field[X]` rather than the
text-field subtype: the column stores text, but the value is opaque, so
text-only condition methods must not leak onto it. (When the typed query
API lands the condition set comes from X, not from str.)

Everything #83 established still holds -- the field is required in the
constructor unless the call passes `default=`, `allow_null=True` gives
`Field[X | None]`, and the descriptor is symmetric, so assigning a raw
primitive is a call-site type error as well as a write-time TypeError.

The tests pin the static facts the way #83's do: a `ty: ignore` that
would be reported as unused -- and fail the build -- if the fact ever
changed.
A password is now a value type a core column carries --
`types.TextField(value_type=HashedPassword)` -- rather than a field
subclass. Because the constructor is a core one, typed construction
finally treats `password` as required; `PasswordField` could never be
seen by PEP 681, so `User(email=...)` type-checked against a NOT NULL
column.

HashedPassword wraps the encoded hash: `from_raw()` hashes,
`check()` verifies, `needs_rehash()` reports a stale hasher, and
`__repr__` is opaque so a hash can't fall into a log line. It does not
validate raw passwords -- `validate_raw_password()` (the three shipped
rules, moved off the field) is what the form layer runs.

The format sniffing goes away rather than moving. With a strict value
type the question 'is this already hashed?' cannot come up: the only
way to get a HashedPassword is to hash one.
The password form field is where a raw password stops. It validates and
hashes, so the views lose their separate `get_password_errors()` pass --
a rule failure now attaches to the field that read the raw value.

The set/change forms' `new_password1`/`new_password2` become
`new_password` (a HashedPassword) and `confirm_password` (raw), matching
the signup form's pairing. A confirmation can't be compared hash to hash
-- two hashes of the same password have different salts -- so the check
is `new_password.check(confirm_password)`.

The reset token carries `str(user.password)` and its staleness check is
now a HashedPassword comparison (constant time, via `__eq__`).
plain.auth's session hash str()s the field explicitly, since it may hold
a value type.

Example and test-app models, their migrations, and every fixture that
constructed a user with a raw string follow.
The annotated-model rule and the README both carried a caveat that a
field type declared outside plain.postgres -- PasswordField, or one of
your own -- is always optional in the constructor even when the column
is NOT NULL. That hole is closed: the set of field classes is closed and
`value_type=` is the extension axis instead, so the caveat and the
`PasswordMixin` + `@dataclass_transform` workaround it pointed at are
both deleted.

A new 'Value types' section documents the protocol, the strict
descriptor, and the fact that migrations never see the kwarg. Every
README that showed `PasswordField` now shows the value-typed column.

The stub's value_type overloads also drop `max_length` and `choices`:
both are Python-side checks against the value, so neither means anything
applied to an opaque one, and leaving them out makes the combination a
declaration-site type error rather than a crash in full_clean().

Mirrors regenerated with `plain agent install`.
The README described a field class that no longer exists. It now leads
with the value-typed column, documents HashedPassword's surface
(from_raw / check / needs_rehash / str / opaque repr / constant-time
==), and states the two things that follow from it being an ordinary
field declaration: password is required in the typed constructor, and
the column is still text.

Password validation leads with validate_raw_password(); the hashing
section shows the check/needs_rehash rehash pair instead of the old
setter callback (which wrote a raw string into the column and would now
raise); the customization FAQ subclasses NewPasswordField rather than
passing validators= to a field that's gone. The login template snippet
was also on the dead forms API and now uses field_value/field_errors.

The admin value template moves from PasswordField.html to
HashedPassword.html -- plain.admin resolves by database-field-type name
and then by the value's MRO, so the value type is where it belongs now
-- and reads the encoded form through str() instead of treating the
value as one.

The rewritten migration files drop max_length: PasswordField's own
deconstruct() stripped it, so TextField() is the exact equivalent and
keeping it would generate a spurious AlterField.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant