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}