Skip to content

Commit 7e7cf7f

Browse files
committed
Add default and pointer permissions to CLP DSL
Enhances the Parse::CLP class and Parse::Object DSL to support default permissions for all operations, pointer-based permissions (readUserFields, writeUserFields), and improved snake_case to camelCase field conversion. Updates integration and unit tests to cover new features, including default permission propagation, pointer permissions, and field name conversions.
1 parent cbba708 commit 7e7cf7f

6 files changed

Lines changed: 930 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,48 @@
22

33
### 3.2.1
44

5+
#### New Features
6+
7+
- **NEW**: Added `set_default_clp` method to set a default permission for all CLP operations at once. This is important because Parse Server treats missing operations as `{}` (no access, master key only).
8+
9+
```ruby
10+
class Document < Parse::Object
11+
# Set all operations to public by default
12+
set_default_clp public: true
13+
14+
# Or require authentication for all operations
15+
set_default_clp requires_authentication: true
16+
17+
# Or restrict all operations to specific roles
18+
set_default_clp roles: ["Admin", "Editor"]
19+
20+
# Then override specific operations as needed
21+
set_clp :delete, public: false, roles: ["Admin"]
22+
end
23+
```
24+
25+
- **NEW**: Added `set_read_user_fields` and `set_write_user_fields` for pointer-based permissions. These allow users referenced by pointer fields to have read/write access to objects.
26+
27+
```ruby
28+
class Document < Parse::Object
29+
belongs_to :owner, as: :user
30+
belongs_to :editor, as: :user
31+
32+
# Owner can read, editor can write
33+
set_read_user_fields [:owner]
34+
set_write_user_fields [:editor]
35+
36+
# Snake_case field names are auto-converted to camelCase
37+
end
38+
```
39+
40+
- **NEW**: Added `reset_clp!` method to reset CLPs to public defaults. Useful for clearing restrictive permissions that may have accumulated on the server.
41+
42+
```ruby
43+
# Reset all CLPs to public access
44+
Song.reset_clp!
45+
```
46+
547
#### Improvements
648

749
- **IMPROVED**: CLP methods now automatically convert snake_case Ruby property names to camelCase Parse Server field names. This provides consistency with the rest of the Parse Stack framework where you define properties in snake_case.
@@ -12,7 +54,7 @@
1254
class Document < Parse::Object
1355
property :internal_notes, :string
1456
property :secret_data, :string
15-
property :owner_user, :pointer, as: :user
57+
belongs_to :owner_user, as: :user
1658

1759
# Field names are auto-converted
1860
protect_fields "*", [:internal_notes, :secret_data]
@@ -33,17 +75,35 @@ end
3375

3476
```ruby
3577
class Document < Parse::Object
36-
property :owner_field, :pointer, as: :user
37-
property :editor_field, :pointer, as: :user
78+
belongs_to :owner_field, as: :user
79+
belongs_to :editor_field, as: :user
3880

3981
# pointer_fields are auto-converted
4082
set_clp :update, pointer_fields: [:owner_field, :editor_field]
4183
# Converts to: pointerFields: ["ownerField", "editorField"]
4284
end
4385
```
4486

87+
- **IMPROVED**: Added `include_defaults` parameter to `CLP#as_json`. When `true`, includes default permissions for all undefined operations (useful when pushing complete CLP to server).
88+
89+
```ruby
90+
clp = Parse::CLP.new
91+
clp.set_default_permission(public: true)
92+
clp.set_permission(:delete, roles: ["Admin"])
93+
94+
# Without defaults - only explicitly set operations
95+
clp.as_json
96+
# => {"delete" => {"role:Admin" => true}}
97+
98+
# With defaults - all operations included
99+
clp.as_json(include_defaults: true)
100+
# => {"find" => {"*" => true}, "get" => {"*" => true}, ... "delete" => {"role:Admin" => true}}
101+
```
102+
45103
#### Bug Fixes
46104

105+
- **FIXED**: `auto_upgrade!` now resets CLPs before applying new ones. Parse Server merges CLP updates rather than replacing them, so old restrictive permissions could persist and cause "Permission denied" errors. Now `auto_upgrade!` first resets CLPs to public defaults, then applies the model's CLP configuration.
106+
47107
- **FIXED**: Test setup for role membership now correctly uses `add_users()` method for adding users to roles (roles use Parse Relations, not Array properties).
48108

