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.
@@ -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
- value = formatted_value
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
- value = formatted_value
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
- value = formatted_value
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 == "public" ? "*" : 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, or Array of these (got #{value.class})"
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 that silently skips unrecognized entries
2577
- # rather than raising, matching the pre-refactor behavior where
2578
- # the array branch tolerated a mixed bag of types and ignored
2579
- # anything it didn't understand.
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 == "public" ? "*" : 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
- def pipeline(permissions, field:)
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).map { |name| "role:#{name}" }
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
- # Special case: "none" matches objects whose _rperm is an empty
2719
- # array — master-key-only documents. Parse Server writes []
2720
- # when no read permission is set, and an absent _rperm is
2721
- # treated as public (handled by the default predicate path).
2722
- if value.is_a?(String) && value == "none"
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
- # Special case: "none" matches objects whose _wperm is an empty
2787
- # array — master-key-only documents. See {ACLReadableByConstraint#build}.
2788
- if value.is_a?(String) && value == "none"
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
- # Shared helper module for ACL constraint classes.
3008
- # Provides common normalization logic for converting various input types
3009
- # (User, Role, Pointer, symbols, strings) to ACL permission keys.
3010
- # @api private
3011
- module AclConstraintHelpers
3012
- private
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
- # ACL Read Permission Query Constraint
3050
- # Query objects based on read permissions using MongoDB's internal _rperm field.
3051
- # Parse Server restricts direct queries on _rperm, so this uses aggregation pipeline.
3052
- #
3053
- # @example Find objects with NO read permissions (master key only / private)
3054
- # Song.query.where(:acl.readable_by => [])
3055
- #
3056
- # @example Find objects readable by a specific user ID
3057
- # Song.query.where(:acl.readable_by => "userId123")
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 for writeable_by (American spelling)
3177
- # NOTE: :writable_by is already registered by ACLWritableByConstraint above.
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 = normalize_acl_keys(@value)
3233
+ keys = ACLPermissions.collect_for_negation(@value)
3202
3234
  return { "__aggregation_pipeline" => [] } if keys.empty?
3203
3235
 
3204
- # Find objects where _rperm does NOT contain any of the keys
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 = normalize_acl_keys(@value)
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
- is_private = @value == true
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
- if is_private
3271
- # Match objects with empty or missing _rperm AND _wperm
3272
- pipeline = [
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" => pipeline }
3327
+ { "__aggregation_pipeline" => [{ "$match" => match }] }
3307
3328
  end
3308
3329
  end
3309
3330
  end