Skip to main content

cratefield_tables_api/
access.rs

1//! Who may reach a declared table's rows, and which of them (issue #153).
2//!
3//! The manifest declares an access level per table (#359) and the harness
4//! can now identify a caller (#362). This is the decision between them,
5//! and it is kept apart from the handlers on purpose: it is the part that
6//! decides who sees whose rows, it has no HTTP in it, and every case can
7//! be stated as a value rather than as a request.
8//!
9//! # Refusals say as little as possible
10//!
11//! Under `owner`, a row belonging to somebody else is **not found**, not
12//! forbidden. A `403` is an answer about a row the caller was told
13//! exists, which is a fact they had no way to learn. The design produces
14//! that answer by construction rather than by remembering to: the
15//! subject filter is part of the same `WHERE` as the key, so the row
16//! simply does not match.
17//!
18//! # `tenant-members` under the two tenancy shapes
19//!
20//! There is still no membership fact to check. A [`Caller`] is an id, a
21//! session and an address; a `Subject` carries no tenant claim, and
22//! `Ports::tenants` is not a [`Port`](cratefield_core::Port) and
23//! `view_for` never copies it, so a module cannot ask which tenant it is
24//! serving either. What a request does carry is its
25//! [`Tenancy`] — which of the deployment's two shapes served it, read
26//! from the `TenantConn` the resolution layer handed over and from
27//! nowhere else — and the two shapes disagree about what the level would
28//! be admitting.
29//!
30//! Under [`Tenancy::Sole`] the deployment has no registry: a venture `fz
31//! build` generates has one tenant, and every verified subject is a
32//! member of it because there is no second tenant to belong to. "Any
33//! verified caller" and "a member of this tenant" are the same set, and
34//! the level serves as it always did.
35//!
36//! Under [`Tenancy::FromRegistry`] a registry named the tenant from the
37//! `Host` header, and the two sets come apart: the verifier is the
38//! deployment's while the tenant is the host's, so a subject who signed
39//! in as a user of tenant A presents the same bearer at tenant B's host
40//! and "any verified caller" is a wider set than "a member of this
41//! tenant". Serving the first as the second is the leak of issue #385,
42//! and the fact that would close it — a tenant claim on `Subject` — does
43//! not exist. So the level refuses with [`NO_MEMBERSHIP_FACT`] rather
44//! than guessing who is a member. That is the whole of this: it does not
45//! implement membership, it refuses where membership would be needed,
46//! and #385 stays open for the real answer.
47
48use cratefield_core::{Caller, Problem, ProblemDef, Tenancy};
49use cratefield_manifest::Access;
50use cratefield_tables::TableDef;
51use http::StatusCode;
52use serde_json::Value;
53
54/// A caller presented no credential where one is required — core's shared
55/// 401, `cratefield_core::SLUGS.unauthenticated`. The same body answers a
56/// presented credential that did not verify ([`crate::read::NOT_VERIFIED`]):
57/// one 401 for every unauthenticated caller, because each distinguishable
58/// answer is a hint about how to get closer.
59pub const UNAUTHENTICATED: ProblemDef = cratefield_core::SLUGS.unauthenticated;
60
61/// A caller is signed in and still may not reach this table.
62pub const FORBIDDEN: ProblemDef = ProblemDef {
63    slug: "table-forbidden",
64    status: StatusCode::FORBIDDEN,
65    title: "Not yours to reach",
66    description: "The table's declared access level does not admit this caller.",
67};
68
69/// The declaration names a subject column the table does not have, or
70/// none at all.
71///
72/// A manifest that would do this is refused by `fz build`
73/// (`cratefield_manifest::access::validate`), so reaching this means a
74/// deployment is running a composition its manifest would not produce.
75/// It is a `500` and not a `403`: nothing the caller did is wrong.
76pub const MISDECLARED: ProblemDef = ProblemDef {
77    slug: "table-misdeclared",
78    status: StatusCode::INTERNAL_SERVER_ERROR,
79    title: "Table declaration cannot be enforced",
80    description: "The table declares owner access without a subject column to match a caller against.",
81};
82
83/// A `tenant-members` table on a deployment whose tenants come from a
84/// registry (issue #385).
85///
86/// Where a registry resolved the tenant, "any caller the verifier
87/// accepts" and "a member of this tenant" are different sets — the
88/// verifier is the deployment's and the tenant is the host's — and the
89/// level has no membership fact to tell them apart with. Serving anyway
90/// would hand every verified caller every tenant's rows, so the level is
91/// refused rather than guessed at.
92///
93/// It is a `500` and not a `403`: nothing the caller did is wrong, and
94/// no credential of theirs fixes it — signing in as somebody else only
95/// presents a different caller the deployment equally cannot place.
96pub const NO_MEMBERSHIP_FACT: ProblemDef = ProblemDef {
97    slug: "no-membership-fact",
98    status: StatusCode::INTERNAL_SERVER_ERROR,
99    title: "Tenant membership cannot be checked",
100    description: "This table admits members of the tenant, and on a deployment that resolves tenants from a registry there is no membership fact to check.",
101};
102
103/// One declared table, with everything a request needs to decide about it.
104#[derive(Debug, Clone)]
105pub struct TableApi {
106    /// The columns, key and constraints the manifest declared.
107    pub table: TableDef,
108    /// Who may reach it.
109    pub access: Access,
110    /// The column naming whose each row is, from the table's privacy
111    /// declaration. `Some` exactly when that declaration is `personal`.
112    pub subject: Option<String>,
113}
114
115/// How much of a table this request reaches.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Reach {
118    /// Every row.
119    Everything,
120    /// Only rows whose `column` holds `subject`.
121    OwnedBy {
122        /// The table's subject column.
123        column: String,
124        /// The caller's own id.
125        subject: String,
126    },
127}
128
129/// Whether this caller may read this table, and which rows.
130///
131/// `admin` is the outcome of `cratefield_core::require_admin`, passed in
132/// rather than computed here so the decision stays a function of values.
133/// Its own refusal is propagated unchanged: it distinguishes "admin
134/// endpoints are disabled or you sent no token" from "the token is
135/// wrong", and re-deciding that here would lose the difference.
136///
137/// # Errors
138///
139/// [`UNAUTHENTICATED`] when the level needs a signed-in caller and there
140/// is none, the admin check's own problem for `admin`, [`MISDECLARED`]
141/// when `owner` has no subject column to match against, and
142/// [`NO_MEMBERSHIP_FACT`] when the tenant came from a registry, where
143/// `tenant-members` cannot be honoured for anyone.
144pub fn may_read(
145    api: &TableApi,
146    tenancy: Tenancy,
147    caller: &Caller,
148    admin: Result<(), Problem>,
149) -> Result<Reach, Problem> {
150    match api.access {
151        // The one level an anonymous caller reaches. Writes are a
152        // separate decision and this function does not grant them.
153        Access::PublicRead => Ok(Reach::Everything),
154        Access::TenantMembers => match tenancy {
155            // The tenancy before the caller. On a registry deployment the
156            // level cannot be honoured whoever is asking, so the caller's
157            // credential is not the question this arm answers; reading it
158            // first would answer an anonymous caller with "sign in" —
159            // advice that would not help, dressed up as a 401 about them
160            // when the fault is the deployment's composition.
161            //
162            // Logged here, where the 500 is constructed, as the sibling
163            // 500s are (`misdeclared`, `unavailable`, `drifted`): an
164            // operator pairing a registry with this level otherwise gets
165            // client-facing 500s and no server-side trace of why.
166            Tenancy::FromRegistry => {
167                tracing::error!(
168                    table = %api.table.name,
169                    "a `tenant-members` table on a registry deployment has no membership fact to check",
170                );
171                Err(Problem::new(&NO_MEMBERSHIP_FACT))
172            }
173            // No registry, one tenant: there is no second tenant to
174            // belong to, so "a member of this tenant" and "any verified
175            // caller" are the same set and the level serves as it always
176            // did.
177            Tenancy::Sole => {
178                caller.id().ok_or_else(|| Problem::new(&UNAUTHENTICATED))?;
179                Ok(Reach::Everything)
180            }
181        },
182        // `fz build` refuses a manifest that declares `owner` on a table
183        // holding nothing personal, so a missing subject column is a
184        // deployment that did not come from one. Refusing is the only
185        // safe answer: with no column to match, "everything" and
186        // "nothing" are both wrong and one of them is a leak.
187        Access::Owner => owner_scope(api, caller),
188        Access::Admin => {
189            admin?;
190            Ok(Reach::Everything)
191        }
192        // `Access` is `#[non_exhaustive]`, so a level added later has to
193        // arrive here rather than fall into one of the four above. The
194        // safe answer for a level this build does not understand is no.
195        _ => Err(Problem::new(&FORBIDDEN)),
196    }
197}
198
199/// A table nobody may write through this API.
200pub const READ_ONLY: ProblemDef = ProblemDef {
201    slug: "table-read-only",
202    status: StatusCode::FORBIDDEN,
203    title: "Not writable",
204    description: "This table's declared access is public-read: it is served, never written.",
205};
206
207/// A write that would give a row to somebody other than its writer.
208pub const NOT_YOURS_TO_GIVE: ProblemDef = ProblemDef {
209    slug: "not-yours-to-give",
210    status: StatusCode::FORBIDDEN,
211    title: "That row would not be yours",
212    description: "The subject column names a different caller; a row written here is your own.",
213};
214
215/// Whether this caller may write this table, and which rows.
216///
217/// The same shape as [`may_read`] and deliberately a separate function:
218/// `public-read` reads for everybody and writes for nobody, so one
219/// function answering both would need a parameter saying which — and the
220/// day somebody passes the wrong one, a public table becomes writable.
221///
222/// # Errors
223///
224/// [`READ_ONLY`] for `public-read`, [`UNAUTHENTICATED`] when the level
225/// needs a signed-in caller and there is none, the admin check's own
226/// problem for `admin`, [`MISDECLARED`] when `owner` has no subject
227/// column to match against, and [`NO_MEMBERSHIP_FACT`] when the tenant
228/// came from a registry, where `tenant-members` cannot be honoured for
229/// anyone.
230pub fn may_write(
231    api: &TableApi,
232    tenancy: Tenancy,
233    caller: &Caller,
234    admin: Result<(), Problem>,
235) -> Result<Reach, Problem> {
236    match api.access {
237        // Read by everybody, written by nobody. A venture that wants
238        // public rows written has not declared `public-read`.
239        Access::PublicRead => Err(Problem::new(&READ_ONLY)),
240        Access::TenantMembers => match tenancy {
241            // The same tenancy-before-caller order as the read: the
242            // level is not writable on a registry deployment by anyone,
243            // and a 401 about the caller's credential would misreport a
244            // fault of the deployment's composition as an answer about
245            // them. Logged where constructed, as the read arm above is.
246            Tenancy::FromRegistry => {
247                tracing::error!(
248                    table = %api.table.name,
249                    "a `tenant-members` table on a registry deployment has no membership fact to check",
250                );
251                Err(Problem::new(&NO_MEMBERSHIP_FACT))
252            }
253            Tenancy::Sole => {
254                caller.id().ok_or_else(|| Problem::new(&UNAUTHENTICATED))?;
255                Ok(Reach::Everything)
256            }
257        },
258        Access::Owner => owner_scope(api, caller),
259        Access::Admin => {
260            admin?;
261            Ok(Reach::Everything)
262        }
263        _ => Err(Problem::new(&FORBIDDEN)),
264    }
265}
266
267/// The `owner` scope, shared by reads and writes because getting it
268/// right twice is how the two drift apart.
269fn owner_scope(api: &TableApi, caller: &Caller) -> Result<Reach, Problem> {
270    let subject = caller
271        .id()
272        .ok_or_else(|| Problem::new(&UNAUTHENTICATED))?
273        .to_owned();
274    let column = api
275        .subject
276        .clone()
277        .ok_or_else(|| Problem::new(&MISDECLARED))?;
278    // The column has to exist and it has to be able to hold a caller's
279    // id. Without the second, a subject column declared `integer` binds
280    // nothing and the read fails further down while its statement is
281    // built — which answered `400 bad-filter` naming the subject column,
282    // blaming the caller for a filter they never sent and telling them
283    // part of the table's shape. `fz build` refuses such a declaration;
284    // this is the same rule where the serving happens.
285    let holds_a_subject = api
286        .table
287        .fields
288        .iter()
289        .find(|field| field.name == column)
290        .is_some_and(|field| field.kind.can_hold_a_subject());
291    if !holds_a_subject {
292        return Err(Problem::new(&MISDECLARED));
293    }
294    Ok(Reach::OwnedBy { column, subject })
295}
296
297/// Settles the subject column of a row about to be written.
298///
299/// Under [`Reach::OwnedBy`] the harness sets it, not the caller:
300///
301/// - absent, or null, and it is filled in with the caller's own id;
302/// - already the caller's, and it is left alone;
303/// - somebody else's, and the write is refused.
304///
305/// The third is the one worth arguing about. Overwriting silently would
306/// be safe — the row would still be the caller's — but the client asked
307/// for something and got something else without being told, which is how
308/// a bug in a client becomes data nobody can explain. Refusing says what
309/// happened.
310///
311/// # Errors
312///
313/// [`NOT_YOURS_TO_GIVE`] when the row names a different subject.
314pub fn settle_subject(reach: &Reach, row: &mut Value) -> Result<(), Problem> {
315    let Reach::OwnedBy { column, subject } = reach else {
316        return Ok(());
317    };
318    let Some(object) = row.as_object_mut() else {
319        // Not an object: the row validator refuses it with a message
320        // about that, which is the more useful one.
321        return Ok(());
322    };
323    match object.get(column) {
324        None | Some(Value::Null) => {
325            object.insert(column.clone(), Value::String(subject.clone()));
326            Ok(())
327        }
328        Some(Value::String(named)) if named == subject => Ok(()),
329        Some(_) => Err(Problem::new(&NOT_YOURS_TO_GIVE)),
330    }
331}