parse_rust_server/request.rs
1//! Everything one HTTP request resolves once and then shares.
2//!
3//! Three things are built here and nowhere else, and each has a rule attached.
4//!
5//! - **One schema snapshot per request.** Upstream threads a `validSchemaController` down through
6//! every controller entry point so one request cannot evaluate half its work under one schema
7//! and half under another (`DatabaseController.js:553`). A `/batch` of twenty writes therefore
8//! loads schemas once, and every sub-request sees the same table. Doing it per operation is not
9//! a performance bug, it is a correctness bug.
10//! - **One role expansion per request.** Roles are uncached in 0.2.0, so expanding them per
11//! operation would issue two queries per level of the role graph per sub-request. It is also
12//! the same correctness argument: two operations in one batch must not disagree about who the
13//! caller is.
14//! - **An unknown session token is an error, not anonymity.** Downgrading silently meant a client
15//! whose session had gone kept working as a public caller, with no signal that authentication
16//! had failed.
17
18use indexmap::IndexMap;
19use parse_rust_auth::{expand_roles, resolve_session, RolePrincipal};
20use parse_rust_core::{ClassLevelPermissions, ParseError, ParseMap, ParseValue};
21use parse_rust_mongo::MongoAdapter;
22use parse_rust_rest::{AclScope, Ctx, PermissionOptions, SchemaSnapshot};
23use parse_rust_storage::{ClassSchema, StorageAdapter};
24
25use crate::auth::{Authority, Credentials};
26use crate::config::ServerConfig;
27
28/// The request-scoped state every handler runs against.
29///
30/// Owned rather than borrowed because [`Ctx`] borrows all three and a handler needs somewhere to
31/// keep them. Build it once at the top of a route, hand out [`RequestContext::ctx`] as often as
32/// needed.
33pub struct RequestContext {
34 pub snapshot: SchemaSnapshot,
35 pub scope: AclScope,
36 pub options: PermissionOptions,
37 /// The token this request presented, if any. `/users/me` and `/sessions/me` echo it back and
38 /// `POST /logout` revokes it.
39 pub session_token: Option<String>,
40 /// The caller's `_User` objectId, or `None` for master, maintenance and anonymous callers.
41 pub user_id: Option<String>,
42 /// `X-Parse-Installation-Id`. Read by session creation and nothing else.
43 pub installation_id: Option<String>,
44 /// `protectedFieldsSaveResponseExempt` (`Options/Definitions.js:507-512`).
45 pub save_response_exempt: bool,
46 /// Authenticated with the maintenance key rather than the master key.
47 pub is_maintenance: bool,
48}
49
50impl RequestContext {
51 pub fn ctx<'a>(&'a self, storage: &'a MongoAdapter) -> Ctx<'a, MongoAdapter> {
52 Ctx::new(storage, &self.snapshot, &self.scope, &self.options)
53 .maintenance(self.is_maintenance)
54 }
55
56 /// Is this a master or maintenance request?
57 pub fn is_master(&self) -> bool {
58 self.scope.is_master()
59 }
60}
61
62/// Resolve a request into its context: session, roles, scope, schemas.
63///
64/// The order is upstream's. The session is resolved first, because its three failures are what a
65/// client sees before anything else happens, and roles are expanded from the user it produces.
66pub async fn resolve(
67 storage: &MongoAdapter,
68 config: &ServerConfig,
69 authority: &Authority,
70) -> Result<RequestContext, ParseError> {
71 let (scope, user_id) = match (&authority.credentials, authority.session_token.as_deref()) {
72 // Master and maintenance short-circuit before session resolution, matching
73 // `middlewares.js:249-251`. A request carrying both a master key and a session token is a
74 // master request and the token is never looked up.
75 (Credentials::Master | Credentials::Maintenance, _) => (AclScope::Unrestricted, None),
76 (Credentials::Client, None) => (AclScope::Anonymous, None),
77 (Credentials::Client, Some(token)) => {
78 let session = resolve_session(storage, token).await?;
79 // Once per request, never per operation. A `/batch` of twenty writes expands the role
80 // graph once, and its twenty operations cannot disagree about who the caller is.
81 let roles = expand_roles(storage, RolePrincipal::User(&session.user_object_id)).await?;
82 let names = roles.iter().map(|r| r.as_str().to_string()).collect();
83 // The checked constructor. A user whose objectId began with `role:` would be granted
84 // that role by every ACL check, and this is the one place a scope can be built.
85 let scope = AclScope::user(session.user_object_id.clone(), names)?;
86 (scope, Some(session.user_object_id))
87 }
88 };
89
90 // One load, then the option is folded in before the snapshot is built. Loading twice would
91 // reintroduce exactly the mid-request schema change the snapshot exists to prevent.
92 let mut classes = storage.all_schemas().await?;
93 merge_server_protected_fields(&mut classes, config);
94 let snapshot = SchemaSnapshot::from_classes(classes);
95
96 Ok(RequestContext {
97 snapshot,
98 scope,
99 options: config.permission_options(),
100 session_token: authority.session_token.clone(),
101 user_id,
102 installation_id: authority.installation_id.clone(),
103 save_response_exempt: config.protected_fields_save_response_exempt,
104 // The scope cannot carry this: `Unrestricted` is master and maintenance alike. One
105 // decision reads them differently; see `Ctx::is_maintenance`.
106 is_maintenance: matches!(authority.credentials, Credentials::Maintenance),
107 })
108}
109
110/// Fold the server-level `protectedFields` option into the snapshot's CLP blocks.
111///
112/// `SchemaController.js:577-586`: for each entity key the option names, the stored list and the
113/// configured list are **set-unioned**, so the option composes with a class's own block rather
114/// than replacing it.
115///
116/// **This never reaches storage.** The snapshot is request state; the schema routes read `_SCHEMA`
117/// afresh rather than rendering this, so a `PUT /schemas` round trip cannot write the server's
118/// option into a class's stored block and turn a configuration value into a database value for
119/// every parse-server node reading it.
120///
121/// A class whose CLP is absent gets a synthetic block carrying `protectedFields` and nothing else.
122/// That is equivalent to upstream's merge over `defaultCLPS`, whose operation entries are all
123/// `{'*': true}`: an absent operation entry and a fully public one both evaluate as unrestricted.
124fn merge_server_protected_fields(classes: &mut [ClassSchema], config: &ServerConfig) {
125 if config.protected_fields.is_empty() {
126 return;
127 }
128 for schema in classes.iter_mut() {
129 let Some(configured) = config.protected_fields.get(&schema.class_name) else {
130 continue;
131 };
132 let raw = schema
133 .clp
134 .as_ref()
135 .map(|clp| clp.raw().clone())
136 .unwrap_or_default();
137 schema.clp = Some(ClassLevelPermissions::from_map(union_protected_fields(
138 raw, configured,
139 )));
140 }
141}
142
143/// One CLP block with the configured entries unioned into its `protectedFields`.
144fn union_protected_fields(
145 mut raw: ParseMap,
146 configured: &IndexMap<String, Vec<String>>,
147) -> ParseMap {
148 let mut protected = match raw.shift_remove("protectedFields") {
149 Some(ParseValue::Object(map)) => map,
150 // Anything that is not an object is replaced rather than merged into. Upstream would
151 // throw on such a block at validation time, so reaching here means the database already
152 // holds one and the safe reading is that no field is protected by it.
153 _ => ParseMap::new(),
154 };
155 for (entity, fields) in configured {
156 let mut merged: Vec<String> = match protected.get(entity) {
157 Some(ParseValue::Array(items)) => items
158 .iter()
159 .filter_map(|v| match v {
160 ParseValue::String(s) => Some(s.clone()),
161 _ => None,
162 })
163 .collect(),
164 _ => Vec::new(),
165 };
166 for field in fields {
167 if !merged.contains(field) {
168 merged.push(field.clone());
169 }
170 }
171 protected.insert(
172 entity.clone(),
173 ParseValue::Array(merged.into_iter().map(ParseValue::String).collect()),
174 );
175 }
176 raw.insert("protectedFields".to_string(), ParseValue::Object(protected));
177 raw
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use parse_rust_core::PfEntity;
184
185 fn configured(pairs: &[(&str, &[&str])]) -> IndexMap<String, Vec<String>> {
186 pairs
187 .iter()
188 .map(|(k, v)| {
189 (
190 (*k).to_string(),
191 v.iter().map(|s| (*s).to_string()).collect(),
192 )
193 })
194 .collect()
195 }
196
197 fn parse(json: &str) -> ParseMap {
198 match parse_rust_core::classify(serde_json::from_str(json).expect("test literal"))
199 .expect("classify")
200 {
201 ParseValue::Object(m) => m,
202 _ => panic!("expected an object"),
203 }
204 }
205
206 /// The rule that makes the option compose rather than replace. Getting this backwards is a
207 /// data-exposure bug in one direction and a compatibility break in the other.
208 #[test]
209 fn the_server_option_is_unioned_into_the_class_block() {
210 let raw = parse(r#"{"find":{"*":true},"protectedFields":{"*":["phone"]}}"#);
211 let merged = ClassLevelPermissions::from_map(union_protected_fields(
212 raw,
213 &configured(&[("*", &["email"])]),
214 ));
215 assert_eq!(
216 merged.protected_fields().get(&PfEntity::Public),
217 Some(&vec!["phone".to_string(), "email".to_string()]),
218 "the stored list keeps its order and the configured entry is appended"
219 );
220 // Every other key survives the round trip.
221 assert!(merged.raw().contains_key("find"));
222 }
223
224 #[test]
225 fn a_class_with_no_block_gets_one_carrying_only_protected_fields() {
226 let merged = ClassLevelPermissions::from_map(union_protected_fields(
227 ParseMap::new(),
228 &configured(&[("*", &["email"])]),
229 ));
230 assert_eq!(merged.raw().len(), 1, "no operation entry is invented");
231 assert_eq!(
232 merged.protected_fields().get(&PfEntity::Public),
233 Some(&vec!["email".to_string()])
234 );
235 for op in parse_rust_core::Operation::ALL {
236 assert!(
237 merged.op(op).is_none(),
238 "an absent operation entry stays absent, which is unrestricted"
239 );
240 }
241 }
242
243 #[test]
244 fn a_field_already_protected_is_not_duplicated() {
245 let raw = parse(r#"{"protectedFields":{"*":["email"]}}"#);
246 let merged = ClassLevelPermissions::from_map(union_protected_fields(
247 raw,
248 &configured(&[("*", &["email"])]),
249 ));
250 assert_eq!(
251 merged.protected_fields().get(&PfEntity::Public),
252 Some(&vec!["email".to_string()])
253 );
254 }
255}