49109
### 3.2.0

lib/parse/model/clp.rb

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,15 @@ module Parse
6060
#
6161
# @see https://docs.parseplatform.org/rest/guide/#class-level-permissions
6262
class CLP
63-
# Valid CLP operation keys
63+
# Valid CLP operation keys for permission-based access
6464
OPERATIONS = %i[find get count create update delete addField].freeze
6565

66+
# Pointer-permission keys (users in these fields get read/write access)
67+
POINTER_PERMISSIONS = %i[readUserFields writeUserFields].freeze
68+
69+
# All valid CLP keys
70+
ALL_KEYS = (OPERATIONS + POINTER_PERMISSIONS + [:protectedFields]).freeze
71+
6672
# @return [Hash] the raw CLP hash
6773
attr_reader :permissions
6874

@@ -83,13 +89,50 @@ def parse_data(data)
8389
@protected_fields = value.transform_keys(&:to_s)
8490
elsif OPERATIONS.include?(key_sym)
8591
@permissions[key_sym] = value.transform_keys(&:to_s)
92+
elsif POINTER_PERMISSIONS.include?(key_sym)
93+
# readUserFields and writeUserFields are arrays of field names
94+
@permissions[key_sym] = Array(value)
8695
else
87-
# Store any other keys (like requiresAuthentication, etc.)
96+
# Store any other keys
8897
@permissions[key_sym] = value
8998
end
9099
end
91100
end
92101

102+
# Set pointer-permission fields for read access.
103+
# Users pointed to by these fields can read the object.
104+
# @param fields [Array<String, Symbol>] pointer field names
105+
# @return [self]
106+
# @example
107+
# clp.set_read_user_fields(:owner, :collaborators)
108+
def set_read_user_fields(*fields)
109+
@permissions[:readUserFields] = fields.flatten.map(&:to_s)
110+
self
111+
end
112+
113+
# Set pointer-permission fields for write access.
114+
# Users pointed to by these fields can write to the object.
115+
# @param fields [Array<String, Symbol>] pointer field names
116+
# @return [self]
117+
# @example
118+
# clp.set_write_user_fields(:owner)
119+
def set_write_user_fields(*fields)
120+
@permissions[:writeUserFields] = fields.flatten.map(&:to_s)
121+
self
122+
end
123+
124+
# Get the read user fields.
125+
# @return [Array<String>] pointer field names for read access
126+
def read_user_fields
127+
@permissions[:readUserFields] || []
128+
end
129+
130+
# Get the write user fields.
131+
# @return [Array<String>] pointer field names for write access
132+
def write_user_fields
133+
@permissions[:writeUserFields] || []
134+
end
135+
93136
# Set permissions for a specific operation.
94137
# @param operation [Symbol] one of :find, :get, :count, :create, :update, :delete, :addField
95138
# @param public_access [Boolean, nil] whether public access is allowed
@@ -255,14 +298,45 @@ def filter_fields(data, user: nil, roles: [], authenticated: nil)
255298
data.reject { |key, _| fields_to_hide.include?(key.to_s) }
256299
end
257300

301+
# The default permission to use for operations not explicitly set.
302+
# When set, `as_json` will include this for all undefined operations.
303+
# @return [Hash, nil] the default permission hash (e.g., { "*" => true })
304+
attr_accessor :default_permission
305+
258306
# Convert to Parse Server CLP format.
307+
#
308+
# IMPORTANT: Parse Server interprets missing operations as {} (no access).
309+
# If you have protectedFields but no operations defined, the class becomes
310+
# effectively master-key-only. Use `set_default_permission` or `include_defaults`
311+
# to ensure all operations are included.
312+
#
313+
# @param include_defaults [Boolean] whether to include default permissions
314+
# for operations that haven't been explicitly set. Defaults to true if
315+
# any CLPs are defined (operations or protectedFields).
259316
# @return [Hash] the CLP hash suitable for schema updates
260-
def as_json(*_args)
317+
def as_json(include_defaults: nil)
261318
result = {}
262319

