Skip to main content

parse_rust_server/
config.rs

1//! Server configuration.
2//!
3//! A deliberately small slice of upstream's ~292 options: what the routes that exist actually
4//! need. Options are added when a route needs one, not speculatively, so that every field here
5//! has a behavior behind it. Each carries upstream's env var name and upstream's default; a
6//! wrong default in this file is a security default.
7
8use indexmap::IndexMap;
9use parse_rust_auth::SessionConfig;
10use parse_rust_core::ErrorDetail;
11use parse_rust_rest::PermissionOptions;
12use parse_rust_schema::{ClpValidation, ObjectIdForm, Unenforceable};
13
14/// The parse-server version parse-rust reports as its own.
15///
16/// **This is a decision, not an oversight.** `/serverInfo` returns `parseServerVersion`, and
17/// SDKs branch on it: the Ruby SDK warns below 7.0.0, and features gate on version comparisons.
18/// Reporting `parse-rust 0.0.0` would fail every one of those checks, so the wire-compatible
19/// answer is the parse-server version whose behavior this server implements. It is the same
20/// number recorded in `PIN`.
21///
22/// If parse-rust ever needs to advertise itself distinctly, that belongs in a separate field
23/// that upstream does not define, not in this one.
24pub const REPORTED_PARSE_SERVER_VERSION: &str = "9.10.1-alpha.6";
25
26/// What this server can actually do, as reported by `GET /serverInfo`.
27///
28/// **The key set and the nesting of the `features` object are wire contract. The booleans are
29/// not.** Upstream hardcodes nearly all of them to `true` (`FeaturesRouter.js`) because upstream
30/// implements the subsystems behind them. Transcribing those literals would advertise a schema
31/// API, cloud jobs, hooks, a global config and a log API that all answer 404 here.
32///
33/// That matters because the object is not documentation: Parse Dashboard builds its UI from it,
34/// so an advertised capability becomes a button that fails when a user presses it. This is a
35/// deliberate difference from upstream, in the direction of telling the truth. Every field is
36/// `false` until the subsystem behind it exists, and flipping one is part of landing that
37/// subsystem rather than a follow-up.
38#[derive(Debug, Clone)]
39pub struct FeatureSupport {
40    /// `/config`. Not implemented.
41    pub global_config: bool,
42    /// `/hooks`. Not implemented.
43    pub hooks: bool,
44    /// Cloud Code jobs. Not implemented; `TriggerHost` is the milestone that lands them.
45    pub cloud_code_jobs: bool,
46    /// The log API. Not implemented.
47    pub logs: bool,
48    /// The schema API, `/schemas` and `DELETE /purge/:className`.
49    ///
50    /// True as of 0.2.0, and every capability it drives has a route that does the thing:
51    /// `addField` and `removeField` through `PUT`, `addClass` through `POST`, `removeClass`
52    /// through `DELETE`, `clearAllDataFromClass` through `DELETE /purge/:className`,
53    /// `editClassLevelPermissions` through the `classLevelPermissions` key on `POST` and `PUT`,
54    /// and `editPointerPermissions` through per-operation `pointerFields` plus the class-wide
55    /// `readUserFields` and `writeUserFields` arrays, all three of which the read and write
56    /// pipelines enforce.
57    pub schemas: bool,
58    /// Push, including audiences and localization. Not implemented.
59    pub push_audiences: bool,
60}
61
62impl Default for FeatureSupport {
63    fn default() -> Self {
64        Self {
65            global_config: false,
66            hooks: false,
67            cloud_code_jobs: false,
68            logs: false,
69            schemas: true,
70            push_audiences: false,
71        }
72    }
73}
74
75/// Server-level `protectedFields`: class name, then entity, then the fields that entity may not
76/// see (`Options/Definitions.js:491-500`).
77///
78/// Order-preserving because the intersection that consumes it is order-sensitive on the wire.
79pub type ProtectedFieldsConfig = IndexMap<String, IndexMap<String, Vec<String>>>;
80
81/// The upstream default, `{_User: {'*': ['email']}}` (`Options/Definitions.js:495-499`).
82///
83/// **Merged as a set union per entity key over the class's own block**
84/// (`SchemaController.js:577-586`), so it composes with a configured `protectedFields` rather
85/// than replacing it. A class that protects `phone` from `*` ends up protecting `phone` and
86/// `email`, which is what a parse-server node reading the same database would do.
87pub fn default_protected_fields() -> ProtectedFieldsConfig {
88    let mut entities = IndexMap::new();
89    entities.insert("*".to_string(), vec!["email".to_string()]);
90    let mut classes = ProtectedFieldsConfig::new();
91    classes.insert("_User".to_string(), entities);
92    classes
93}
94
95/// Fold the defaults into a configured `protectedFields`, as upstream does at option-resolution
96/// time (`ParseServer.ts:657-673`).
97///
98/// **A configured block adds to the defaults, it does not replace them.** Assigning the parsed
99/// configuration straight onto the config is the obvious translation and it is a data exposure: a
100/// deployment that configures protection for one of its own classes and never mentions `_User`
101/// thereby unprotects `email` on every user, which the operator did not ask for and cannot see in
102/// their own configuration file.
103///
104/// Upstream's rule, per class present in the defaults:
105///
106/// - the configuration does not name the class at all, so the default block is used whole;
107/// - the configuration names it, so each default entity key is unioned into the configured one.
108///
109/// The single exception is `protectedFieldsOwnerExempt == false`, where a configured entity key is
110/// left exactly as written. That option means "apply `protectedFields` to the owner the same as to
111/// anyone else", and merging a default the operator did not write would undo the point of setting
112/// it.
113pub fn merge_protected_fields_defaults(configured: &mut ProtectedFieldsConfig, owner_exempt: bool) {
114    for (class_name, default_entities) in default_protected_fields() {
115        let Some(entities) = configured.get_mut(&class_name) else {
116            configured.insert(class_name, default_entities);
117            continue;
118        };
119        for (entity, default_fields) in default_entities {
120            match entities.get_mut(&entity) {
121                // Configured and the owner is not exempt: upstream returns early and the
122                // configured list stands alone.
123                Some(_) if !owner_exempt => {}
124                Some(fields) => {
125                    for field in default_fields {
126                        if !fields.contains(&field) {
127                            fields.push(field);
128                        }
129                    }
130                }
131                None => {
132                    entities.insert(entity, default_fields);
133                }
134            }
135        }
136    }
137}
138
139/// The keys and identity a request is checked against.
140#[derive(Debug, Clone)]
141pub struct ServerConfig {
142    pub app_id: String,
143    pub master_key: String,
144    /// The read-only master key sets `isMaster` upstream (`Auth.js:63`), with the restriction
145    /// enforced by scattered checks. Not implemented yet; recorded so the gap is visible.
146    pub maintenance_key: Option<String>,
147    pub javascript_key: Option<String>,
148    pub rest_api_key: Option<String>,
149    pub client_key: Option<String>,
150    pub dot_net_key: Option<String>,
151    /// Where the API is mounted, e.g. `/parse`. A **builder input, never inferred from the
152    /// request path**: axum's `nest` and Express's `app.use` differ here, and every generated
153    /// file URL is built from this value.
154    pub mount_path: String,
155    /// `enableSanitizedErrorResponse`, default true (`Options/Definitions.js:253-258`).
156    ///
157    /// When true, every denial upstream routes through `createSanitizedError` or
158    /// `createSanitizedHttpError` (`Error.js:13-43`) says `Permission denied` instead of naming
159    /// the rule that refused. That is the configuration an unmodified deployment runs, so it is
160    /// what every SDK sees by default. Read it as [`ServerConfig::error_detail`] rather than as a
161    /// bare bool at a call site.
162    pub enable_sanitized_error_response: bool,
163    pub has_push_support: bool,
164    pub has_push_scheduled_support: bool,
165    pub security_check_enabled: bool,
166    /// What `/serverInfo` advertises. Defaults to the truth: nothing unimplemented.
167    pub features: FeatureSupport,
168
169    /// `sessionLength` and `expireInactiveSessions`, which together decide `_Session.expiresAt`.
170    /// Defaults are upstream's (`Options/Definitions.js:629-634`, `:269-274`).
171    pub session: SessionConfig,
172
173    /// `protectedFields`. See [`default_protected_fields`] for the merge rule.
174    pub protected_fields: ProtectedFieldsConfig,
175
176    /// `protectedFieldsOwnerExempt`, default true (`Options/Definitions.js:501-506`). When true a
177    /// user reading their own `_User` row sees every field regardless of `protectedFields`.
178    pub protected_fields_owner_exempt: bool,
179
180    /// `protectedFieldsSaveResponseExempt`, default true (`Options/Definitions.js:507-512`).
181    ///
182    /// When true, a create or update response carries protected fields the write touched. When
183    /// false they are stripped from the response as they are from a query result. parse-rust only
184    /// ever echoes back the keys whose request value was an operation, so this narrows that echo
185    /// rather than a whole object.
186    pub protected_fields_save_response_exempt: bool,
187
188    /// `allowCustomObjectId`, default false (`Options/Definitions.js:73-78`).
189    ///
190    /// Two effects, and the second is easy to forget because it is in a different file. It gates
191    /// whether a create may carry its own `objectId` (`RestWrite.js:50-65`, enforced by
192    /// `enforce_object_id_policy`), **and** it widens the objectId grammar a CLP entity key is
193    /// matched against, from `^[a-zA-Z0-9]{1,}$` to `^.{1,}$` (`SchemaController.js:726-731`).
194    ///
195    /// The two are one option because a CLP naming a user by id has to be able to name a user
196    /// whose id the client chose.
197    pub allow_custom_object_id: bool,
198
199    /// `allowClientClassCreation`, default **false** (`Options/Definitions.js:67-72`).
200    ///
201    /// Gates whether a write may bring a class into existence. Enforced by
202    /// `validateClientClassCreation` in the write pipeline, which exempts master, maintenance and
203    /// the classes Parse defines itself.
204    ///
205    /// The default matters more than the option. Left unimplemented, a server behaves as though
206    /// this were `true`, which lets a caller holding only the app id and client key create classes
207    /// without limit on a database parse-server nodes also read, each with a default-open CLP.
208    pub allow_client_class_creation: bool,
209
210    /// `allowOrigin`, default `["*"]` (`middlewares.js:407-408`).
211    ///
212    /// A list rather than one value, because upstream accepts either and echoes back whichever
213    /// entry matches the request's `Origin`. An unmatched origin gets the first entry, so a
214    /// single-element list is an allowlist of one rather than a wildcard.
215    pub allow_origin: Vec<String>,
216
217    /// `allowHeaders`. Appended to `DEFAULT_ALLOWED_HEADERS` rather than replacing it
218    /// (`middlewares.js:402-405`), so a deployment adding one custom header does not have to
219    /// restate the twelve a Parse SDK needs.
220    pub allow_headers: Vec<String>,
221
222    /// `requestComplexity.batchRequestLimit`, default `-1`, which disables it
223    /// (`Options/Definitions.js:733-738`). Master and maintenance bypass it (`batch.js:73`).
224    pub batch_request_limit: i64,
225
226    /// `databaseOptions.createIndexRoleName`, default true (`Options/Definitions.js:1318-1323`),
227    /// created at `DatabaseController.js:2033-2038`.
228    ///
229    /// **Not cosmetic.** Without the index two `_Role` rows can carry the same `name`, and an ACL
230    /// entry of `role:X` then grants every member of both, which is a privilege-escalation path
231    /// rather than a duplicate-data annoyance. Upstream tests `!== false`, so anything other than
232    /// an explicit `false` creates it.
233    pub create_index_role_name: bool,
234}
235
236impl ServerConfig {
237    pub fn new(app_id: impl Into<String>, master_key: impl Into<String>) -> Self {
238        Self {
239            app_id: app_id.into(),
240            master_key: master_key.into(),
241            maintenance_key: None,
242            javascript_key: None,
243            rest_api_key: None,
244            client_key: None,
245            dot_net_key: None,
246            mount_path: "/parse".to_string(),
247            enable_sanitized_error_response: true,
248            has_push_support: false,
249            has_push_scheduled_support: false,
250            security_check_enabled: false,
251            features: FeatureSupport::default(),
252            session: SessionConfig::default(),
253            protected_fields: default_protected_fields(),
254            protected_fields_owner_exempt: true,
255            protected_fields_save_response_exempt: true,
256            allow_custom_object_id: false,
257            allow_client_class_creation: false,
258            allow_origin: vec!["*".to_string()],
259            allow_headers: Vec::new(),
260            batch_request_limit: -1,
261            create_index_role_name: true,
262        }
263    }
264
265    /// Whether a denial tells the client why.
266    ///
267    /// The one place `enable_sanitized_error_response` becomes an [`ErrorDetail`], so no call site
268    /// has to remember which way round the bool runs.
269    pub fn error_detail(&self) -> ErrorDetail {
270        ErrorDetail::from_sanitized(self.enable_sanitized_error_response)
271    }
272
273    /// How a CLP entity key that looks like an objectId is matched.
274    pub fn object_id_form(&self) -> ObjectIdForm {
275        if self.allow_custom_object_id {
276            ObjectIdForm::Custom
277        } else {
278            ObjectIdForm::Generated
279        }
280    }
281
282    /// What CLP validation accepts.
283    ///
284    /// `Unenforceable::Accept`, because the read and write pipelines enforce all three of the
285    /// features the toggle guards: per-operation `pointerFields`, the class-wide `readUserFields`
286    /// and `writeUserFields` arrays (`ClassLevelPermissions::applicable_pointer_fields`), and
287    /// `userField:` protected-field entries (`ProtectedFieldPlan::user_field_rules`). Refusing
288    /// them would reject a CLP this server honors.
289    pub fn clp_validation(&self) -> ClpValidation {
290        ClpValidation {
291            object_id: self.object_id_form(),
292            unenforceable: Unenforceable::Accept,
293        }
294    }
295
296    /// The permission options the read and write pipelines take.
297    pub fn permission_options(&self) -> PermissionOptions {
298        PermissionOptions {
299            protected_fields_owner_exempt: self.protected_fields_owner_exempt,
300            error_detail: self.error_detail(),
301            allow_client_class_creation: self.allow_client_class_creation,
302        }
303    }
304
305    pub fn javascript_key(mut self, k: impl Into<String>) -> Self {
306        self.javascript_key = Some(k.into());
307        self
308    }
309
310    pub fn rest_api_key(mut self, k: impl Into<String>) -> Self {
311        self.rest_api_key = Some(k.into());
312        self
313    }
314
315    pub fn mount_path(mut self, p: impl Into<String>) -> Self {
316        self.mount_path = p.into();
317        self
318    }
319
320    /// True when any client key is configured. Upstream's rule is all-or-nothing: if *any* of
321    /// these is set, a non-master request must present one that matches
322    /// (`middlewares.js:255-265`). If none is configured, none is required.
323    pub fn requires_client_key(&self) -> bool {
324        self.javascript_key.is_some()
325            || self.rest_api_key.is_some()
326            || self.client_key.is_some()
327            || self.dot_net_key.is_some()
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn fields(config: &ProtectedFieldsConfig, class: &str, entity: &str) -> Vec<String> {
336        config
337            .get(class)
338            .and_then(|e| e.get(entity))
339            .cloned()
340            .unwrap_or_default()
341    }
342
343    /// The exposure this merge exists to prevent. A deployment protecting one of its own classes
344    /// and never mentioning `_User` must still protect `email`, or every user's address becomes
345    /// readable by every other user without that appearing anywhere in the configuration.
346    #[test]
347    fn configuring_an_unrelated_class_still_protects_user_email() {
348        let mut configured = ProtectedFieldsConfig::new();
349        let mut post = IndexMap::new();
350        post.insert("*".to_string(), vec!["secret".to_string()]);
351        configured.insert("Post".to_string(), post);
352
353        merge_protected_fields_defaults(&mut configured, true);
354
355        assert_eq!(fields(&configured, "_User", "*"), vec!["email".to_string()]);
356        assert_eq!(fields(&configured, "Post", "*"), vec!["secret".to_string()]);
357    }
358
359    /// Present but for a different entity key: the default `*` is added alongside rather than
360    /// displacing what was configured.
361    #[test]
362    fn a_user_block_for_another_entity_gains_the_default_star() {
363        let mut configured = ProtectedFieldsConfig::new();
364        let mut user = IndexMap::new();
365        user.insert("authenticated".to_string(), vec!["phone".to_string()]);
366        configured.insert("_User".to_string(), user);
367
368        merge_protected_fields_defaults(&mut configured, true);
369
370        assert_eq!(fields(&configured, "_User", "*"), vec!["email".to_string()]);
371        assert_eq!(
372            fields(&configured, "_User", "authenticated"),
373            vec!["phone".to_string()]
374        );
375    }
376
377    /// Same entity key: a set union, and `email` is not duplicated if it was already named.
378    #[test]
379    fn the_same_entity_key_is_unioned_without_duplicating() {
380        let mut configured = ProtectedFieldsConfig::new();
381        let mut user = IndexMap::new();
382        user.insert(
383            "*".to_string(),
384            vec!["phone".to_string(), "email".to_string()],
385        );
386        configured.insert("_User".to_string(), user);
387
388        merge_protected_fields_defaults(&mut configured, true);
389
390        assert_eq!(
391            fields(&configured, "_User", "*"),
392            vec!["phone".to_string(), "email".to_string()]
393        );
394    }
395
396    /// `protectedFieldsOwnerExempt == false` is the one case where a configured entity key stands
397    /// alone (`ParseServer.ts:664-666`). The operator asked for their list to apply to everyone
398    /// including the owner, so a default they did not write is not folded in.
399    #[test]
400    fn owner_exempt_false_leaves_a_configured_entity_key_alone() {
401        let mut configured = ProtectedFieldsConfig::new();
402        let mut user = IndexMap::new();
403        user.insert("*".to_string(), vec!["phone".to_string()]);
404        configured.insert("_User".to_string(), user);
405
406        merge_protected_fields_defaults(&mut configured, false);
407
408        assert_eq!(fields(&configured, "_User", "*"), vec!["phone".to_string()]);
409    }
410
411    /// But an absent class is still filled in wholesale even then: upstream's early return is
412    /// reached only when the entity key is already present.
413    #[test]
414    fn owner_exempt_false_still_fills_in_an_absent_class() {
415        let mut configured = ProtectedFieldsConfig::new();
416        let mut post = IndexMap::new();
417        post.insert("*".to_string(), vec!["secret".to_string()]);
418        configured.insert("Post".to_string(), post);
419
420        merge_protected_fields_defaults(&mut configured, false);
421
422        assert_eq!(fields(&configured, "_User", "*"), vec!["email".to_string()]);
423    }
424}