parse-stack-next 5.4.1 → 5.5.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/CHANGELOG.md +344 -0
- data/Gemfile.lock +1 -1
- data/README.md +45 -6
- data/docs/atlas_vector_search_guide.md +314 -19
- data/lib/parse/api/users.rb +10 -0
- data/lib/parse/client.rb +19 -1
- data/lib/parse/embeddings/batch_embedder.rb +188 -0
- data/lib/parse/embeddings/cache.rb +322 -0
- data/lib/parse/embeddings/cohere.rb +31 -18
- data/lib/parse/embeddings/image_fetch.rb +347 -0
- data/lib/parse/embeddings/provider.rb +17 -11
- data/lib/parse/embeddings/spend_cap.rb +117 -3
- data/lib/parse/embeddings/voyage.rb +34 -25
- data/lib/parse/embeddings.rb +40 -3
- data/lib/parse/model/acl.rb +15 -11
- data/lib/parse/model/core/embed_managed.rb +243 -14
- data/lib/parse/model/core/vector_searchable.rb +157 -8
- data/lib/parse/query/constraint.rb +22 -0
- data/lib/parse/query/constraints.rb +271 -250
- data/lib/parse/query.rb +233 -42
- data/lib/parse/retrieval/agent_tool.rb +21 -14
- data/lib/parse/retrieval/retriever.rb +84 -0
- data/lib/parse/schema/search_index_migrator.rb +48 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +39 -1
- data/lib/parse/vector_search.rb +34 -0
- data/lib/parse/webhooks/payload.rb +7 -1
- data/lib/parse/webhooks.rb +107 -21
- metadata +4 -1
|
@@ -1638,6 +1638,12 @@ module Parse
|
|
|
1638
1638
|
# q.where :field.like => /ruby_regex/i
|
|
1639
1639
|
# :name.like => /Bob/i
|
|
1640
1640
|
#
|
|
1641
|
+
# # Opt into Unicode-aware matching (Parse Server 8.3.0+ over REST,
|
|
1642
|
+
# # MongoDB 6.1+ mongo-direct). The hash form compiles to the explicit
|
|
1643
|
+
# # $regex/$options shape and adds the `u` flag:
|
|
1644
|
+
# q.where :name.like => { value: /café/i, unicode: true }
|
|
1645
|
+
# # Generates: "name": { "$regex": "café", "$options": "iu" }
|
|
1646
|
+
#
|
|
1641
1647
|
class RegularExpressionConstraint < Constraint
|
|
1642
1648
|
# Requires that a key's value match a regular expression.
|
|
1643
1649
|
# Includes security validation to prevent ReDoS attacks.
|
|
@@ -1659,6 +1665,24 @@ module Parse
|
|
|
1659
1665
|
# @raise [ArgumentError] if the pattern is potentially dangerous (ReDoS)
|
|
1660
1666
|
# @return [Hash] the compiled constraint
|
|
1661
1667
|
def build
|
|
1668
|
+
# Opt-in `{ value:, unicode: true }` form. Unlike the bare form (which
|
|
1669
|
+
# stringifies a Ruby Regexp to its inline-flag source, e.g.
|
|
1670
|
+
# "(?i-mx:Bob)"), this compiles to the explicit $regex/$options shape so
|
|
1671
|
+
# the `u` flag can be appended for Unicode-aware matching.
|
|
1672
|
+
if @value.is_a?(Hash)
|
|
1673
|
+
raw, unicode = regex_unicode_option(@value)
|
|
1674
|
+
pattern_str = raw.is_a?(Regexp) ? raw.source : raw.to_s
|
|
1675
|
+
options = +""
|
|
1676
|
+
options << "i" if raw.is_a?(Regexp) && raw.casefold?
|
|
1677
|
+
options << "u" if unicode
|
|
1678
|
+
|
|
1679
|
+
Parse::RegexSecurity.validate!(pattern_str)
|
|
1680
|
+
|
|
1681
|
+
return options.empty? ?
|
|
1682
|
+
{ @operation.operand => { key => pattern_str } } :
|
|
1683
|
+
{ @operation.operand => { key => pattern_str, :$options => options } }
|
|
1684
|
+
end
|
|
1685
|
+
|
|
1662
1686
|
value = formatted_value
|
|
1663
1687
|
pattern_str = value.is_a?(Regexp) ? value.source : value.to_s
|
|
1664
1688
|
options = value.is_a?(Regexp) && value.casefold? ? "i" : nil
|
|
@@ -2322,6 +2346,11 @@ module Parse
|
|
|
2322
2346
|
# User.where(:name.starts_with => "John")
|
|
2323
2347
|
# # Generates: "name": { "$regex": "^John", "$options": "i" }
|
|
2324
2348
|
#
|
|
2349
|
+
# # Opt into Unicode-aware case-insensitive matching (Parse Server 8.3.0+
|
|
2350
|
+
# # over REST, MongoDB 6.1+ mongo-direct):
|
|
2351
|
+
# User.where(:name.starts_with => { value: "café", unicode: true })
|
|
2352
|
+
# # Generates: "name": { "$regex": "^café", "$options": "iu" }
|
|
2353
|
+
#
|
|
2325
2354
|
class StartsWithConstraint < Constraint
|
|
2326
2355
|
# @!method starts_with
|
|
2327
2356
|
# A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
|
|
@@ -2333,7 +2362,8 @@ module Parse
|
|
|
2333
2362
|
|
|
2334
2363
|
# @return [Hash] the compiled constraint.
|
|
2335
2364
|
def build
|
|
2336
|
-
|
|
2365
|
+
raw, unicode = regex_unicode_option(@value)
|
|
2366
|
+
value = self.class.formatted_value(raw)
|
|
2337
2367
|
unless value.is_a?(String)
|
|
2338
2368
|
raise ArgumentError, "#{self.class}: Value must be a string for starts_with constraint"
|
|
2339
2369
|
end
|
|
@@ -2347,7 +2377,7 @@ module Parse
|
|
|
2347
2377
|
escaped_value = Regexp.escape(value)
|
|
2348
2378
|
regex_pattern = "^#{escaped_value}"
|
|
2349
2379
|
|
|
2350
|
-
{ @operation.operand => { :$regex => regex_pattern, :$options => "i" } }
|
|
2380
|
+
{ @operation.operand => { :$regex => regex_pattern, :$options => (unicode ? "iu" : "i") } }
|
|
2351
2381
|
end
|
|
2352
2382
|
end
|
|
2353
2383
|
|
|
@@ -2358,6 +2388,11 @@ module Parse
|
|
|
2358
2388
|
# Post.where(:title.contains => "parse")
|
|
2359
2389
|
# # Generates: "title": { "$regex": ".*parse.*", "$options": "i" }
|
|
2360
2390
|
#
|
|
2391
|
+
# # Opt into Unicode-aware case-insensitive matching (Parse Server 8.3.0+
|
|
2392
|
+
# # over REST, MongoDB 6.1+ mongo-direct):
|
|
2393
|
+
# Post.where(:title.contains => { value: "café", unicode: true })
|
|
2394
|
+
# # Generates: "title": { "$regex": ".*café.*", "$options": "iu" }
|
|
2395
|
+
#
|
|
2361
2396
|
class ContainsConstraint < Constraint
|
|
2362
2397
|
# @!method contains
|
|
2363
2398
|
# A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
|
|
@@ -2369,7 +2404,8 @@ module Parse
|
|
|
2369
2404
|
|
|
2370
2405
|
# @return [Hash] the compiled constraint.
|
|
2371
2406
|
def build
|
|
2372
|
-
|
|
2407
|
+
raw, unicode = regex_unicode_option(@value)
|
|
2408
|
+
value = self.class.formatted_value(raw)
|
|
2373
2409
|
unless value.is_a?(String)
|
|
2374
2410
|
raise ArgumentError, "#{self.class}: Value must be a string for contains constraint"
|
|
2375
2411
|
end
|
|
@@ -2383,7 +2419,7 @@ module Parse
|
|
|
2383
2419
|
escaped_value = Regexp.escape(value)
|
|
2384
2420
|
regex_pattern = ".*#{escaped_value}.*"
|
|
2385
2421
|
|
|
2386
|
-
{ @operation.operand => { :$regex => regex_pattern, :$options => "i" } }
|
|
2422
|
+
{ @operation.operand => { :$regex => regex_pattern, :$options => (unicode ? "iu" : "i") } }
|
|
2387
2423
|
end
|
|
2388
2424
|
end
|
|
2389
2425
|
|
|
@@ -2394,6 +2430,11 @@ module Parse
|
|
|
2394
2430
|
# File.where(:name.ends_with => ".pdf")
|
|
2395
2431
|
# # Generates: "name": { "$regex": "\\.pdf$", "$options": "i" }
|
|
2396
2432
|
#
|
|
2433
|
+
# # Opt into Unicode-aware case-insensitive matching (Parse Server 8.3.0+
|
|
2434
|
+
# # over REST, MongoDB 6.1+ mongo-direct):
|
|
2435
|
+
# Post.where(:title.ends_with => { value: "café", unicode: true })
|
|
2436
|
+
# # Generates: "title": { "$regex": "café$", "$options": "iu" }
|
|
2437
|
+
#
|
|
2397
2438
|
class EndsWithConstraint < Constraint
|
|
2398
2439
|
# @!method ends_with
|
|
2399
2440
|
# A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
|
|
@@ -2405,7 +2446,8 @@ module Parse
|
|
|
2405
2446
|
|
|
2406
2447
|
# @return [Hash] the compiled constraint.
|
|
2407
2448
|
def build
|
|
2408
|
-
|
|
2449
|
+
raw, unicode = regex_unicode_option(@value)
|
|
2450
|
+
value = self.class.formatted_value(raw)
|
|
2409
2451
|
unless value.is_a?(String)
|
|
2410
2452
|
raise ArgumentError, "#{self.class}: Value must be a string for ends_with constraint"
|
|
2411
2453
|
end
|
|
@@ -2419,7 +2461,7 @@ module Parse
|
|
|
2419
2461
|
escaped_value = Regexp.escape(value)
|
|
2420
2462
|
regex_pattern = "#{escaped_value}$"
|
|
2421
2463
|
|
|
2422
|
-
{ @operation.operand => { :$regex => regex_pattern, :$options => "i" } }
|
|
2464
|
+
{ @operation.operand => { :$regex => regex_pattern, :$options => (unicode ? "iu" : "i") } }
|
|
2423
2465
|
end
|
|
2424
2466
|
end
|
|
2425
2467
|
|
|
@@ -2535,15 +2577,41 @@ module Parse
|
|
|
2535
2577
|
permissions_for_pointer(value)
|
|
2536
2578
|
elsif value.is_a?(Array)
|
|
2537
2579
|
value.flat_map { |item| collect_array_item(item) }
|
|
2580
|
+
elsif value.is_a?(Symbol)
|
|
2581
|
+
[symbol_permission(value)]
|
|
2538
2582
|
elsif value.is_a?(String)
|
|
2539
|
-
[value
|
|
2583
|
+
[normalize_string_permission(value)]
|
|
2540
2584
|
else
|
|
2541
2585
|
raise ArgumentError,
|
|
2542
2586
|
"ACL permission value must be a Parse::User, Parse::Role, " \
|
|
2543
|
-
"Parse::Pointer, String,
|
|
2587
|
+
"Parse::Pointer, String, Symbol (:public/:everyone/:world), or " \
|
|
2588
|
+
"Array of these (got #{value.class})"
|
|
2589
|
+
end
|
|
2590
|
+
end
|
|
2591
|
+
|
|
2592
|
+
# @!visibility private
|
|
2593
|
+
# Map a Symbol permission (:public / :everyone / :world) to the "*"
|
|
2594
|
+
# wildcard. Any other Symbol RAISES rather than silently mapping to a
|
|
2595
|
+
# bogus key — a mistyped permission must not quietly weaken the filter.
|
|
2596
|
+
def symbol_permission(sym)
|
|
2597
|
+
case sym
|
|
2598
|
+
when :public, :everyone, :world then "*"
|
|
2599
|
+
else
|
|
2600
|
+
raise ArgumentError,
|
|
2601
|
+
"Unsupported ACL permission Symbol #{sym.inspect}. Use " \
|
|
2602
|
+
":public / :everyone / :world for public access, or pass a " \
|
|
2603
|
+
"role name as a String or a Parse::Role."
|
|
2544
2604
|
end
|
|
2545
2605
|
end
|
|
2546
2606
|
|
|
2607
|
+
# @!visibility private
|
|
2608
|
+
# Normalize a String permission: the sentinel "public" maps to the
|
|
2609
|
+
# "*" wildcard; every other String is an exact permission key
|
|
2610
|
+
# (user objectId or "role:<Name>") used verbatim.
|
|
2611
|
+
def normalize_string_permission(str)
|
|
2612
|
+
str == "public" ? "*" : str
|
|
2613
|
+
end
|
|
2614
|
+
|
|
2547
2615
|
# Expand a +:ACL.readable_by_role+ / +:ACL.writable_by_role+ value
|
|
2548
2616
|
# into a permission-string array. Differs from {.collect} by
|
|
2549
2617
|
# auto-prefixing bare strings with +"role:"+ and refusing
|
|
@@ -2573,10 +2641,11 @@ module Parse
|
|
|
2573
2641
|
end
|
|
2574
2642
|
|
|
2575
2643
|
# @!visibility private
|
|
2576
|
-
# Array-element variant
|
|
2577
|
-
#
|
|
2578
|
-
# the
|
|
2579
|
-
#
|
|
2644
|
+
# Array-element variant. An unrecognized element RAISES rather than
|
|
2645
|
+
# being silently dropped: a mistyped permission that vanished from
|
|
2646
|
+
# the key set would silently weaken the intended ACL filter (a
|
|
2647
|
+
# security footgun). The Symbol :none contributes nothing (it is the
|
|
2648
|
+
# array-element spelling of "no extra grant").
|
|
2580
2649
|
def collect_array_item(item)
|
|
2581
2650
|
if item.is_a?(Parse::User)
|
|
2582
2651
|
permissions_for_user(item)
|
|
@@ -2584,10 +2653,15 @@ module Parse
|
|
|
2584
2653
|
permissions_for_role(item)
|
|
2585
2654
|
elsif item.is_a?(Parse::Pointer)
|
|
2586
2655
|
permissions_for_pointer(item)
|
|
2656
|
+
elsif item.is_a?(Symbol)
|
|
2657
|
+
item == :none ? [] : [symbol_permission(item)]
|
|
2587
2658
|
elsif item.is_a?(String)
|
|
2588
|
-
[item
|
|
2659
|
+
[normalize_string_permission(item)]
|
|
2589
2660
|
else
|
|
2590
|
-
|
|
2661
|
+
raise ArgumentError,
|
|
2662
|
+
"Unsupported ACL permission element #{item.inspect} " \
|
|
2663
|
+
"(#{item.class}) in array. Expected a Parse::User / Parse::Role / " \
|
|
2664
|
+
"Parse::Pointer, a permission String, or :public/:everyone/:world."
|
|
2591
2665
|
end
|
|
2592
2666
|
end
|
|
2593
2667
|
|
|
@@ -2619,19 +2693,61 @@ module Parse
|
|
|
2619
2693
|
# @param field [String] +"_rperm"+ or +"_wperm"+.
|
|
2620
2694
|
# @return [Hash] aggregation-pipeline wrapper compatible with
|
|
2621
2695
|
# {Parse::Query}'s constraint-build contract.
|
|
2622
|
-
|
|
2696
|
+
# @param strict [Boolean] when true, build an EXACT match: suppress
|
|
2697
|
+
# both the implicit public +"*"+ grant AND the missing-field
|
|
2698
|
+
# (+$exists: false+) branch, so only rows whose +_rperm+/+_wperm+
|
|
2699
|
+
# literally contains one of +permissions+ match. Used by the
|
|
2700
|
+
# +readable_by(..., strict: true)+ / +readable_by_exact+ surface.
|
|
2701
|
+
def pipeline(permissions, field:, strict: false)
|
|
2623
2702
|
deduped = permissions.compact.reject(&:empty?).uniq
|
|
2624
2703
|
if deduped.empty?
|
|
2625
2704
|
raise ArgumentError, "no valid permissions found in provided value"
|
|
2626
2705
|
end
|
|
2627
2706
|
predicate = if field == "_rperm"
|
|
2628
|
-
Parse::ACL.read_predicate(deduped)
|
|
2707
|
+
Parse::ACL.read_predicate(deduped, include_public: !strict, include_missing: !strict)
|
|
2629
2708
|
else
|
|
2630
|
-
Parse::ACL.write_predicate(deduped)
|
|
2709
|
+
Parse::ACL.write_predicate(deduped, include_public: !strict, include_missing: !strict)
|
|
2631
2710
|
end
|
|
2632
2711
|
{ "__aggregation_pipeline" => [{ "$match" => predicate }] }
|
|
2633
2712
|
end
|
|
2634
2713
|
|
|
2714
|
+
# @!visibility private
|
|
2715
|
+
# Whether a +readable_by+ / +writable_by+ value expresses "no
|
|
2716
|
+
# permissions" (master-key-only): +nil+, an empty Array, the String
|
|
2717
|
+
# +"none"+, or the Symbol +:none+. These map to {.empty_pipeline}.
|
|
2718
|
+
def empty_intent?(value)
|
|
2719
|
+
return true if value.nil?
|
|
2720
|
+
return true if value == "none" || value == :none
|
|
2721
|
+
return true if value.is_a?(Array) && value.empty?
|
|
2722
|
+
false
|
|
2723
|
+
end
|
|
2724
|
+
|
|
2725
|
+
# @!visibility private
|
|
2726
|
+
# The match for "no permissions": an explicit empty array. A missing
|
|
2727
|
+
# +_rperm+/+_wperm+ is treated by Parse Server as PUBLIC — the
|
|
2728
|
+
# opposite of master-only — so it must NOT match here. +$eq: []+
|
|
2729
|
+
# already excludes a missing field (missing != []); the +$exists:
|
|
2730
|
+
# true+ guard documents that intent.
|
|
2731
|
+
# @param field [String] +"_rperm"+ or +"_wperm"+.
|
|
2732
|
+
def empty_pipeline(field:)
|
|
2733
|
+
{ "__aggregation_pipeline" => [
|
|
2734
|
+
{ "$match" => { field => { "$exists" => true, "$eq" => [] } } },
|
|
2735
|
+
] }
|
|
2736
|
+
end
|
|
2737
|
+
|
|
2738
|
+
# @!visibility private
|
|
2739
|
+
# Permission keys for a +not_readable_by+ / +not_writable_by+ value:
|
|
2740
|
+
# the expanded grant set (user→roles, role→parent roles) PLUS the
|
|
2741
|
+
# public +"*"+ wildcard. A public row is readable/writable by everyone,
|
|
2742
|
+
# so it must be EXCLUDED from a "not readable/writable by X" result —
|
|
2743
|
+
# hence +"*"+ is added to the +$nin+ set. Returns +[]+ for an
|
|
2744
|
+
# empty-intent value (no negation constraint is applied).
|
|
2745
|
+
# @return [Array<String>]
|
|
2746
|
+
def collect_for_negation(value)
|
|
2747
|
+
return [] if empty_intent?(value)
|
|
2748
|
+
(collect(value) + ["*"]).compact.reject(&:empty?).uniq
|
|
2749
|
+
end
|
|
2750
|
+
|
|
2635
2751
|
# @!visibility private
|
|
2636
2752
|
def permissions_for_user(user)
|
|
2637
2753
|
return [] unless user.id.present?
|
|
@@ -2652,7 +2768,12 @@ module Parse
|
|
|
2652
2768
|
def permissions_for_role(role)
|
|
2653
2769
|
return [] unless role.respond_to?(:name) && role.name.present?
|
|
2654
2770
|
begin
|
|
2655
|
-
role.all_parent_role_names(max_depth: 5)
|
|
2771
|
+
names = role.all_parent_role_names(max_depth: 5)
|
|
2772
|
+
# The role's OWN name must always be present: an unpersisted role
|
|
2773
|
+
# (id still nil) yields [] from the upward-inheritance walk, which
|
|
2774
|
+
# would otherwise drop the role entirely and raise "no valid
|
|
2775
|
+
# permissions". Self is included idempotently for persisted roles.
|
|
2776
|
+
(Array(names) + [role.name]).uniq.map { |name| "role:#{name}" }
|
|
2656
2777
|
rescue
|
|
2657
2778
|
["role:#{role.name}"]
|
|
2658
2779
|
end
|
|
@@ -2709,23 +2830,41 @@ module Parse
|
|
|
2709
2830
|
# @return [ACLReadableByConstraint]
|
|
2710
2831
|
register :readable_by
|
|
2711
2832
|
|
|
2833
|
+
# @return [Boolean] whether to compile an EXACT match (suppress the
|
|
2834
|
+
# implicit public +"*"+ grant and the missing-field branch).
|
|
2835
|
+
# Overridden by {ACLReadableByExactConstraint}.
|
|
2836
|
+
def strict?
|
|
2837
|
+
false
|
|
2838
|
+
end
|
|
2839
|
+
|
|
2712
2840
|
# @return [Hash] the compiled constraint using _rperm field.
|
|
2713
2841
|
def build
|
|
2714
2842
|
# Use @value directly to preserve type information before
|
|
2715
2843
|
# formatted_value converts to pointers.
|
|
2716
2844
|
value = @value
|
|
2717
2845
|
|
|
2718
|
-
#
|
|
2719
|
-
# array — master-key-only
|
|
2720
|
-
#
|
|
2721
|
-
#
|
|
2722
|
-
|
|
2723
|
-
pipeline = [{ "$match" => { "_rperm" => { "$eq" => [] } } }]
|
|
2724
|
-
return { "__aggregation_pipeline" => pipeline }
|
|
2725
|
-
end
|
|
2846
|
+
# "No permissions" intent (nil / [] / "none" / :none) matches
|
|
2847
|
+
# objects whose _rperm is an explicit empty array — master-key-only
|
|
2848
|
+
# documents. A missing _rperm is public (the opposite of "none"), so
|
|
2849
|
+
# {ACLPermissions.empty_pipeline} deliberately does NOT match it.
|
|
2850
|
+
return ACLPermissions.empty_pipeline(field: "_rperm") if ACLPermissions.empty_intent?(value)
|
|
2726
2851
|
|
|
2727
2852
|
permissions = ACLPermissions.collect(value)
|
|
2728
|
-
ACLPermissions.pipeline(permissions, field: "_rperm")
|
|
2853
|
+
ACLPermissions.pipeline(permissions, field: "_rperm", strict: strict?)
|
|
2854
|
+
end
|
|
2855
|
+
end
|
|
2856
|
+
|
|
2857
|
+
# Strict variant of {ACLReadableByConstraint}: matches ONLY rows whose
|
|
2858
|
+
# +_rperm+ literally contains one of the resolved permissions — no
|
|
2859
|
+
# implicit public +"*"+ and no missing-+_rperm+ (public-by-absence) rows.
|
|
2860
|
+
# Reached via +Query#readable_by(value, strict: true)+ or the
|
|
2861
|
+
# +:ACL.readable_by_exact+ symbol operator. Use this for ownership /
|
|
2862
|
+
# security audits ("which rows explicitly grant this principal") rather
|
|
2863
|
+
# than access simulation ("what can this principal read").
|
|
2864
|
+
class ACLReadableByExactConstraint < ACLReadableByConstraint
|
|
2865
|
+
register :readable_by_exact
|
|
2866
|
+
def strict?
|
|
2867
|
+
true
|
|
2729
2868
|
end
|
|
2730
2869
|
end
|
|
2731
2870
|
|
|
@@ -2748,10 +2887,25 @@ module Parse
|
|
|
2748
2887
|
# @return [ACLReadableByRoleConstraint]
|
|
2749
2888
|
register :readable_by_role
|
|
2750
2889
|
|
|
2890
|
+
# @return [Boolean] whether to compile an EXACT match. Overridden by
|
|
2891
|
+
# {ACLReadableByRoleExactConstraint}.
|
|
2892
|
+
def strict?
|
|
2893
|
+
false
|
|
2894
|
+
end
|
|
2895
|
+
|
|
2751
2896
|
# @return [Hash] the compiled constraint using _rperm field.
|
|
2752
2897
|
def build
|
|
2753
2898
|
permissions = ACLPermissions.collect_role_only(@value)
|
|
2754
|
-
ACLPermissions.pipeline(permissions, field: "_rperm")
|
|
2899
|
+
ACLPermissions.pipeline(permissions, field: "_rperm", strict: strict?)
|
|
2900
|
+
end
|
|
2901
|
+
end
|
|
2902
|
+
|
|
2903
|
+
# Strict variant of {ACLReadableByRoleConstraint}. See
|
|
2904
|
+
# {ACLReadableByExactConstraint}.
|
|
2905
|
+
class ACLReadableByRoleExactConstraint < ACLReadableByRoleConstraint
|
|
2906
|
+
register :readable_by_role_exact
|
|
2907
|
+
def strict?
|
|
2908
|
+
true
|
|
2755
2909
|
end
|
|
2756
2910
|
end
|
|
2757
2911
|
|
|
@@ -2777,21 +2931,33 @@ module Parse
|
|
|
2777
2931
|
# @return [ACLWritableByConstraint]
|
|
2778
2932
|
register :writable_by
|
|
2779
2933
|
|
|
2934
|
+
# @return [Boolean] whether to compile an EXACT match. Overridden by
|
|
2935
|
+
# {ACLWritableByExactConstraint}.
|
|
2936
|
+
def strict?
|
|
2937
|
+
false
|
|
2938
|
+
end
|
|
2939
|
+
|
|
2780
2940
|
# @return [Hash] the compiled constraint using _wperm field.
|
|
2781
2941
|
def build
|
|
2782
2942
|
# Use @value directly to preserve type information before
|
|
2783
2943
|
# formatted_value converts to pointers.
|
|
2784
2944
|
value = @value
|
|
2785
2945
|
|
|
2786
|
-
#
|
|
2787
|
-
#
|
|
2788
|
-
|
|
2789
|
-
pipeline = [{ "$match" => { "_wperm" => { "$eq" => [] } } }]
|
|
2790
|
-
return { "__aggregation_pipeline" => pipeline }
|
|
2791
|
-
end
|
|
2946
|
+
# "No permissions" intent (nil / [] / "none" / :none) — see
|
|
2947
|
+
# {ACLReadableByConstraint#build}.
|
|
2948
|
+
return ACLPermissions.empty_pipeline(field: "_wperm") if ACLPermissions.empty_intent?(value)
|
|
2792
2949
|
|
|
2793
2950
|
permissions = ACLPermissions.collect(value)
|
|
2794
|
-
ACLPermissions.pipeline(permissions, field: "_wperm")
|
|
2951
|
+
ACLPermissions.pipeline(permissions, field: "_wperm", strict: strict?)
|
|
2952
|
+
end
|
|
2953
|
+
end
|
|
2954
|
+
|
|
2955
|
+
# Strict variant of {ACLWritableByConstraint}. See
|
|
2956
|
+
# {ACLReadableByExactConstraint}.
|
|
2957
|
+
class ACLWritableByExactConstraint < ACLWritableByConstraint
|
|
2958
|
+
register :writable_by_exact
|
|
2959
|
+
def strict?
|
|
2960
|
+
true
|
|
2795
2961
|
end
|
|
2796
2962
|
end
|
|
2797
2963
|
|
|
@@ -2814,10 +2980,25 @@ module Parse
|
|
|
2814
2980
|
# @return [ACLWritableByRoleConstraint]
|
|
2815
2981
|
register :writable_by_role
|
|
2816
2982
|
|
|
2983
|
+
# @return [Boolean] whether to compile an EXACT match. Overridden by
|
|
2984
|
+
# {ACLWritableByRoleExactConstraint}.
|
|
2985
|
+
def strict?
|
|
2986
|
+
false
|
|
2987
|
+
end
|
|
2988
|
+
|
|
2817
2989
|
# @return [Hash] the compiled constraint using _wperm field.
|
|
2818
2990
|
def build
|
|
2819
2991
|
permissions = ACLPermissions.collect_role_only(@value)
|
|
2820
|
-
ACLPermissions.pipeline(permissions, field: "_wperm")
|
|
2992
|
+
ACLPermissions.pipeline(permissions, field: "_wperm", strict: strict?)
|
|
2993
|
+
end
|
|
2994
|
+
end
|
|
2995
|
+
|
|
2996
|
+
# Strict variant of {ACLWritableByRoleConstraint}. See
|
|
2997
|
+
# {ACLReadableByExactConstraint}.
|
|
2998
|
+
class ACLWritableByRoleExactConstraint < ACLWritableByRoleConstraint
|
|
2999
|
+
register :writable_by_role_exact
|
|
3000
|
+
def strict?
|
|
3001
|
+
true
|
|
2821
3002
|
end
|
|
2822
3003
|
end
|
|
2823
3004
|
|
|
@@ -3004,179 +3185,29 @@ module Parse
|
|
|
3004
3185
|
end
|
|
3005
3186
|
end
|
|
3006
3187
|
|
|
3007
|
-
#
|
|
3008
|
-
#
|
|
3009
|
-
#
|
|
3010
|
-
#
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
# Normalize various input types to ACL permission keys.
|
|
3015
|
-
# @param value [Array, String, Symbol, Parse::User, Parse::Role, nil]
|
|
3016
|
-
# @return [Array<String>] normalized permission keys
|
|
3017
|
-
# @note Returns empty array for nil, [], "none", or :none (indicating no permissions)
|
|
3018
|
-
def normalize_acl_keys(value)
|
|
3019
|
-
# Handle special "none" case for no permissions
|
|
3020
|
-
return [] if value.nil?
|
|
3021
|
-
return [] if value == "none" || value == :none
|
|
3022
|
-
return [] if value.is_a?(Array) && value.empty?
|
|
3023
|
-
|
|
3024
|
-
Array(value).map do |item|
|
|
3025
|
-
case item
|
|
3026
|
-
when Parse::User
|
|
3027
|
-
item.id
|
|
3028
|
-
when Parse::Role
|
|
3029
|
-
"role:#{item.name}"
|
|
3030
|
-
when Parse::Pointer
|
|
3031
|
-
item.id
|
|
3032
|
-
when :public, :everyone, :world
|
|
3033
|
-
"*"
|
|
3034
|
-
when "public", "*"
|
|
3035
|
-
"*"
|
|
3036
|
-
when "none", :none
|
|
3037
|
-
nil # Will be compacted out, but array will be non-empty so won't match "no permissions"
|
|
3038
|
-
when String
|
|
3039
|
-
item
|
|
3040
|
-
when Symbol
|
|
3041
|
-
item == :public ? "*" : item.to_s
|
|
3042
|
-
else
|
|
3043
|
-
item.respond_to?(:id) ? item.id : item.to_s
|
|
3044
|
-
end
|
|
3045
|
-
end.compact.uniq
|
|
3046
|
-
end
|
|
3188
|
+
# @deprecated Thin alias of {ACLReadableByConstraint}. The +:readable_by+
|
|
3189
|
+
# operator is registered by {ACLReadableByConstraint}; this constant is
|
|
3190
|
+
# retained only so any code referencing it keeps working. The previous
|
|
3191
|
+
# standalone implementation (no role expansion, no implicit public
|
|
3192
|
+
# +"*"+, divergent empty-ACL shape) has been removed — it never backed
|
|
3193
|
+
# the +:readable_by+ operator and silently disagreed with it.
|
|
3194
|
+
class ReadableByConstraint < ACLReadableByConstraint
|
|
3047
3195
|
end
|
|
3048
3196
|
|
|
3049
|
-
#
|
|
3050
|
-
#
|
|
3051
|
-
#
|
|
3052
|
-
#
|
|
3053
|
-
#
|
|
3054
|
-
#
|
|
3055
|
-
#
|
|
3056
|
-
#
|
|
3057
|
-
|
|
3058
|
-
# Song.query.where(:acl.readable_by => current_user)
|
|
3059
|
-
#
|
|
3060
|
-
# @example Find objects readable by a role
|
|
3061
|
-
# Song.query.where(:acl.readable_by => "role:Admin")
|
|
3062
|
-
#
|
|
3063
|
-
# @example Find objects with public read access
|
|
3064
|
-
# Song.query.where(:acl.readable_by => "*")
|
|
3065
|
-
# Song.query.where(:acl.readable_by => :public)
|
|
3066
|
-
#
|
|
3067
|
-
# @example Find objects readable by ANY of the specified users/roles
|
|
3068
|
-
# Song.query.where(:acl.readable_by => [user1.id, "role:Admin", "*"])
|
|
3069
|
-
#
|
|
3070
|
-
# @note This constraint uses aggregation pipeline because Parse Server
|
|
3071
|
-
# restricts direct queries on the internal _rperm field.
|
|
3072
|
-
class ReadableByConstraint < Constraint
|
|
3073
|
-
include AclConstraintHelpers
|
|
3074
|
-
|
|
3075
|
-
# @!method readable_by
|
|
3076
|
-
# A registered method on a symbol to create the constraint.
|
|
3077
|
-
# @example
|
|
3078
|
-
# q.where :acl.readable_by => []
|
|
3079
|
-
# q.where :acl.readable_by => "userId"
|
|
3080
|
-
# q.where :acl.readable_by => ["userId", "role:Admin"]
|
|
3081
|
-
# @return [ReadableByConstraint]
|
|
3082
|
-
# NOTE: :readable_by is already registered by ACLReadableByConstraint above.
|
|
3083
|
-
# This class provides simplified empty ACL queries and is used internally.
|
|
3084
|
-
|
|
3085
|
-
# @return [Hash] the compiled constraint using aggregation pipeline.
|
|
3086
|
-
def build
|
|
3087
|
-
keys = normalize_acl_keys(@value)
|
|
3088
|
-
|
|
3089
|
-
if keys.empty?
|
|
3090
|
-
# Empty array = no read permissions (master key only)
|
|
3091
|
-
# Match documents where _rperm is an empty array
|
|
3092
|
-
pipeline = [
|
|
3093
|
-
{
|
|
3094
|
-
"$match" => {
|
|
3095
|
-
"$or" => [
|
|
3096
|
-
{ "_rperm" => { "$exists" => true, "$eq" => [] } },
|
|
3097
|
-
{ "_rperm" => { "$exists" => false } },
|
|
3098
|
-
],
|
|
3099
|
-
},
|
|
3100
|
-
},
|
|
3101
|
-
]
|
|
3102
|
-
else
|
|
3103
|
-
# Find objects readable by ANY of the specified keys
|
|
3104
|
-
# Use $in to match if _rperm contains any of the keys
|
|
3105
|
-
pipeline = [
|
|
3106
|
-
{
|
|
3107
|
-
"$match" => {
|
|
3108
|
-
"_rperm" => { "$in" => keys },
|
|
3109
|
-
},
|
|
3110
|
-
},
|
|
3111
|
-
]
|
|
3112
|
-
end
|
|
3113
|
-
|
|
3114
|
-
{ "__aggregation_pipeline" => pipeline }
|
|
3115
|
-
end
|
|
3116
|
-
end
|
|
3117
|
-
|
|
3118
|
-
# ACL Write Permission Query Constraint
|
|
3119
|
-
# Query objects based on write permissions using MongoDB's internal _wperm field.
|
|
3120
|
-
# Parse Server restricts direct queries on _wperm, so this uses aggregation pipeline.
|
|
3121
|
-
#
|
|
3122
|
-
# @example Find objects with NO write permissions (master key only / read-only)
|
|
3123
|
-
# Song.query.where(:acl.writeable_by => [])
|
|
3124
|
-
#
|
|
3125
|
-
# @example Find objects writable by a specific user ID
|
|
3126
|
-
# Song.query.where(:acl.writeable_by => "userId123")
|
|
3127
|
-
# Song.query.where(:acl.writeable_by => current_user)
|
|
3128
|
-
#
|
|
3129
|
-
# @example Find objects writable by a role
|
|
3130
|
-
# Song.query.where(:acl.writeable_by => "role:Admin")
|
|
3131
|
-
#
|
|
3132
|
-
# @note This constraint uses aggregation pipeline because Parse Server
|
|
3133
|
-
# restricts direct queries on the internal _wperm field.
|
|
3134
|
-
class WriteableByConstraint < Constraint
|
|
3135
|
-
include AclConstraintHelpers
|
|
3136
|
-
|
|
3137
|
-
# @!method writeable_by
|
|
3138
|
-
# A registered method on a symbol to create the constraint.
|
|
3139
|
-
# @example
|
|
3140
|
-
# q.where :acl.writeable_by => []
|
|
3141
|
-
# q.where :acl.writeable_by => "userId"
|
|
3142
|
-
# @return [WriteableByConstraint]
|
|
3197
|
+
# @deprecated Alias of {ACLWritableByConstraint}. The British-spelled
|
|
3198
|
+
# +:writeable_by+ operator now resolves to the SAME public-inclusive,
|
|
3199
|
+
# role-expanding implementation as +:writable_by+ — previously it was a
|
|
3200
|
+
# separate, strict, non-expanding constraint, so the one-letter spelling
|
|
3201
|
+
# difference silently changed query semantics. For the old exact-match
|
|
3202
|
+
# behavior (no implicit public, no role expansion, no missing-field),
|
|
3203
|
+
# use +readable_by(..., strict: true)+ / +writable_by(..., strict: true)+
|
|
3204
|
+
# or the +:writable_by_exact+ operator.
|
|
3205
|
+
class WriteableByConstraint < ACLWritableByConstraint
|
|
3143
3206
|
register :writeable_by
|
|
3144
|
-
|
|
3145
|
-
# @return [Hash] the compiled constraint using aggregation pipeline.
|
|
3146
|
-
def build
|
|
3147
|
-
keys = normalize_acl_keys(@value)
|
|
3148
|
-
|
|
3149
|
-
if keys.empty?
|
|
3150
|
-
# Empty array = no write permissions (master key only)
|
|
3151
|
-
pipeline = [
|
|
3152
|
-
{
|
|
3153
|
-
"$match" => {
|
|
3154
|
-
"$or" => [
|
|
3155
|
-
{ "_wperm" => { "$exists" => true, "$eq" => [] } },
|
|
3156
|
-
{ "_wperm" => { "$exists" => false } },
|
|
3157
|
-
],
|
|
3158
|
-
},
|
|
3159
|
-
},
|
|
3160
|
-
]
|
|
3161
|
-
else
|
|
3162
|
-
# Find objects writable by ANY of the specified keys
|
|
3163
|
-
pipeline = [
|
|
3164
|
-
{
|
|
3165
|
-
"$match" => {
|
|
3166
|
-
"_wperm" => { "$in" => keys },
|
|
3167
|
-
},
|
|
3168
|
-
},
|
|
3169
|
-
]
|
|
3170
|
-
end
|
|
3171
|
-
|
|
3172
|
-
{ "__aggregation_pipeline" => pipeline }
|
|
3173
|
-
end
|
|
3174
3207
|
end
|
|
3175
3208
|
|
|
3176
|
-
# Alias
|
|
3177
|
-
|
|
3178
|
-
# This class provides simplified empty ACL queries and is used internally.
|
|
3179
|
-
class WritableByConstraint < WriteableByConstraint
|
|
3209
|
+
# @deprecated Alias of {ACLWritableByConstraint}; see {WriteableByConstraint}.
|
|
3210
|
+
class WritableByConstraint < ACLWritableByConstraint
|
|
3180
3211
|
end
|
|
3181
3212
|
|
|
3182
3213
|
# ACL NOT Readable By Constraint
|
|
@@ -3190,22 +3221,29 @@ module Parse
|
|
|
3190
3221
|
# Song.query.where(:acl.not_readable_by => "*")
|
|
3191
3222
|
# Song.query.where(:acl.not_readable_by => :public)
|
|
3192
3223
|
#
|
|
3224
|
+
# @note "Not readable by X" excludes rows readable by X *directly*, *via
|
|
3225
|
+
# any role X inherits*, AND *publicly* — so a User value expands its
|
|
3226
|
+
# roles and the public +"*"+ is always added to the exclusion set.
|
|
3193
3227
|
# @note This constraint uses aggregation pipeline because Parse Server
|
|
3194
3228
|
# restricts direct queries on the internal _rperm field.
|
|
3195
3229
|
class NotReadableByConstraint < Constraint
|
|
3196
|
-
include AclConstraintHelpers
|
|
3197
|
-
|
|
3198
3230
|
register :not_readable_by
|
|
3199
3231
|
|
|
3200
3232
|
def build
|
|
3201
|
-
keys =
|
|
3233
|
+
keys = ACLPermissions.collect_for_negation(@value)
|
|
3202
3234
|
return { "__aggregation_pipeline" => [] } if keys.empty?
|
|
3203
3235
|
|
|
3204
|
-
# Find objects
|
|
3236
|
+
# Find objects whose _rperm EXISTS and does NOT contain any of the
|
|
3237
|
+
# keys. The `$exists: true` guard is essential: Parse Server treats a
|
|
3238
|
+
# missing `_rperm` as publicly readable, and MongoDB's `$nin` matches
|
|
3239
|
+
# documents where the field is absent. Without the guard,
|
|
3240
|
+
# `not_readable_by("*")` (i.e. #not_publicly_readable) would MATCH the
|
|
3241
|
+
# public-by-absence rows it is meant to exclude — inverting the result
|
|
3242
|
+
# and giving a security audit a false sense of safety.
|
|
3205
3243
|
pipeline = [
|
|
3206
3244
|
{
|
|
3207
3245
|
"$match" => {
|
|
3208
|
-
"_rperm" => { "$nin" => keys },
|
|
3246
|
+
"_rperm" => { "$exists" => true, "$nin" => keys },
|
|
3209
3247
|
},
|
|
3210
3248
|
},
|
|
3211
3249
|
]
|
|
@@ -3223,18 +3261,20 @@ module Parse
|
|
|
3223
3261
|
# @note This constraint uses aggregation pipeline because Parse Server
|
|
3224
3262
|
# restricts direct queries on the internal _wperm field.
|
|
3225
3263
|
class NotWriteableByConstraint < Constraint
|
|
3226
|
-
include AclConstraintHelpers
|
|
3227
|
-
|
|
3228
3264
|
register :not_writeable_by
|
|
3229
3265
|
|
|
3230
3266
|
def build
|
|
3231
|
-
keys =
|
|
3267
|
+
keys = ACLPermissions.collect_for_negation(@value)
|
|
3232
3268
|
return { "__aggregation_pipeline" => [] } if keys.empty?
|
|
3233
3269
|
|
|
3270
|
+
# See {NotReadableByConstraint#build}: the `$exists: true` guard
|
|
3271
|
+
# prevents a missing `_wperm` (publicly writable per Parse Server)
|
|
3272
|
+
# from matching `$nin`, which would otherwise make
|
|
3273
|
+
# #not_publicly_writable report write-exposed objects as safe.
|
|
3234
3274
|
pipeline = [
|
|
3235
3275
|
{
|
|
3236
3276
|
"$match" => {
|
|
3237
|
-
"_wperm" => { "$nin" => keys },
|
|
3277
|
+
"_wperm" => { "$exists" => true, "$nin" => keys },
|
|
3238
3278
|
},
|
|
3239
3279
|
},
|
|
3240
3280
|
]
|
|
@@ -3265,45 +3305,26 @@ module Parse
|
|
|
3265
3305
|
register :master_key_only
|
|
3266
3306
|
|
|
3267
3307
|
def build
|
|
3268
|
-
|
|
3308
|
+
# A truly private (master-key-only) object has an EXPLICIT empty
|
|
3309
|
+
# _rperm AND an explicit empty _wperm. A MISSING _rperm/_wperm is
|
|
3310
|
+
# treated by Parse Server as PUBLIC — the opposite of private — so
|
|
3311
|
+
# the `$exists: true` guards are required and the missing-field
|
|
3312
|
+
# branch must NOT be matched (this is the bug-fixed shape: the
|
|
3313
|
+
# previous version OR'd in `{$exists: false}`, wrongly classifying
|
|
3314
|
+
# the most-public rows as private).
|
|
3315
|
+
private_match = {
|
|
3316
|
+
"$and" => [
|
|
3317
|
+
{ "_rperm" => { "$exists" => true, "$eq" => [] } },
|
|
3318
|
+
{ "_wperm" => { "$exists" => true, "$eq" => [] } },
|
|
3319
|
+
],
|
|
3320
|
+
}
|
|
3269
3321
|
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
"$match" => {
|
|
3275
|
-
"$and" => [
|
|
3276
|
-
{
|
|
3277
|
-
"$or" => [
|
|
3278
|
-
{ "_rperm" => { "$exists" => true, "$eq" => [] } },
|
|
3279
|
-
{ "_rperm" => { "$exists" => false } },
|
|
3280
|
-
],
|
|
3281
|
-
},
|
|
3282
|
-
{
|
|
3283
|
-
"$or" => [
|
|
3284
|
-
{ "_wperm" => { "$exists" => true, "$eq" => [] } },
|
|
3285
|
-
{ "_wperm" => { "$exists" => false } },
|
|
3286
|
-
],
|
|
3287
|
-
},
|
|
3288
|
-
],
|
|
3289
|
-
},
|
|
3290
|
-
},
|
|
3291
|
-
]
|
|
3292
|
-
else
|
|
3293
|
-
# Match objects that have SOME permissions (either read or write)
|
|
3294
|
-
pipeline = [
|
|
3295
|
-
{
|
|
3296
|
-
"$match" => {
|
|
3297
|
-
"$or" => [
|
|
3298
|
-
{ "_rperm" => { "$exists" => true, "$ne" => [] } },
|
|
3299
|
-
{ "_wperm" => { "$exists" => true, "$ne" => [] } },
|
|
3300
|
-
],
|
|
3301
|
-
},
|
|
3302
|
-
},
|
|
3303
|
-
]
|
|
3304
|
-
end
|
|
3322
|
+
# `private_acl => false` is the exact complement: every object that
|
|
3323
|
+
# is NOT fully master-key-only — those with any read/write grant AND
|
|
3324
|
+
# those with a missing (public) _rperm/_wperm.
|
|
3325
|
+
match = @value == true ? private_match : { "$nor" => [private_match] }
|
|
3305
3326
|
|
|
3306
|
-
{ "__aggregation_pipeline" =>
|
|
3327
|
+
{ "__aggregation_pipeline" => [{ "$match" => match }] }
|
|
3307
3328
|
end
|
|
3308
3329
|
end
|
|
3309
3330
|
end
|