320+
# Determine if we should include defaults
321+
# Auto-enable if any CLP settings exist and no explicit choice made
322+
should_include_defaults = if include_defaults.nil?
323+
present? && @default_permission
324+
else
325+
include_defaults
326+
end
327+
263328
# Add operation permissions
264329
OPERATIONS.each do |op|
265-
result[op.to_s] = @permissions[op] if @permissions[op]
330+
if @permissions[op]
331+
result[op.to_s] = @permissions[op]
332+
elsif should_include_defaults && @default_permission
333+
result[op.to_s] = @default_permission.dup
334+
end
335+
end
336+
337+
# Add pointer permissions (readUserFields, writeUserFields)
338+
POINTER_PERMISSIONS.each do |perm|
339+
result[perm.to_s] = @permissions[perm] if @permissions[perm]&.any?
266340
end
267341

268342
# Add protected fields
@@ -271,6 +345,26 @@ def as_json(*_args)
271345
result
272346
end
273347

348+
# Set the default permission for operations not explicitly configured.
349+
# This ensures that when CLPs are pushed to Parse Server, all operations
350+
# have explicit permissions (avoiding the implicit {} = no access behavior).
351+
#
352+
# @param public_access [Boolean] whether public access is allowed
353+
# @param requires_authentication [Boolean] whether authentication is required
354+
# @param roles [Array<String>] role names that have access
355+
# @return [self]
356+
# @example
357+
# clp.set_default_permission(public_access: true) # Default to public
358+
# clp.set_default_permission(requires_authentication: true) # Default to auth required
359+
def set_default_permission(public_access: nil, requires_authentication: false, roles: [])
360+
perm = {}
361+
perm["*"] = true if public_access == true
362+
perm["requiresAuthentication"] = true if requires_authentication
363+
Array(roles).each { |role| perm["role:#{role}"] = true }
364+
@default_permission = perm.empty? ? nil : perm
365+
self
366+
end
367+
274368
alias_method :to_h, :as_json
275369

276370
# Check if there are any CLP settings.

lib/parse/model/core/schema.rb

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,37 @@ def fetch_schema
7373
Parse::Model::CLASS_SCHEMA
7474
].freeze
7575

76+
# Default CLP that grants public access to all operations.
77+
# Used to reset CLPs before applying new ones.
78+
DEFAULT_PUBLIC_CLP = {
79+
"find" => { "*" => true },
80+
"get" => { "*" => true },
81+
"count" => { "*" => true },
82+
"create" => { "*" => true },
83+
"update" => { "*" => true },
84+
"delete" => { "*" => true },
85+
"addField" => { "*" => true }
86+
}.freeze
87+
88+
# Reset the CLP on the server to public defaults.
89+
# This clears any existing restrictive permissions.
90+
#
91+
# @param client [Parse::Client] optional client to use
92+
# @return [Parse::Response] the response from the server
93+
#
94+
# @example Reset CLPs to public
95+
# Song.reset_clp!
96+
def reset_clp!(client: nil)
97+
client ||= self.client
98+
99+
unless client.master_key.present?
100+
warn "[Parse] CLP reset for #{parse_class} requires the master key!"
101+
return nil
102+
end
103+
104+
client.update_schema(parse_class, { "classLevelPermissions" => DEFAULT_PUBLIC_CLP })
105+
end
106+
76107
# A class method for non-destructive auto upgrading a remote schema based
77108
# on the properties and relations you have defined in your local model. If
78109
# the collection doesn't exist, we create the schema. If the collection already
@@ -123,9 +154,15 @@ def auto_upgrade!(include_clp: true)
123154
h
124155
end
125156

126-
# Add CLP updates if configured and requested
157+
# Handle CLP updates if configured and requested
127158
if include_clp && respond_to?(:class_permissions) && class_permissions.present?
128-
current_schema[:classLevelPermissions] = class_permissions.as_json
159+
# First, reset CLPs to public defaults to clear any old restrictive permissions.
160+
# Parse Server merges CLPs rather than replacing them, so old keys can persist
161+
# and cause "Permission denied" errors if not explicitly cleared.
162+
reset_clp!
163+
164+
# Now apply the new CLP configuration
165+
current_schema[:classLevelPermissions] = class_permissions.as_json(include_defaults: true)
129166
end
130167

