parse_rust_server/routes/classes.rs
1//! The five `/classes` verbs, and the cores every other class-shaped route reuses.
2//!
3//! Upstream: `src/Routers/ClassesRouter.js`. Response shapes are wire contract and narrower than
4//! they look: a create returns `{objectId, createdAt}`, an update returns `{updatedAt}`, and a
5//! delete returns `{}`. Returning the whole object would be more helpful and would not match.
6//!
7//! `RolesRouter` and `SessionsRouter` are `ClassesRouter` with `className()` pinned
8//! (`RolesRouter.js:4-6`, `SessionsRouter.js:8-10`), so they call the cores below with a fixed
9//! class name rather than reimplementing them. `/batch` dispatches into the same cores, which is
10//! what makes a sub-request and a top-level request the same code path rather than two that drift.
11
12use parse_rust_core::{ErrorCode, ParseError, ParseMap, ParseValue};
13use parse_rust_rest::{FindOptions, ParsedClause, ParsedWhere};
14use parse_rust_storage::{Constraint, StorageAdapter};
15use serde_json::{json, Value as Json};
16
17use crate::auth::Authority;
18use crate::params::Params;
19use crate::request::RequestContext;
20use crate::state::AppState;
21
22/// The class sessions live in.
23///
24/// A non-master read of it is narrowed to the caller's own sessions, but that happens in
25/// `parse_rust_rest`'s read pipeline rather than here. See `pipeline::narrow_sessions` for why the
26/// layer matters.
27pub const SESSION_CLASS: &str = "_Session";
28
29/// Convert a Parse-format map into a JSON response body.
30///
31/// Strips every `_`-prefixed key and flattens the top-level timestamps. This is the single audit
32/// point for "nothing internal reaches a client", and it runs on every response body this module
33/// produces.
34pub fn body_of(row: &ParseMap) -> Json {
35 let row = parse_rust_rest::to_response_body(row);
36 serde_json::from_str(&ParseValue::Object(row).to_json()).unwrap_or(Json::Null)
37}
38
39/// Decode a JSON request body into a write body.
40fn decode_body(
41 value: &Json,
42 path: parse_rust_core::op::OpPath,
43) -> Result<parse_rust_rest::WriteBody, ParseError> {
44 let body = parse_rust_rest::decode_write_body(value, path)?;
45 // A client must not supply a server-internal column. Without this, a caller could write its
46 // own `_rperm` and grant itself read access to a row.
47 parse_rust_rest::reject_reserved_keys_in(body.keys().map(String::as_str))?;
48 Ok(body)
49}
50
51// -------------------------------------------------------------------------------------------
52// Cores
53// -------------------------------------------------------------------------------------------
54
55pub async fn find_core(
56 state: &AppState,
57 rc: &RequestContext,
58 authority: &Authority,
59 class_name: &str,
60 params: &Params,
61) -> Result<Json, ParseError> {
62 parse_rust_rest::enforce_class_security(
63 class_name,
64 authority.is_privileged(),
65 "find",
66 rc.options.error_detail,
67 )?;
68 params.reject_unknown_find_keys()?;
69
70 let where_ = params.parse_where()?;
71 let options = params.find_options()?;
72 let wants_count = params.wants_count();
73
74 let ctx = rc.ctx(state.storage());
75 let results = parse_rust_rest::find(&ctx, class_name, where_.clone(), options).await?;
76
77 let mut body = json!({ "results": results.iter().map(body_of).collect::<Vec<_>>() });
78 if wants_count {
79 let n = parse_rust_rest::count(&ctx, class_name, where_).await?;
80 body["count"] = json!(n);
81 }
82 Ok(body)
83}
84
85pub async fn get_core(
86 state: &AppState,
87 rc: &RequestContext,
88 authority: &Authority,
89 class_name: &str,
90 object_id: &str,
91 params: &Params,
92) -> Result<Json, ParseError> {
93 parse_rust_rest::enforce_class_security(
94 class_name,
95 authority.is_privileged(),
96 "get",
97 rc.options.error_detail,
98 )?;
99 params.reject_unknown_get_keys()?;
100
101 // `handleGet` is `rest.get`, which pins the query to an objectId **and carries the `get`
102 // method** (`rest.js:150`, `:183`). Routing it through `find` instead would re-derive the
103 // method as `find` inside the pipeline, and `enforceRoleSecurity` distinguishes the two: a
104 // client may `get` an installation and may not `find` one.
105 //
106 // The `_Session` narrowing this needs is applied by the pipeline, because upstream applies it
107 // in the query constructor (`RestQuery.js:116-134`) rather than at a route handler.
108 let options = FindOptions {
109 limit: Some(1),
110 ..params.get_options()?
111 };
112 let ctx = rc.ctx(state.storage());
113 let row = parse_rust_rest::get(&ctx, class_name, object_id, options).await?;
114 Ok(body_of(&row))
115}
116
117pub async fn create_core(
118 state: &AppState,
119 rc: &RequestContext,
120 authority: &Authority,
121 class_name: &str,
122 body: &Json,
123) -> Result<Json, ParseError> {
124 parse_rust_rest::enforce_class_security(
125 class_name,
126 authority.is_privileged(),
127 "create",
128 rc.options.error_detail,
129 )?;
130 let mut body = decode_body(body, parse_rust_core::op::OpPath::Create)?;
131 // The `RestWrite` constructor's first check, and it runs on the client's body before any
132 // server-side identity is folded in (`RestWrite.js:50-65`).
133 parse_rust_rest::enforce_object_id_policy(&body, state.config().allow_custom_object_id)?;
134 if class_name == crate::routes::users::USER_CLASS {
135 // `handleCreate`'s guard, which lives on `ClassesRouter` and therefore covers this route
136 // as well as signup (`ClassesRouter.js:105-112`).
137 crate::routes::users::reject_role_prefixed_object_id(&body, rc)?;
138 // Upstream's `!this.query && !hasAuthData` guard is not gated on the caller
139 // (`RestWrite.js:468-473`), so the master key does not buy an exemption from it. Before
140 // the uniqueness query and the hash, as upstream orders those stages.
141 crate::routes::users::require_create_credentials(&body)?;
142 // **`transformUser` is not gated on the caller**, so a master create through this route
143 // gets the same username and email validation a signup does (`RestWrite.js:803-807`).
144 // Without it, `POST /classes/_User` with the master key admitted case-only duplicate
145 // usernames and malformed email addresses that `POST /users` refuses. The dashboard
146 // creates users through this route. There is no self to exclude on a create, which is
147 // what the empty objectId means here.
148 crate::routes::users::validate_user_identity(state, rc, &body, "").await?;
149 crate::routes::users::prepare_user_write(&mut body, true).await?;
150 }
151 let ctx = rc.ctx(state.storage());
152 let res = parse_rust_rest::create(&ctx, class_name, body)
153 .await
154 // Same relabelling the update path does, and `_User` only: a collision on the unique index
155 // is 202 or 203 to a client, not a bare 137.
156 .map_err(|e| {
157 if class_name == crate::routes::users::USER_CLASS {
158 crate::routes::users::map_duplicate(e)
159 } else {
160 e
161 }
162 })?;
163
164 let mut out = json!({
165 "objectId": res.object_id,
166 "createdAt": res.created_at.to_iso(),
167 });
168 merge_echo(&mut out, res.echoed, rc, class_name);
169 Ok(out)
170}
171
172pub async fn update_core(
173 state: &AppState,
174 rc: &RequestContext,
175 authority: &Authority,
176 class_name: &str,
177 object_id: &str,
178 body: &Json,
179) -> Result<Json, ParseError> {
180 parse_rust_rest::enforce_class_security(
181 class_name,
182 authority.is_privileged(),
183 "update",
184 rc.options.error_detail,
185 )?;
186 let mut body = decode_body(body, parse_rust_core::op::OpPath::Update)?;
187 let is_user = class_name == crate::routes::users::USER_CLASS;
188
189 // Whether this write changes the password, decided before `prepare_user_write` replaces the
190 // key with its hash. **Only a string counts**, because only a string is a password: a
191 // `{"password": null}` body previously read as "no password" to the hasher and as "a password
192 // change" to the followup below, so it revoked every session and issued a replacement while
193 // leaving the old password working. The policy check refuses that body outright now, and this
194 // stays narrow so the two cannot disagree again.
195 let changes_password = is_user
196 && matches!(
197 body.get("password"),
198 Some(parse_rust_core::FieldWrite::Value(ParseValue::String(_)))
199 );
200
201 if is_user {
202 crate::routes::users::enforce_user_update_policy(&body, rc, authority, object_id)?;
203 crate::routes::users::validate_user_identity(state, rc, &body, object_id).await?;
204 crate::routes::users::force_owner_into_acl(&mut body, object_id, authority.is_privileged());
205 crate::routes::users::prepare_user_write(&mut body, false).await?;
206 }
207 let ctx = rc.ctx(state.storage());
208 let res = parse_rust_rest::update(&ctx, class_name, object_id, body)
209 .await
210 // **`_User` only.** The relabelling turns a duplicate-key error into 202 or 203 by reading
211 // the index name, and an ordinary class is free to carry its own unique index called
212 // `username_1`. Applying it everywhere reported somebody else's collision as
213 // `Account already exists for this username.`, where upstream leaves a non-`_User`
214 // collision as 137.
215 .map_err(|e| {
216 if is_user {
217 crate::routes::users::map_duplicate(e)
218 } else {
219 e
220 }
221 })?;
222
223 let mut out = json!({ "updatedAt": res.updated_at.to_iso() });
224 merge_echo(&mut out, res.echoed, rc, class_name);
225
226 // **A password change revokes every session and, for a non-master caller, mints a replacement**
227 // (`RestWrite.js:1192-1211`). Both halves matter and they are not symmetric: revoking is what
228 // makes a password change mean anything, and the new token is what stops the caller logging
229 // themselves out by changing their own password. Master gets the revocation and no new token,
230 // because upstream gates `generateNewSession` on the caller not being master.
231 //
232 // Runs after the write, as upstream's `handleFollowup` does. A failure here leaves the password
233 // changed and the old sessions alive, which is the safe direction to fail in only because the
234 // caller can retry; it is not silent, because the error reaches the client.
235 if changes_password {
236 parse_rust_auth::revoke_all_for_user(state.storage(), object_id).await?;
237 if !authority.is_privileged() {
238 let session = parse_rust_auth::create_session(
239 state.storage(),
240 &state.config().session,
241 parse_rust_auth::NewSession {
242 user_object_id: object_id,
243 // **No `createdWith`.** `setCreatedWith` computes `login` only when an auth
244 // provider is in storage and `signup` only on a create; a password update is
245 // neither, so it returns before setting anything and upstream's replacement
246 // session carries no such column (`RestWrite.js:860-870`). Writing
247 // `{"action":"login"}` here would be visible through `/sessions/me` and would
248 // describe a login that did not happen.
249 created_with: None,
250 installation_id: rc.installation_id.as_deref(),
251 },
252 )
253 .await?;
254 out["sessionToken"] = json!(session.session_token);
255 }
256 }
257 Ok(out)
258}
259
260pub async fn delete_core(
261 state: &AppState,
262 rc: &RequestContext,
263 authority: &Authority,
264 class_name: &str,
265 object_id: &str,
266) -> Result<Json, ParseError> {
267 parse_rust_rest::enforce_class_security(
268 class_name,
269 authority.is_privileged(),
270 "delete",
271 rc.options.error_detail,
272 )?;
273 let ctx = rc.ctx(state.storage());
274 parse_rust_rest::delete(&ctx, class_name, object_id).await?;
275 // Upstream answers an empty object, not 204.
276 Ok(json!({}))
277}
278
279/// Fold the post-write value of any operation the request carried into the response.
280///
281/// `protectedFieldsSaveResponseExempt` decides whether a protected field survives that fold. It
282/// defaults to `true` (`Options/Definitions.js:507-512`), which is the pass-through case; set to
283/// `false` the echo is stripped the same way a query result is.
284fn merge_echo(out: &mut Json, echoed: ParseMap, rc: &RequestContext, class_name: &str) {
285 if echoed.is_empty() {
286 return;
287 }
288 let mut echoed = echoed;
289 if !rc.save_response_exempt && !rc.is_master() {
290 if let Some(plan) = parse_rust_rest::clp::plan_protected_fields(
291 class_name,
292 rc.snapshot.clp(class_name),
293 &rc.scope,
294 None,
295 &rc.options,
296 ) {
297 for field in plan.strip {
298 echoed.shift_remove(&field);
299 }
300 }
301 }
302 let Json::Object(map) = out else { return };
303 if let Json::Object(rendered) = body_of(&echoed) {
304 for (key, value) in rendered {
305 map.insert(key, value);
306 }
307 }
308}
309
310/// `DELETE /sessions/:objectId`, which is narrower than an ordinary class delete.
311///
312/// `rest.del` reads the row first for `_Session` and then re-checks the owner explicitly
313/// (`rest.js:181-197`), because `_Session` rows carry no ACL and the ordinary write constraint
314/// therefore excludes nothing. A miss is `Object not found for delete.`, which is that path's
315/// message rather than the class router's `Object not found.`
316pub async fn delete_session_core(
317 state: &AppState,
318 rc: &RequestContext,
319 object_id: &str,
320) -> Result<Json, ParseError> {
321 let mut where_ = ParsedWhere::default();
322 where_.push(ParsedClause::Field(Constraint::equal(
323 "objectId",
324 ParseValue::String(object_id.to_string()),
325 )));
326
327 let ctx = rc.ctx(state.storage());
328 let rows = parse_rust_rest::find(
329 &ctx,
330 SESSION_CLASS,
331 where_,
332 FindOptions {
333 limit: Some(1),
334 ..Default::default()
335 },
336 )
337 .await?;
338 if rows.is_empty() {
339 return Err(ParseError::new(
340 ErrorCode::ObjectNotFound,
341 "Object not found for delete.",
342 ));
343 }
344
345 // The delete itself is master-scoped, because the narrowing above has already established
346 // that this row belongs to the caller and `_Session` carries no `_wperm` for the ordinary
347 // write constraint to match.
348 let schema = rc.snapshot.get_or_default(SESSION_CLASS);
349 let query = parse_rust_storage::Query::from_constraints(vec![Constraint::equal(
350 "objectId",
351 ParseValue::String(object_id.to_string()),
352 )]);
353 state.storage().delete(&schema, &query).await?;
354 Ok(json!({}))
355}