1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
//! Fields that must never reach a client, on any transport.
//!
//! # Why this lives in core and not in a plugin
//!
//! It used to live in `umbral-rest` (`HARD_DENIED_FIELDS`), which meant `password_hash` was
//! safe only if you happened to have mounted REST. `umbral-openapi` had to reach across and
//! call `umbral_rest::is_hidden` to stay consistent with it — a dependency between two
//! *optional, swappable* plugins, which is the shape of a rule living in the wrong place.
//! And `umbral-graphql`, added later, inherited none of it: it exposed every column of every
//! model it was pointed at, so `.expose("auth_user")` would have served password hashes.
//!
//! Secrecy is a property of the **data**, not of the transport. `password_hash` is not
//! confidential because of which door you walk through to reach it. So the rule belongs at
//! the centre, where every plugin — including ones nobody has written yet — inherits it
//! without having to remember to.
//!
//! This is the read-path twin of [`crate::migrate::Column::privileged`], which is already a
//! model-level, default-deny guard for the *write* path (mass assignment). Disclosure had
//! no equivalent. Now it does.
//!
//! # Hard-denied, not merely hidden
//!
//! There is deliberately **no unlock**. A plugin's own `hide()` list is configuration and can
//! be reconfigured; this cannot. The whole value of a tier with no escape hatch is that
//! nobody can reach for it at 2am under a deadline. An admin UI that needs to show *whether*
//! a password is set should show "set / not set" — never the hash. Django's admin has made
//! exactly this call for twenty years.
//!
//! See `planning/gaps3.md` for the broader `#[umbral(private)]` / `#[umbral(secret)]` design
//! this is the first, narrow slice of.
/// Column names that never appear in a serialized response, regardless of any plugin's
/// `expose` / `hide` configuration.
///
/// Matched on the column name alone, across every table: a model that names a column
/// `password_hash` means the same thing whatever the table is called, and the failure mode
/// of matching too broadly (a field is missing from an API) is survivable in a way that the
/// failure mode of matching too narrowly (a password hash on the wire) is not.
pub const HARD_DENIED_FIELDS: & = &;
/// Whether `field` may never be serialized. See [`HARD_DENIED_FIELDS`].
/// Whether this column may never be serialized to a client — for any reason.
///
/// Two independent sources, and a column needs only one of them:
///
/// 1. `#[umbral(secret)]`, which every `Masked<T>` field also gets automatically. A masked
/// column is encrypted at rest precisely so its plaintext is not lying around; handing it
/// to an API caller would defeat the entire point of encrypting it.
/// 2. The name denylist ([`HARD_DENIED_FIELDS`]) — a backstop for models that predate the
/// annotation, or whose author never thought to add it. `password_hash` means the same
/// thing whatever the table is called.
///
/// The name check is why (2) exists at all: annotations only protect the people who remember
/// to write them, and the whole point of this module is to protect the people who don't.