131168
return true if current_schema[:fields].empty? && !current_schema[:classLevelPermissions]
@@ -136,7 +173,7 @@ def auto_upgrade!(include_clp: true)
136173
initial_schema = schema
137174
# Include CLPs in initial schema creation if configured
138175
if include_clp && respond_to?(:class_permissions) && class_permissions.present?
139-
initial_schema[:classLevelPermissions] = class_permissions.as_json
176+
initial_schema[:classLevelPermissions] = class_permissions.as_json(include_defaults: true)
140177
end
141178
client.create_schema parse_class, initial_schema
142179
end

lib/parse/model/object.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,90 @@ def class_permissions
384384

385385
alias_method :clp, :class_permissions
386386

387+
# Set default permissions for all CLP operations at once.
388+
# This is useful for establishing a baseline before customizing specific operations.
389+
#
390+
# @param public [Boolean] whether public access is allowed for all operations
391+
# @param roles [Array<String>] role names that have access to all operations
392+
# @param requires_authentication [Boolean] whether authentication is required for all operations
393+
#
394+
# @example Public read, authenticated write
395+
# class Document < Parse::Object
396+
# # Start with public read access for all operations
397+
# set_default_clp public: true
398+
#
399+
# # Then restrict write operations
400+
# set_clp :create, requires_authentication: true
401+
# set_clp :update, requires_authentication: true
402+
# set_clp :delete, public: false, roles: ["Admin"]
403+
# end
404+
#
405+
# @example Role-based access for everything
406+
# class AdminReport < Parse::Object
407+
# # Only admins can do anything
408+
# set_default_clp public: false, roles: ["Admin"]
409+
# end
410+
#
411+
# @example Authenticated users only
412+
# class PrivateData < Parse::Object
413+
# # Require authentication for all operations
414+
# set_default_clp requires_authentication: true
415+
# end
416+
def set_default_clp(public: nil, roles: [], requires_authentication: false)
417+
# Set the default permission on the CLP instance
418+
# This will be used by as_json to fill in missing operations
419+
class_permissions.set_default_permission(
420+
public_access: public,
421+
roles: Array(roles),
422+
requires_authentication: requires_authentication
423+
)
424+
425+
# Also explicitly set all operations to ensure they're included
426+
Parse::CLP::OPERATIONS.each do |operation|
427+
set_clp(operation, public: public, roles: roles, requires_authentication: requires_authentication)
428+
end
429+
end
430+
431+
# Set pointer-permission fields for read access.
432+
# Users pointed to by these fields can read objects of this class.
433+
# This is an alternative to ACLs for owner-based access control.
434+
#
435+
# @param fields [Array<Symbol, String>] pointer field names (snake_case supported)
436+
# @example
437+
# class Document < Parse::Object
438+
# belongs_to :owner, as: :user
439+
# belongs_to :editor, as: :user
440+
#
441+
# # Only owner and editor can read
442+
# set_read_user_fields :owner, :editor
443+
# end
444+
def set_read_user_fields(*fields)
445+
converted = fields.flatten.map do |f|
446+
field_sym = f.to_sym
447+
field_map[field_sym] || f.to_s.camelize(:lower)
448+
end
449+
class_permissions.set_read_user_fields(*converted)
450+
end
451+
452+
# Set pointer-permission fields for write access.
453+
# Users pointed to by these fields can write to objects of this class.
454+
#
455+
# @param fields [Array<Symbol, String>] pointer field names (snake_case supported)
456+
# @example
457+
# class Document < Parse::Object
458+
# belongs_to :owner, as: :user
459+
#
460+
# # Only owner can write
461+
# set_write_user_fields :owner
462+
# end
463+
def set_write_user_fields(*fields)
464+
converted = fields.flatten.map do |f|
465+
field_sym = f.to_sym
466+
field_map[field_sym] || f.to_s.camelize(:lower)
467+
end
468+
class_permissions.set_write_user_fields(*converted)
469+
end
470+
387471
# Set a class-level permission for a specific operation.
388472
# This is the main DSL method for configuring CLPs in your model.
389473
#

0 commit comments

Comments
 (0)