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.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
- data/.github/dependabot.yml +13 -0
- data/.github/workflows/codeql.yml +1 -1
- data/.github/workflows/docs.yml +3 -3
- data/.github/workflows/release.yml +14 -3
- data/.github/workflows/ruby.yml +1 -1
- data/.gitignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +792 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +8 -5
- data/README.md +15 -0
- data/Rakefile +5 -1
- data/docs/acl_clp_guide.md +553 -0
- data/docs/atlas_vector_search_guide.md +123 -22
- data/docs/client_sdk_guide.md +201 -5
- data/docs/usage_guide.md +21 -0
- data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
- data/lib/parse/agent/tools.rb +153 -1
- data/lib/parse/cache/redis.rb +53 -0
- data/lib/parse/client/caching.rb +18 -1
- data/lib/parse/client.rb +79 -12
- data/lib/parse/embeddings/cohere.rb +143 -6
- data/lib/parse/embeddings/provider.rb +20 -2
- data/lib/parse/embeddings/voyage.rb +102 -0
- data/lib/parse/embeddings.rb +332 -1
- data/lib/parse/live_query/client.rb +167 -4
- data/lib/parse/live_query/configuration.rb +12 -0
- data/lib/parse/live_query/subscription.rb +55 -2
- data/lib/parse/live_query.rb +123 -1
- data/lib/parse/lock.rb +342 -0
- data/lib/parse/lock_backend.rb +308 -0
- data/lib/parse/model/classes/audience.rb +5 -0
- data/lib/parse/model/classes/installation.rb +122 -0
- data/lib/parse/model/classes/job_schedule.rb +3 -1
- data/lib/parse/model/classes/job_status.rb +4 -1
- data/lib/parse/model/classes/push_status.rb +4 -1
- data/lib/parse/model/classes/session.rb +7 -0
- data/lib/parse/model/classes/user.rb +204 -0
- data/lib/parse/model/core/create_lock.rb +28 -146
- data/lib/parse/model/core/embed_managed.rb +162 -13
- data/lib/parse/model/core/parse_reference.rb +17 -1
- data/lib/parse/model/core/querying.rb +26 -2
- data/lib/parse/model/file.rb +523 -18
- data/lib/parse/query.rb +31 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +98 -1
- data/parse-stack-next.gemspec +2 -2
- 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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|