parse-stack-next 5.0.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
data/Gemfile CHANGED
@@ -13,6 +13,9 @@ group :test, :development do
13
13
  gem 'minitest-reporters'
14
14
  gem "pry"
15
15
  gem "yard", ">= 0.9.11"
16
+ # Rack 3 removed Rack::Server (used by `yard server`); the rackup gem
17
+ # restores it. Drop this once YARD's server adapter stops referencing it.
18
+ gem "rackup"
16
19
  gem "redcarpet"
17
20
  gem "rufo"
18
21
  gem "mongo"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.0.1)
4
+ parse-stack-next (5.1.0)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
@@ -57,7 +57,7 @@ GEM
57
57
  faraday (~> 2.5)
58
58
  net-http-persistent (>= 4.0.4, < 5)
59
59
  fiber-storage (1.0.1)
60
- graphql (2.6.2)
60
+ graphql (2.6.3)
61
61
  base64
62
62
  fiber-storage
63
63
  logger
@@ -82,7 +82,7 @@ GEM
82
82
  minitest (>= 5.0, < 7)
83
83
  ruby-progressbar
84
84
  moneta (1.6.0)
85
- mongo (2.24.0)
85
+ mongo (2.24.1)
86
86
  base64
87
87
  bson (>= 4.14.1, < 6.0.0)
88
88
  mustermann (3.1.1)
@@ -104,7 +104,7 @@ GEM
104
104
  psych (5.3.1)
105
105
  date
106
106
  stringio
107
- puma (8.0.1)
107
+ puma (8.0.2)
108
108
  nio4r (~> 2.0)
109
109
  rack (3.2.6)
110
110
  rack-protection (4.2.1)
@@ -116,6 +116,8 @@ GEM
116
116
  rack (>= 3.0.0)
117
117
  rack-test (2.2.0)
118
118
  rack (>= 1.3)
119
+ rackup (2.3.1)
120
+ rack (>= 3)
119
121
  rake (13.4.2)
120
122
  rdoc (7.2.0)
121
123
  erb
@@ -145,7 +147,7 @@ GEM
145
147
  concurrent-ruby (~> 1.0)
146
148
  uri (1.1.1)
147
149
  webrick (1.9.2)
148
- yard (0.9.43)
150
+ yard (0.9.44)
149
151
 
150
152
  PLATFORMS
151
153
  aarch64-linux-gnu
@@ -170,6 +172,7 @@ DEPENDENCIES
170
172
  pry
171
173
  puma
172
174
  rack-test
175
+ rackup
173
176
  rake
174
177
  redcarpet
175
178
  redis
