Skip to main content

parse_rust_rest/
class_security.rs

1//! `enforceRoleSecurity`: the classes a client may not address at all.
2//!
3//! This lives in the pipeline crate rather than in a route handler, and the layer is the point.
4//! Upstream calls it from both `rest.js` entry points **and** from the `RestQuery` constructor
5//! (`RestQuery.js:54`), and the include path builds a `RestQuery` (`RestQuery.js:1250-1258`), so
6//! an included read is checked too. A router-level copy is checked on the request a client sent
7//! and not on the reads that request fans out into, which is the same defect the `_Session`
8//! narrowing had.
9
10use parse_rust_core::{ErrorCode, ErrorDetail, ParseError};
11
12/// Classes a client may not address, reproduced from `enforceRoleSecurity`
13/// (`SharedRest.js:14-55`).
14///
15/// Note what is **not** here. `_Role` and `_Session` are absent upstream despite a stale comment
16/// on the function saying otherwise: role reads and writes go through ordinary CLP plus ACL.
17/// `_User` is absent too. The two additions below are stated rather than silent.
18pub fn enforce_class_security(
19    class_name: &str,
20    privileged: bool,
21    operation: &str,
22    detail: ErrorDetail,
23) -> Result<(), ParseError> {
24    if privileged {
25        return Ok(());
26    }
27
28    // `_Installation`, and only for two of the five operations. The message says `installation
29    // collection` in lower case rather than naming the class, which is upstream's string.
30    if class_name == "_Installation" && matches!(operation, "delete" | "find") {
31        return Err(ParseError::permission_denied(
32            ErrorCode::OperationForbidden,
33            format!(
34                "Clients aren't allowed to perform the {operation} operation on the installation collection."
35            ),
36            detail,
37        ));
38    }
39
40    let forbidden = MASTER_ONLY_CLASSES.contains(&class_name)
41        || class_name.starts_with("_Join:")
42        // **Two deliberate additions, both fail-closed over a subsystem that does not exist yet.**
43        //
44        // A `_Session` write reaches `RestWrite.handleSession` upstream (`RestWrite.js:1221-1292`),
45        // which never stores the client's body: it mints a real session for the authenticated
46        // caller and refuses a client-chosen `sessionToken`, `user`, `expiresAt` or `createdWith`.
47        // parse-rust has no such stage, so allowing the write would let any client insert a
48        // `_Session` row carrying a token of its choosing, which is account takeover rather than a
49        // missing feature. `DELETE /sessions/:objectId` is served by its own narrowed path.
50        //
51        // A `_User` **create or delete** reaches stages parse-rust does not have. `transformUser`
52        // on a create validates the username, generates one when absent, enforces the private ACL
53        // and mints a session, so a create through this route would produce a user with no
54        // username and no session; signup is `POST /users`. A delete additionally has to revoke
55        // the user's sessions, which is not wired here.
56        //
57        // **Update is allowed**, because it is what `user.save()` on an existing user compiles to
58        // in every SDK, and the stages it needs do exist: the password is hashed by
59        // `prepare_user_write`, `_hashed_password` and every other reserved key are refused before
60        // the body is decoded, uniqueness is enforced by the `username` and `email` unique indexes,
61        // and the ACL written at signup already restricts the row to its owner. The password-change
62        // followup, revoking sessions and minting a replacement, is handled by the route.
63        || (class_name == crate::pipeline::SESSION_CLASS && matches!(operation, "create" | "update" | "delete"))
64        || (class_name == crate::pipeline::USER_CLASS
65            && matches!(operation, "create" | "delete"));
66
67    if forbidden {
68        return Err(ParseError::permission_denied(
69            ErrorCode::OperationForbidden,
70            format!(
71                "Clients aren't allowed to perform the {operation} operation on the {class_name} collection."
72            ),
73            detail,
74        ));
75    }
76    Ok(())
77}
78
79/// `classesWithMasterOnlyAccess` (`SharedRest.js:1-10`), in upstream's order.
80const MASTER_ONLY_CLASSES: [&str; 8] = [
81    "_JobStatus",
82    "_PushStatus",
83    "_Hooks",
84    "_GlobalConfig",
85    "_GraphQLConfig",
86    "_JobSchedule",
87    "_Audience",
88    "_Idempotency",
89];
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    /// `enableSanitizedErrorResponse: true`, the upstream default.
96    const WITHHELD: ErrorDetail = ErrorDetail::Withheld;
97    /// `enableSanitizedErrorResponse: false`. The detailed strings are contract under it.
98    const DISCLOSED: ErrorDetail = ErrorDetail::Disclosed;
99
100    /// Upstream's `auth.isMaster` gate. A bool rather than a credential type, because this
101    /// function is below the layer that knows how a caller proved it.
102    const CLIENT: bool = false;
103    const MASTER: bool = true;
104
105    #[test]
106    fn role_and_session_reads_are_no_longer_denylisted() {
107        // 0.1.0's substitute for CLP. Both classes now go through ordinary CLP plus ACL, and
108        // `_Session` additionally through the owner narrowing.
109        assert!(enforce_class_security("_Role", CLIENT, "find", WITHHELD).is_ok());
110        assert!(enforce_class_security("_Role", CLIENT, "create", WITHHELD).is_ok());
111        assert!(enforce_class_security("_Session", CLIENT, "find", WITHHELD).is_ok());
112        assert!(enforce_class_security("_Session", CLIENT, "get", WITHHELD).is_ok());
113    }
114
115    #[test]
116    fn the_master_only_list_is_upstreams() {
117        for class in MASTER_ONLY_CLASSES {
118            let e = enforce_class_security(class, CLIENT, "find", DISCLOSED).unwrap_err();
119            assert_eq!(e.code, ErrorCode::OperationForbidden);
120            assert_eq!(
121                e.message,
122                format!("Clients aren't allowed to perform the find operation on the {class} collection.")
123            );
124            // The regime a stock deployment runs: same code, no reason.
125            let withheld = enforce_class_security(class, CLIENT, "find", WITHHELD).unwrap_err();
126            assert_eq!(withheld.code, ErrorCode::OperationForbidden);
127            assert_eq!(withheld.message, "Permission denied");
128            assert!(enforce_class_security(class, MASTER, "find", WITHHELD).is_ok());
129        }
130    }
131
132    #[test]
133    fn installation_is_restricted_on_two_operations_only() {
134        for op in ["delete", "find"] {
135            let e = enforce_class_security("_Installation", CLIENT, op, DISCLOSED).unwrap_err();
136            assert_eq!(
137                e.message,
138                format!(
139                    "Clients aren't allowed to perform the {op} operation on the installation collection."
140                ),
141                "the message names the collection in lower case, not the class"
142            );
143            assert_eq!(
144                enforce_class_security("_Installation", CLIENT, op, WITHHELD)
145                    .unwrap_err()
146                    .message,
147                "Permission denied"
148            );
149        }
150        for op in ["get", "create", "update"] {
151            assert!(enforce_class_security("_Installation", CLIENT, op, WITHHELD).is_ok());
152        }
153    }
154
155    #[test]
156    fn join_tables_are_never_client_addressable() {
157        let e = enforce_class_security("_Join:users:_Role", CLIENT, "find", WITHHELD).unwrap_err();
158        assert_eq!(e.code, ErrorCode::OperationForbidden);
159    }
160
161    /// The two additions, and the reason each exists.
162    #[test]
163    fn session_and_user_writes_are_refused_but_reads_are_not() {
164        // `_Session` is closed to clients on every write. There is no stage that could make one
165        // safe: the token is the credential, so a client-authored row is a forged credential.
166        for op in ["create", "update", "delete"] {
167            assert_eq!(
168                enforce_class_security(crate::pipeline::SESSION_CLASS, CLIENT, op, WITHHELD)
169                    .unwrap_err()
170                    .code,
171                ErrorCode::OperationForbidden,
172                "_Session/{op}"
173            );
174        }
175
176        // `_User` is closed to a client create and delete, and **open to update**, which is what
177        // `user.save()` on an existing user compiles to in every SDK. The row's own ACL is what
178        // stops one user saving another's; the class guard is not carrying that weight.
179        for op in ["create", "delete"] {
180            assert_eq!(
181                enforce_class_security(crate::pipeline::USER_CLASS, CLIENT, op, WITHHELD)
182                    .unwrap_err()
183                    .code,
184                ErrorCode::OperationForbidden,
185                "_User/{op}"
186            );
187        }
188        assert!(
189            enforce_class_security(crate::pipeline::USER_CLASS, CLIENT, "update", WITHHELD).is_ok(),
190            "a client may save its own user row"
191        );
192
193        for class in [crate::pipeline::SESSION_CLASS, crate::pipeline::USER_CLASS] {
194            for op in ["find", "get"] {
195                assert!(
196                    enforce_class_security(class, CLIENT, op, WITHHELD).is_ok(),
197                    "{class}/{op}"
198                );
199            }
200            // Master is exempt, which is what lets the dashboard write both.
201            assert!(enforce_class_security(class, MASTER, "create", WITHHELD).is_ok());
202        }
203    }
204}