Skip to main content

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}