data/README.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [parse-stack-next](https://github.com/neurosynq/parse-stack-next) is a Ruby client SDK, REST client, and Active Model ORM for [Parse Server](http://parseplatform.org/), combining a low-level API client, a query engine, an object-relational mapper (ORM), and a Cloud Code Webhooks rack application in a single gem.
6
6
 
7
+ ### What's new in 5.1
8
+
9
+ - **`Parse::File` URL normalization + presigned-URL stash** — `Parse::File#url=` and `attributes=` now strip signed-URL query parameters (`X-Amz-Signature`, `AWSAccessKeyId`, `Key-Pair-Id`, etc.) before storage; the bare canonical URL lands in `@url`, and the original signed URL is stashed in `file.presigned_url` with a data-driven expiry in `file.presigned_url_expires_at`. New `file.presigned_url_valid?(buffer: 60)` predicate, configurable `Parse::File.signed_url_policy = :strip | :raise`, and `Parse::File.log_filter` / `log_filter_strict` regexes for `lograge` / Sentry / Honeybadger scrubbers. `Parse::File#inspect` no longer emits the URL — see CHANGELOG for the error-reporter payload migration callout
10
+ - **`Parse::Lock` — public TTL-bounded mutual-exclusion primitive** — `Parse::Lock.acquire(key, ttl:, wait:) { … }` exposes the Redis-backed lock previously hidden inside `first_or_create!` as a first-class API. In-process `Mutex` fallback for memory-backed caches, fails closed on backend errors, HMAC-keyed via `PARSE_STACK_LOCK_SECRET`, namespace-separated from `first_or_create!` so the two cannot collide
11
+ - **LiveQuery ergonomics** — autoloaded (no explicit `require 'parse/live_query'`); connections are **ACL-scoped by default** (build an admin, ACL-bypassing connection explicitly with `Parse::LiveQuery::Client.new(use_master_key: true)` — master-key authorization is per-connection, not per-subscription); `Query#subscribe` / `Klass.subscribe` accept a block yielded the `Subscription` *before* the subscribe frame is sent so `sub.on(:create) { … }` callbacks are wired before any server event can arrive; `Parse::LiveQuery.run_until_signal!(client:) { … }` is a signal-safe shutdown helper for long-running consumers
12
+ - **Image embeddings** — new `embed_image` class macro for `:file`-typed source properties plus `Voyage#embed_image` (`voyage-multimodal-3`, 1024-dim) and `Cohere#embed_image` (`embed-v4.0`, 1536-dim). URL-only routing in v5.1 (bytes-fetch with MIME-sniff lands later); operator-gated via the `Parse::Embeddings.trust_provider_url_fetch = "PROVIDER_EGRESS_VERIFIED"` sentinel plus a `Parse::Embeddings.allowed_image_hosts` CDN allowlist
13
+ - **Tenant-aware cache namespacing** — `Parse.with_cache_tenant(scope) { … }` composes the tenant into the response-cache key as `<base>:T:<tenant>:…` so a multi-tenant app sharing one Redis gets per-tenant key isolation and per-tenant SCAN-delete eviction without per-tenant `Parse::Client.new` plumbing. Fiber-local, restored on block exit, AS::N payloads carry `:cache_tenant`
14
+ - **`_User` field-visibility DSL** — `Parse::User.master_only_fields(*fields)` and `Parse::User.self_visible_fields(*fields, via: :self)` declare admin-only and owner-only field protections on `_User`. Requires Parse Server's `protectedFieldsOwnerExempt: false` server option (the SDK emits a one-time advisory at class declaration so the dependency is surfaced before deploy). Parse Server's default for this option is changing to `false` in a future version; until your server adopts that default, set it explicitly
15
+ - **`Parse::Installation` `belongs_to :user`** — read `installation.user` to find which user a device is currently signed in as. Symmetric `Parse::User#has_many :installations` for targeted-push grouping (master-key-only by Parse Server design; see the YARD for the owner-identity caveat)
16
+ - **`Parse.setup` / `live_query_url:` fixes** — `Parse.setup` is no longer a silent no-op on re-invocation; `Parse.setup(live_query_url: …)` and `live_query: { … }` options no longer raise `ArgumentError`; `ws://` against non-loopback hosts is refused unless `live_query: { allow_insecure: true }` is also passed
17
+ - **MCP `structuredContent` for 5 more tools** — `aggregate`, `export_data`, `atlas_text_search`, `atlas_autocomplete`, `atlas_faceted_search` now emit `structuredContent` with declared `outputSchema`s (sixteen of the built-in catalog now structured)
18
+ - **New ACL / CLP / `protectedFields` guide** — [`docs/acl_clp_guide.md`](./docs/acl_clp_guide.md) is the canonical reference for the five enforcement layers, the system-class CLP matrix (including the hardcoded master-key-only classes), the `_User` field-visibility recipe, role hierarchy direction, and the REST-aggregate vs `Parse::MongoDB.aggregate` enforcement asymmetry
19
+
20
+ See [CHANGELOG.md](./CHANGELOG.md) for the full 5.1 entry, including breaking changes, migration callouts, and the round-by-round security review notes.
21
+
7
22
  ### What's new in 5.0
8
23
 
9
24
  - **RAG foundation** — `:vector` property type, `Parse::Embeddings` provider registry shipping built-in adapters for OpenAI, Cohere (v3 + v4.0 Matryoshka text-mode), Voyage (incl. open-weight `voyage-4-nano` and `voyage-multimodal-3` text-mode), Jina v3/v4/v5/code, Qwen 3 (DashScope), and a generic `LocalHTTP` client for Ollama / LM Studio / vLLM / TEI. `Klass.find_similar(vector:/text:, k:)` over Atlas `$vectorSearch`, and an `embed` class macro that digest-tracks source fields so vectors only recompute when content changes
data/Rakefile CHANGED
@@ -563,7 +563,11 @@ end
563
563
 
564
564
  desc "Start the yard server"
565
565
  task "docs" do
566
- exec "rm -rf ./yard && yard server --reload"
566
+ # `-t docs/yard-template` is required: `yard server` does not honor
567
+ # the --template-path line in .yardopts (that's only read by
568
+ # `yard doc`), so the custom theme overlay only takes effect when
569
+ # the path is passed on the command line.
570
+ exec "rm -rf ./yard && yard server --reload -t docs/yard-template"
567
571
  end
568
572
 
569
573
  YARD::Rake::YardocTask.new do |t|
@@ -0,0 +1,553 @@
1
+ # ACL, CLP, and Field-Level Access in parse-stack-next
2
+
3
+ This is the canonical reference for who can do what to which records
4
+ in a Parse Server backed by parse-stack-next. It covers four layers
5
+ that compose into the effective permission decision, plus the places
6
+ where those layers are bypassed or hardcoded by Parse Server itself
7
+ and where parse-stack-next adds enforcement that the server doesn't.
8
+
9
+ For a narrower client-mode walkthrough (configuration, auth, CRUD),
10
+ see [`client_sdk_guide.md`](./client_sdk_guide.md). For deep dives on
11
+ the read paths that this guide references (Mongo-direct aggregation,
12
+ Atlas Search), see [`mongodb_direct_guide.md`](./mongodb_direct_guide.md)
13
+ and [`atlas_vector_search_guide.md`](./atlas_vector_search_guide.md).
14
+
15
+ ---
16
+
17
+ ## 1. The five layers, in order
18
+
19
+ When a non-master request hits Parse Server, the answer to "can this
20
+ operation proceed, and which fields come back?" is composed from:
21
+
22
+ 1. **CLP** — class-level: "is this operation even allowed on this
23
+ class for this caller?". Configurable per-class via
24
+ `Parse::Object.set_clp`, with hardcoded overrides for some system
25
+ classes (see §3.2).
26
+ 2. **ACL** — row-level: "given the operation is allowed, which rows
27
+ does this caller see / touch?". Stored on each row.
28
+ 3. **`protectedFields`** — read-side field stripping: "of the fields
29
+ on rows the caller can see, which ones does the server delete
30
+ before returning?". Configured under CLP.
31
+ 4. **Field guards (`guard :field, :master_only` / `:immutable` / ...)** —
32
+ write-side, parse-stack-next-only: "if a client tries to write
33
+ this field, silently revert the change". Enforced inside the SDK's
34
+ `_User`/class `beforeSave` webhook handler, NOT by Parse Server.
35
+ See §6.
36
+ 5. **Master key bypass** — master-key callers skip 1–4 entirely
37
+ except where Parse Server hardcodes master-only restrictions (see
38
+ §3.2 and §7).
39
+
40
+ If a layer denies access at step 1, step 2 never runs. If step 2
41
+ filters a row out, step 3 has nothing to strip from it. If step 4
42
+ isn't wired to a webhook, it is silently a no-op.
43
+
44
+ ---
45
+
46
+ ## 2. ACL — row-level
47
+
48
+ Every row carries an `ACL` field shaped as
49
+ `{ "<userId|roleName|*>": { "read": true, "write": true } }`. Parse
50
+ Server enforces it on every find/get/update/delete that does not use
51
+ the master key.
52
+
53
+ ### 2.1 Declaring a default policy for a class
54
+
55
+ ```ruby
56
+ class Post < Parse::Object
57
+ acl_policy public_read: true, public_write: false, default_roles: ["Editor"]
58
+ end
59
+ ```
60
+
61
+ `acl_policy` writes the declared ACL onto every newly-created instance
62
+ of the class. It does NOT retroactively re-ACL existing rows.
63
+
64
+ ### 2.2 Building an ACL imperatively on a record
65
+
66
+ ```ruby
67
+ post = Post.new(body: "draft")
68
+ post.acl.apply(user.id, true, false) # owner can read, not write
69
+ post.acl.apply_role("Admin", true, true)
70
+ post.acl.everyone(false, false) # remove public access
71
+ post.save
72
+ ```
73
+
74
+ `Parse::ACL#apply` accepts a `Parse::User`, a pointer to a user, or
75
+ a `Parse::Role` (with automatic role-name expansion). Passing
76
+ `Parse::Pointer` to a user expands the user's role memberships when
77
+ checking `readable_by?`/`writeable_by?` (see `Parse::Object`).
78
+
79
+ ### 2.3 What clients see under ACL
80
+
81
+ A logged-in user only sees rows that have `read: true` for either
82
+ the user, one of their roles (recursively, see §5), or `"*"` (public).
83
+ The server-side filtering happens inside the find/get query before
84
+ the wire response is built; the SDK is not consulted.
85
+
86
+ ACL does NOT apply to REST `POST /aggregate/<Class>` — see §7.
87
+
88
+ ---
89
+
90
+ ## 3. CLP — class-level
91
+
92
+ CLPs gate whether an operation is even allowed on a class for a
93
+ caller, before ACL is consulted. CLP is master-key-only to configure
94
+ (via the Schema API or the SDK's migration tooling).
95
+
96
+ ### 3.1 The DSL
97
+
98
+ ```ruby
99
+ class Article < Parse::Object
100
+ # Coarse mode-per-op:
101
+ set_class_access(
102
+ find: :public,
103
+ get: :public,
104
+ create: :authenticated,
105
+ update: "Editor", # role name; auto-prefixed "role:"
106
+ delete: ["Editor", "Admin"],
107
+ count: :master,
108
+ addField: :master,
109
+ )
110
+
111
+ # Or fine-grained:
112
+ set_clp :create, public: false, roles: ["Editor"], requires_authentication: true
113
+
114
+ # Sweeping defaults:
115
+ master_only_class! # everything master-only, then selectively open
116
+ unlistable_class! # find + count master-only; rest unchanged
117
+ end
118
+ ```
119
+
120
+ A `set_clp(op)` with no positional args yields the master-only empty
121
+ `{}` permission for that op.
122
+
123
+ ### 3.2 The system-class matrix
124
+
125
+ Several Parse Server system classes either ignore CLP entirely or
126
+ layer it under hardcoded behavior. This is non-negotiable: if you
127
+ call `set_clp` on them, Parse Server will silently do what its own
128
+ REST handler hardcodes regardless of the value you sent.
129
+
130
+ | Class | CLP actually configurable? |
131
+ |--------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
132
+ | `_User` | Yes, but layered under hardcoded protections (password never returned, `authData` stripped from non-master finds, unauth update requires matching session token, email/username lowercasing, owner-exempt `protectedFields`). |
133
+ | `_Role` | Yes, layered under role-name regex, relation validation, and hierarchy integrity checks. |
134
+ | `_Installation` | **Partial.** Only `get`, `count`, `addField`, and `protectedFields` respond to CLP. `find` and `delete` are hardcoded master-only by Parse Server's `RestQuery` / `RestWrite` constructors (they throw `OPERATION_FORBIDDEN` for non-master callers regardless of CLP); `create` and `update` are gated by the `X-Parse-Installation-Id` header, not CLP. The SDK emits a one-time advisory when CLP is configured on `_Installation`. |
135
+ | `_Session` | Mostly redundant — non-master `find` queries are silently rewritten by `RestQuery.js` to scope by `user = <current user>`. `find` also requires a session token. You cannot grant cross-user session visibility through CLP. |
136
+ | `_JobStatus`, `_PushStatus`, `_Hooks`, `_GlobalConfig`, `_GraphQLConfig`, `_JobSchedule`, `_Audience`, `_Idempotency`, `_Join:*` (all relation join tables) | **No.** Hardcoded master-key-only at the REST layer (`SharedRest.js`). CLP changes are ignored. |
137
+
138
+ Practical consequences when you're building a client-mode app:
139
+
140
+ * Don't query `_JobStatus`, `_PushStatus`, `_Audience`, `_JobSchedule`,
141
+ or any `_Join:*` table from the client. The model classes
142
+ (`Parse::JobStatus`, `Parse::PushStatus`, `Parse::Audience`,
143
+ `Parse::JobSchedule`) are server-side helpers — auto-promote to a
144
+ master-key client or expose them through a Cloud Code function.
145
+ * `_Installation` is special-cased — see §3.3 for the full
146
+ CLP-vs-hardcoded matrix on that class.
147
+ * On `_Session`, you don't need ACL or CLP to scope queries to the
148
+ caller; Parse Server already does that.
149
+ * On `_User`, never assume CLP alone gates a flow. Password changes,
150
+ email verification, and session-token rotation have their own
151
+ paths; they fire even if your CLP looks restrictive.
152
+
153
+ ### 3.3 `_Installation` — the hardcoded asymmetry
154
+
155
+ | Operation | Behavior |
156
+ |------------|-------------------------------------------------------------------------------------------|
157
+ | `find` | **Master key only. Hardcoded.** `set_clp :find, ...` is effectively ignored by the server. |
158
+ | `delete` | **Master key only. Hardcoded.** `set_clp :delete, ...` is effectively ignored by the server. |
159
+ | `create` | Open to anonymous clients (`X-Parse-Installation-Id` is the credential). Locking via CLP breaks first-launch device registration. |
160
+ | `update` | Open when the request's `installationId` matches the record; else master key. Locking via CLP breaks silent device-token refresh and channel subscribe/unsubscribe before login. |
161
+ | `get` | CLP applies normally. Safe to tighten — SDKs cache `currentInstallation` locally and don't normally GET it from the server. |
162
+ | `count` | CLP applies normally. Safe to tighten to master-only. |
163
+ | `addField` | CLP applies normally. Safe to tighten to master-only as a hardening default. |
164
+
165
+ If your app genuinely requires login before any installation write,
166
+ put the policy in `beforeSave('_Installation')` Cloud Code rather
167
+ than in CLP:
168
+
169
+ ```js
170
+ Parse.Cloud.beforeSave('_Installation', ({ user, master }) => {
171
+ if (!master && !user) throw 'login required';
172
+ });
173
+ ```
174
+
175
+ **Heads up — device-token dedup auth.** When two `_Installation`
176
+ records share the same `deviceToken`, Parse Server deduplicates them.
177
+ Historically that dedup ran with permissions bypassed; the
178
+ `installation.duplicateDeviceTokenActionEnforceAuth` option (default
179
+ **changing to `true`** in a future version) makes the dedup honor the
180
+ caller's auth context — and the resulting ACL/CLP — instead. This is
181
+ server-side behavior the SDK doesn't drive, but it can change which
182
+ record survives a token collision for non-master callers; set the
183
+ option to `true` to opt in now, or `false` to keep the old
184
+ permission-bypassing behavior.
185
+
186
+ ---
187
+
188
+ ## 4. `protectedFields` — read-side field stripping
189
+
190
+ Server-side strip list applied to query/get responses for non-master
191
+ callers. Configured per class, per group:
192
+
193
+ ```ruby
194
+ class User < Parse::Object
195
+ protect_fields "*", [:email, :phone]
196
+ protect_fields "role:Admin", []
197
+ protect_fields "userField:owner", []
198
+ end
199
+ ```
200
+
201
+ Group resolution is **intersection**: a field is hidden only if it is
202
+ listed under every group the caller matches. So a user with role
203
+ `Admin` who matches both `*` (which strips `[:email, :phone]`) and
204
+ `role:Admin` (which strips `[]`) sees nothing stripped, because the
205
+ intersection of `[:email, :phone]` and `[]` is empty.
206
+
207
+ An empty array `[]` means "this group sees everything".
208
+
209
+ ### 4.1 The "write but not read" pattern
210
+
211
+ ```ruby
212
+ protect_fields "*", [:secret_token]
213
+ ```
214
+
215
+ A client can write `secret_token` on create/update (Parse Server
216
+ accepts it in the POST body), but a subsequent GET/find from the
217
+ client omits it. Master-key fetch still sees it, confirming
218
+ persistence.
219
+
220
+ #### `protectedFieldsSaveResponseExempt` — stripping the write response too
221
+
222
+ Historically Parse Server stripped `protectedFields` only from
223
+ query/get responses; the create/update response echoed the full saved
224
+ row, so the value briefly came back to the client in the save reply
225
+ even though a later read would hide it. Parse Server added the
226
+ `protectedFieldsSaveResponseExempt` option to close that gap, and its
227
+ default **will change to `false` in a future version**. With it set to
228
+ `false`, `protectedFields` are stripped from write (create/update)
229
+ responses too — consistent with how they are already stripped from
230
+ reads. Set it now to opt in early:
231
+
232
+ ```js
233
+ // parse-server config — strip protected fields from write responses
234
+ protectedFieldsSaveResponseExempt: false
235
+ ```
236
+
237
+ parse-stack-next is compatible with either setting and never loses
238
+ local data: `Parse::Object#save` applies the server's response as a
239
+ merge (it only overwrites fields the response actually contains), so a
240
+ stripped protected field simply keeps the value you assigned locally —
241
+ nothing is clobbered.
242
+
243
+ This does **not** affect Cloud Code. A `beforeSave` / `afterSave`
244
+ trigger runs server-side before the response is serialized, so it
245
+ still sees, modifies, and persists protected fields normally — the
246
+ stripping happens only on the reply the *client* receives. The single
247
+ practical consequence is for the client's local view: if a `beforeSave`
248
+ trigger rewrites a protected field, that new value is now stripped from
249
+ the save reply just as it is from a read, so the SDK's in-memory object
250
+ won't reflect the server-side change until a master-key re-fetch. The
251
+ value is still persisted correctly.
252
+
253
+ ### 4.2 `_User` field visibility and `protectedFieldsOwnerExempt`
254
+
255
+ Parse Server's `protectedFieldsOwnerExempt` option (historical default
256
+ **true**) silently exempts the owning user from every `protectedFields`
257
+ rule on `_User`. With that default in place, `protect_fields "*",
258
+ [:risk_score]` on `_User` does NOT hide `risk_score` from the user
259
+ themselves on their own row — they always see it.
260
+
261
+ > **Heads up:** Parse Server's default for `protectedFieldsOwnerExempt`
262
+ > **is changing to `false`** in a future version, which makes
263
+ > `protectedFields` apply consistently to the user's own `_User` row
264
+ > (the same as every other class) without extra config. Until your
265
+ > server adopts that default you must set `protectedFieldsOwnerExempt:
266
+ > false` explicitly for the helpers below to work; once it does, the
267
+ > explicit setting becomes a harmless no-op.
268
+
269
+ The fix has two server-side moving parts:
270
+
271
+ 1. Start Parse Server with `protectedFieldsOwnerExempt: false`.
272
+ 2. Add a self-pointer field on `_User` (default name `:self`),
273
+ populated by a `beforeSave('_User')` Cloud Code trigger:
274
+
275
+ ```js
276
+ Parse.Cloud.beforeSave(Parse.User, (req) => {
277
+ const u = req.object;
278
+ if (!u.get('self')) u.set('self', u);
279
+ });
280
+ ```
281
+
282
+ The trigger only fires on save, so **pre-existing user rows also
283
+ need a one-shot backfill** before `self_visible_fields` works for
284
+ them. Without the backfill, those rows never match the
285
+ `userField:self` group and the self-visible fields stay hidden
286
+ from the user themselves on their own row. A master-key script
287
+ like the following works:
288
+
289
+ ```ruby
290
+ Parse::User.all(:self.null => true, batch_size: 200).each_slice(200) do |batch|
291
+ batch.each { |u| u.self = u; u.save(use_master_key: true) }
292
+ end
293
+ ```
294
+
295
+ With those in place, parse-stack-next exposes:
296
+
297
+ ```ruby
298
+ class Parse::User
299
+ property :my_opinion_of_them, :string # admin metadata
300
+ property :favorite_color, :string # private profile
301
+
302
+ master_only_fields :my_opinion_of_them
303
+ self_visible_fields :favorite_color, via: :self
304
+ end
305
+ ```
306
+
307
+ That expands to:
308
+
309
+ ```ruby
310
+ protect_fields "*", ["myOpinionOfThem", "favoriteColor"]
311
+ protect_fields "userField:self", ["myOpinionOfThem"]
312
+ ```
313
+
314
+ Resolution (intersection across matching groups):
315
+
316
+ | Caller | Matching groups | Hidden (intersection) | Visible |
317
+ |-----------------|--------------------------|------------------------------------|------------------|
318
+ | Other user | `*` | `myOpinionOfThem`, `favoriteColor` | neither |
319
+ | The user itself | `*` ∩ `userField:self` | `myOpinionOfThem` only | `favoriteColor` |
320
+ | Master key | none (master bypasses) | nothing | both |
321
+
322
+ If you call raw `protect_fields` on `_User` directly, the SDK emits a
323
+ one-time advisory pointing at the helpers above and reminding you to
324
+ set `protectedFieldsOwnerExempt: false` — without that flag, the
325
+ default owner-exempt behavior silently negates a lot of what raw
326
+ `protect_fields` looks like it's doing.
327
+
328
+ `protectedFieldsOwnerExempt` only affects `_User`. On your own
329
+ classes, pointer-based group targeting (`userField:owner`) is the
330
+ clean way to do "owner sees their own protected field".
331
+
332
+ ---
333
+
334
+ ## 5. Roles and the hierarchy direction gotcha
335
+
336
+ `Parse::Role` rows carry two relations:
337
+
338
+ * `users` — direct members.
339
+ * `roles` — **child roles whose users inherit access through this role.**
340
+
341
+ That second one is the trap. If you want SuperAdmin to inherit
342
+ everything Admin can do, you put **SuperAdmin into Admin's `roles`
343
+ relation**, not the reverse.
344
+
345
+ The SDK exposes a direction-explicit helper:
346
+
347
+ ```ruby
348
+ super_role = Parse::Role.find_or_create("SuperAdmin")
349
+ super_role.add_users(super_user).save
350
+ super_role.inherits_capabilities_from!(admin_role)
351
+ # Under the hood: adds SuperAdmin to Admin's `roles` relation.
352
+ ```
353
+
354
+ The older `add_child_role` goes the other direction and is preserved
355
+ for backwards compat. Reach for `inherits_capabilities_from!`
356
+ instead — getting the direction wrong is a privilege-escalation bug.
357
+
358
+ For ACL/CLP purposes, the server's role-graph expansion walks this
359
+ relation when resolving the caller's effective roles. So a row
360
+ ACL'd to `role:Admin` becomes readable by SuperAdmin members
361
+ automatically; you do not need to add `role:SuperAdmin` to every
362
+ Admin-readable row.
363
+
364
+ ---
365
+
366
+ ## 6. Field guards (`guard :field, :master_only`) — SDK-only, webhook-required
367
+
368
+ This is parse-stack-next's write-side enforcement. Parse Server
369
+ itself has no equivalent: `protectedFields` only affects reads, not
370
+ writes.
371
+
372
+ ```ruby
373
+ class Project < Parse::Object
374
+ property :slug, :string
375
+ property :created_by, :pointer
376
+
377
+ guard :created_by, :master_only
378
+ guard :slug, :external_id, :immutable
379
+ end
380
+ ```
381
+
382
+ The modes:
383
+
384
+ * `:master_only` — never writable by clients. Client-supplied values
385
+ are reverted. Master key bypasses.
386
+ * `:immutable` — writable on create, reverted on any subsequent
387
+ client update. Master key bypasses updates.
388
+ * `:always_immutable` — same as `:immutable`, plus master-key
389
+ updates are also reverted. Useful for one-way state transitions.
390
+ * `:set_once` — writable while the persisted value is blank, then
391
+ locked forever — including for master-key writes. Useful for
392
+ derived fields populated by an `after_create` callback (e.g.
393
+ `parse_reference`).
394
+
395
+ ### 6.1 This only works if the webhook is wired
396
+
397
+ Field guards are enforced inside the SDK's `beforeSave` webhook
398
+ handler. If your Parse Server deployment does not have its webhook
399
+ HTTPS callback pointed at a Ruby process running
400
+ `Parse::Webhooks`, the guards are silently a no-op. The SDK auto-
401
+ registers a stub handler when `guard` is declared
402
+ (`ensure_field_guards_webhook!`), but it cannot install the Parse
403
+ Server side of the wiring for you.
404
+
405
+ The reverts are silent successful no-ops from the client's
406
+ perspective: the save returns 200, the guarded field simply isn't
407
+ written. A DEBUG-level log line is emitted for diagnosis but
408
+ nothing is raised.
409
+
410
+ ### 6.2 Where field guards fit relative to the other layers
411
+
412
+ * **CLP** says "is `update` even allowed on this class?".
413
+ * **ACL** says "given `update` is allowed, can this caller write
414
+ to THIS row?".
415
+ * **`protectedFields`** strips on the way back out.
416
+ * **Field guards** revert specific field changes inside the
417
+ `beforeSave` webhook before the row reaches the persistent store.
418
+
419
+ A client whose CLP/ACL allow the update will get a successful
420
+ response with the guarded field NOT applied. They have no signal
421
+ that their write was reverted; design your client UX accordingly
422
+ (e.g. re-fetch the row after save if you need to surface the
423
+ canonical value).
424
+
425
+ ---
426
+
427
+ ## 7. Aggregate queries — the big enforcement asymmetry
428
+
429
+ Parse Server's REST `POST /aggregate/<Class>` endpoint **requires
430
+ the master key AND enforces NEITHER CLP nor ACL nor
431
+ `protectedFields`**. There is no session-token authorization model
432
+ on this endpoint. This is non-obvious and asymmetric with the rest
433
+ of Parse Server's REST surface:
434
+
435
+ | Endpoint | Auth model | CLP | ACL | `protectedFields` |
436
+ |-----------------------------------------|-----------------------|-----|-----|-------------------|
437
+ | `GET /classes/<Class>` (find) | session token | yes | yes | yes |
438
+ | `GET /classes/<Class>/<id>` (get) | session token | yes | yes | yes |
439
+ | `?count=1` | session token | yes | yes | yes |
440
+ | `POST /aggregate/<Class>` | **master key only** | no | no | no |
441
+
442
+ ### 7.1 Two aggregate paths in the SDK
443
+
444
+ parse-stack-next exposes two different aggregate code paths and they
445
+ have very different security postures:
446
+
447
+ **REST aggregate** — `Parse::Client#aggregate_pipeline`. Routes to
448
+ the Parse Server REST endpoint above. Master-key only, unscoped.
449
+ Safe ONLY for master-key agents and admin tools.
450
+
451
+ **Mongo-direct aggregate** — `Parse::MongoDB.aggregate`. Routes
452
+ directly to the underlying MongoDB driver. The SDK enforces
453
+ ACL (via `Parse::ACLScope`), CLP (via `Parse::CLPScope`), and
454
+ `protectedFields` itself in this code path. This is the only path
455
+ that supports scoped agents (`session_token:`, `acl_user:`,
456
+ `acl_role:`).
457
+
458
+ `Parse::Query#results_direct` / `#count_direct` and
459
+ `Parse::AtlasSearch.{search,autocomplete,faceted_search}` all route
460
+ through `Parse::MongoDB.aggregate` and inherit the SDK-side
461
+ enforcement.
462
+
463
+ ### 7.2 Auto-promotion for scoped agents
464
+
465
+ The SDK's built-in agent tools auto-promote `mongo_direct: false` to
466
+ `mongo_direct: true` for any scoped agent, so REST aggregate cannot
467
+ silently bypass enforcement. `acl_user:` and `acl_role:` agent
468
+ scopes have NO REST equivalent — Parse Server's REST has no "act as
469
+ user-pointer" or "act as role" affordance. The SDK auto-routes
470
+ those to mongo-direct; `request_opts` fails closed for them.
471
+
472
+ ### 7.3 If you find yourself writing this code
473
+
474
+ ```ruby
475
+ client.aggregate_pipeline(class_name, pipeline, session_token: token)
476
+ client.find_objects(class_name, where: …, session_token: token)
477
+ ```
478
+
479
+ Stop and consider whether the SDK-side enforcement layer should run
480
+ instead. The mongo-direct path is the only one with first-class ACL
481
+ + CLP + `protectedFields` enforcement for scoped agents.
482
+
483
+ ---
484
+
485
+ ## 8. Atlas Search
486
+
487
+ Atlas Search (`Parse::AtlasSearch.search`, `.autocomplete`,
488
+ `.faceted_search`) routes through `Parse::MongoDB.aggregate`, so it
489
+ inherits the SDK-side ACL + CLP + `protectedFields` enforcement
490
+ described in §7. From a security standpoint, an Atlas Search call
491
+ with a session token is treated like a `Parse::Query` with a session
492
+ token — same scoping, same field stripping.
493
+
494
+ The `$search` stage itself runs on the Atlas Search index and is not
495
+ filtered by ACL. The ACL filter is applied as a `$match` stage by
496
+ `Parse::ACLScope` after `$search`, before results are returned. If
497
+ you're seeing rows in search results that the caller shouldn't see,
498
+ verify (a) that the call went through `Parse::AtlasSearch` (not raw
499
+ `aggregate_pipeline`), and (b) that the session token was actually
500
+ threaded through to the call.
501
+
502
+ See [`atlas_vector_search_guide.md`](./atlas_vector_search_guide.md)
503
+ for the search and indexing surface.
504
+
505
+ ---
506
+
507
+ ## 9. Mongo-direct — when it engages
508
+
509
+ `Parse::MongoDB.aggregate` is the SDK's direct path to MongoDB,
510
+ bypassing Parse Server's REST layer entirely. It's used:
511
+
512
+ * Explicitly via `Parse::Query#results_direct` / `#count_direct` /
513
+ `Parse::AtlasSearch.*`.
514
+ * Implicitly by built-in agent tools when the request is scoped to
515
+ a session token, ACL user, or ACL role (`mongo_direct: false`
516
+ is auto-promoted to `true`).
517
+ * Implicitly by built-in agent tools when the requested aggregation
518
+ needs SDK-side ACL/CLP/`protectedFields` enforcement that REST
519
+ can't provide.
520
+
521
+ This path requires the SDK to have a direct MongoDB connection
522
+ configured (see [`mongodb_direct_guide.md`](./mongodb_direct_guide.md)).
523
+ In setups where mongo-direct is unavailable, scoped-agent aggregate
524
+ calls fail closed rather than silently downgrading to the unscoped
525
+ REST aggregate.
526
+
527
+ ---
528
+
529
+ ## 10. Common pitfalls
530
+
531
+ * **`protect_fields "*", [:email]` on `_User` doesn't hide email from
532
+ the user themselves.** Default `protectedFieldsOwnerExempt: true`
533
+ exempts the owner. Use `master_only_fields` / `self_visible_fields`
534
+ and set the option to `false`. See §4.2.
535
+ * **`set_clp :find` on `_Installation` does nothing.** Hardcoded
536
+ master-only at the REST layer. See §3.3.
537
+ * **CLP isn't sufficient gating for `_User` write flows.** Password
538
+ changes, email verification, and session rotation have their own
539
+ paths. Use field guards (§6) or `beforeSave` Cloud Code triggers
540
+ for write-side policy on `_User`.
541
+ * **REST aggregate bypasses everything.** Don't route scoped-agent
542
+ queries through `Parse::Client#aggregate_pipeline`. Use
543
+ `Parse::Query#results_direct` or `Parse::MongoDB.aggregate`. See §7.
544
+ * **Atlas Search results aren't ACL-filtered by Atlas.** The ACL
545
+ filter is a `$match` stage added by `Parse::ACLScope` after
546
+ `$search`. If you call the `$search` stage outside the SDK
547
+ helpers, you lose the filter. See §8.
548
+ * **Role hierarchy direction.** SuperAdmin inheriting from Admin
549
+ means SuperAdmin goes into Admin's `roles` relation. Use
550
+ `inherits_capabilities_from!` to keep it straight. See §5.
551
+ * **Field guards without webhook wiring are a no-op.** The Parse
552
+ Server deployment must point its webhook HTTPS callback at a
553
+ Ruby process running `Parse::Webhooks`. See §6.1.