parse-stack-next 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/classes/role.rb +227 -28
  75. data/lib/parse/model/classes/user.rb +60 -1
  76. data/lib/parse/model/clp.rb +4 -4
  77. data/lib/parse/model/core/actions.rb +97 -15
  78. data/lib/parse/model/core/create_lock.rb +0 -2
  79. data/lib/parse/model/core/describe.rb +57 -57
  80. data/lib/parse/model/core/embed_managed.rb +21 -23
  81. data/lib/parse/model/core/fetching.rb +2 -0
  82. data/lib/parse/model/core/indexing.rb +14 -14
  83. data/lib/parse/model/core/parse_reference.rb +1 -1
  84. data/lib/parse/model/core/properties.rb +2 -2
  85. data/lib/parse/model/core/querying.rb +1 -1
  86. data/lib/parse/model/core/schema.rb +2 -2
  87. data/lib/parse/model/core/search_indexing.rb +2 -2
  88. data/lib/parse/model/core/vector_searchable.rb +4 -5
  89. data/lib/parse/model/file.rb +21 -15
  90. data/lib/parse/model/geojson.rb +2 -2
  91. data/lib/parse/model/geopoint.rb +1 -0
  92. data/lib/parse/model/object.rb +132 -66
  93. data/lib/parse/model/pointer.rb +2 -0
  94. data/lib/parse/model/polygon.rb +3 -6
  95. data/lib/parse/model/push.rb +2 -2
  96. data/lib/parse/model/vector.rb +3 -1
  97. data/lib/parse/mongodb.rb +512 -282
  98. data/lib/parse/pipeline_security.rb +3 -0
  99. data/lib/parse/query/constraints.rb +46 -46
  100. data/lib/parse/query.rb +146 -59
  101. data/lib/parse/retrieval/agent_tool.rb +34 -20
  102. data/lib/parse/retrieval/chunk.rb +1 -0
  103. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  104. data/lib/parse/retrieval/reranker.rb +3 -4
  105. data/lib/parse/retrieval/retriever.rb +4 -6
  106. data/lib/parse/schema/index_migrator.rb +26 -26
  107. data/lib/parse/schema/search_index_migrator.rb +19 -19
  108. data/lib/parse/stack/tasks.rb +6 -6
  109. data/lib/parse/stack/version.rb +1 -1
  110. data/lib/parse/stack.rb +10 -10
  111. data/lib/parse/vector_search/hybrid.rb +66 -41
  112. data/lib/parse/vector_search.rb +14 -11
  113. data/lib/parse/webhooks/payload.rb +1 -0
  114. data/lib/parse/webhooks/registration.rb +4 -4
  115. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  116. data/lib/parse/webhooks.rb +22 -4
  117. data/parse-stack-next.gemspec +6 -6
  118. metadata +10 -1
@@ -0,0 +1,493 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "set"
5
+ require_relative "clp_scope"
6
+
7
+ module Parse
8
+ # Local, evidence-aware inspection of Parse Server object permissions.
9
+ #
10
+ # This is a policy preflight, not an authorization boundary: sessions can be
11
+ # revoked and Cloud Code or custom adapters can add rules the SDK cannot see.
12
+ # The actual Parse Server request remains authoritative. Boolean convenience
13
+ # predicates accept only `:allowed`; both `:denied` and `:unknown` fail closed.
14
+ module Access
15
+ OPERATIONS = %i[read write delete].freeze
16
+ CLP_OPERATIONS = { read: :get, write: :update, delete: :delete }.freeze
17
+ SUPPORTED_SYSTEM_CLASSES = %w[_User _Role].freeze
18
+
19
+ # An access answer together with the evidence behind it.
20
+ class Decision
21
+ attr_reader :status, :operation, :reasons, :details
22
+
23
+ def initialize(status:, operation:, reasons: [], details: {})
24
+ @status = status.to_sym
25
+ @operation = operation.to_sym
26
+ @reasons = Array(reasons).compact.map(&:to_sym).uniq.freeze
27
+ @details = details.dup.freeze
28
+ freeze
29
+ end
30
+
31
+ def allowed?
32
+ status == :allowed
33
+ end
34
+
35
+ def denied?
36
+ status == :denied
37
+ end
38
+
39
+ def unknown?
40
+ status == :unknown
41
+ end
42
+
43
+ def to_h
44
+ { status: status, operation: operation, reasons: reasons, details: details }
45
+ end
46
+ end
47
+
48
+ LayerResult = Struct.new(:status, :via, :reason, :role_claims, keyword_init: true)
49
+ Attempt = Struct.new(:decision, :roles_could_help, keyword_init: true)
50
+ private_constant :LayerResult, :Attempt
51
+
52
+ class << self
53
+ # Inspect one operation. `authenticated: true` is an assertion by the
54
+ # caller; it does not validate a session token. When omitted for a user,
55
+ # an attached session token is accepted as local evidence. An id-only
56
+ # user is not treated as authenticated. A role represents a hypothetical
57
+ # authenticated member, but cannot satisfy user/pointer-specific rules.
58
+ #
59
+ # @param principal [Parse::User, Parse::Role]
60
+ # @param object [Parse::Object]
61
+ # @param operation [Symbol] `:read`, `:write`, or `:delete`.
62
+ # @param client [Parse::Client, nil] application whose CLP and role graph
63
+ # should be inspected. Defaults to the principal's configured client.
64
+ # @param authenticated [Boolean, nil] explicit authentication evidence.
65
+ # @param max_role_depth [Integer] maximum inherited-role traversal depth.
66
+ # @return [Decision]
67
+ def check(principal:, object:, operation:, client: nil, authenticated: nil,
68
+ max_role_depth: 10)
69
+ Checker.new(
70
+ principal: principal,
71
+ object: object,
72
+ client: client,
73
+ authenticated: authenticated,
74
+ max_role_depth: max_role_depth,
75
+ ).check(operation)
76
+ end
77
+
78
+ # Inspect multiple operations while sharing one role-closure lookup.
79
+ # @return [Hash<Symbol, Decision>]
80
+ def check_all(principal:, object:, operations: OPERATIONS, client: nil,
81
+ authenticated: nil, max_role_depth: 10)
82
+ checker = Checker.new(
83
+ principal: principal,
84
+ object: object,
85
+ client: client,
86
+ authenticated: authenticated,
87
+ max_role_depth: max_role_depth,
88
+ )
89
+ Array(operations).each_with_object({}) do |operation, decisions|
90
+ op = operation.to_sym
91
+ decisions[op] = checker.check(op)
92
+ end.freeze
93
+ end
94
+ end
95
+
96
+ # Stateful only for the lifetime of one check/check_all call so an inherited
97
+ # role closure can be reused without becoming a stale authorization cache.
98
+ class Checker
99
+ def initialize(principal:, object:, client:, authenticated:, max_role_depth:)
100
+ @principal = principal
101
+ @object = object
102
+ @client = client || principal_client
103
+ @authentication = authentication_state(authenticated)
104
+ @max_role_depth = Integer(max_role_depth)
105
+ @expanded_claims = nil
106
+ @role_lookup_error = nil
107
+ rescue ArgumentError, TypeError
108
+ @max_role_depth = 0
109
+ end
110
+
111
+ def check(operation)
112
+ op = operation.to_sym
113
+ raise ArgumentError, "unsupported access operation: #{operation.inspect}" unless
114
+ OPERATIONS.include?(op)
115
+
116
+ preflight = preflight_decision(op)
117
+ return preflight if preflight
118
+
119
+ case @authentication
120
+ when :unknown
121
+ check_with_unknown_authentication(op)
122
+ when :authenticated
123
+ check_with_authenticated_principal(op)
124
+ else
125
+ evaluate(op, public_claims, authenticated: false).decision
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def preflight_decision(operation)
132
+ unless valid_principal?
133
+ return decision(:unknown, operation, :unsupported_principal)
134
+ end
135
+ unless defined?(Parse::Object) && @object.is_a?(Parse::Object)
136
+ return decision(:unknown, operation, :invalid_target)
137
+ end
138
+ return decision(:unknown, operation, :client_required) if @client.nil?
139
+ if @object.respond_to?(:has_selective_keys?) && @object.has_selective_keys?
140
+ return decision(:unknown, operation, :target_partially_fetched)
141
+ end
142
+ return decision(:unknown, operation, :target_is_pointer) if @object.pointer?
143
+
144
+ class_name = @object.parse_class.to_s
145
+ if class_name.start_with?("_") && !SUPPORTED_SYSTEM_CLASSES.include?(class_name)
146
+ return decision(:unknown, operation, :unsupported_system_class)
147
+ end
148
+
149
+ user_target_preflight(operation, class_name)
150
+ rescue StandardError
151
+ decision(:unknown, operation, :target_state_unavailable)
152
+ end
153
+
154
+ def user_target_preflight(operation, class_name)
155
+ return unless class_name == Parse::Model::CLASS_USER
156
+ return if operation == :read
157
+
158
+ # Parse Server only permits a user to update/delete their own `_User`
159
+ # row. A role cannot identify which member is making the request.
160
+ return decision(:unknown, operation, :user_self_requires_concrete_member) if role_principal?
161
+ return decision(:denied, operation, :authentication_required) if @authentication == :anonymous
162
+ return decision(:unknown, operation, :authentication_unverified) if @authentication == :unknown
163
+
164
+ principal_id = safe_user_id
165
+ target_id = @object.id.to_s
166
+ if principal_id.empty? || target_id.empty?
167
+ return decision(:unknown, operation, :user_identity_unavailable)
168
+ end
169
+ return decision(:denied, operation, :user_self_only) unless principal_id == target_id
170
+
171
+ nil
172
+ end
173
+
174
+ def check_with_unknown_authentication(operation)
175
+ anonymous = evaluate(operation, public_claims, authenticated: false)
176
+ return anonymous.decision if anonymous.decision.allowed?
177
+
178
+ # Probe only the direct identity claim. Never traverse a role graph for
179
+ # an id-only User: without a session that would let callers enumerate
180
+ # another user's effective permissions by assigning a victim objectId.
181
+ asserted = evaluate(operation, direct_claims, authenticated: true)
182
+ if asserted.decision.denied? && !asserted.roles_could_help
183
+ asserted.decision
184
+ else
185
+ decision(:unknown, operation, :authentication_unverified)
186
+ end
187
+ end
188
+
189
+ def check_with_authenticated_principal(operation)
190
+ direct = evaluate(operation, direct_claims, authenticated: true)
191
+ return direct.decision if direct.decision.allowed?
192
+ return direct.decision unless direct.roles_could_help
193
+ return decision(:unknown, operation, :invalid_role_depth) if @max_role_depth <= 0
194
+ unless role_identity_available?
195
+ return decision(:unknown, operation, :role_identity_unavailable)
196
+ end
197
+
198
+ claims = expanded_claims
199
+ if @role_lookup_error
200
+ return decision(
201
+ :unknown,
202
+ operation,
203
+ :role_membership_unavailable,
204
+ role_error: @role_lookup_error.class.name,
205
+ )
206
+ end
207
+ evaluate(operation, claims, authenticated: true).decision
208
+ end
209
+
210
+ def evaluate(operation, claims, authenticated:)
211
+ acl = acl_evaluation(operation, claims, authenticated: authenticated)
212
+ clp = Parse::CLPScope.evaluate_access(
213
+ @object.parse_class,
214
+ CLP_OPERATIONS.fetch(operation),
215
+ claims: claims,
216
+ authenticated: authenticated,
217
+ user_id: concrete_user_id(authenticated),
218
+ client: @client,
219
+ )
220
+
221
+ row_status = :allowed
222
+ row_reason = nil
223
+ if clp.row_check_required?
224
+ if clp.pointer_fields.any? { |field| !pointer_field_supported?(field) }
225
+ row_status = :unknown
226
+ row_reason = :pointer_field_schema_unavailable
227
+ elsif pointer_field_locally_changed?(clp.pointer_fields)
228
+ row_status = :unknown
229
+ row_reason = :pointer_field_has_local_changes
230
+ elsif pointer_fields_match?(clp.pointer_fields, concrete_user_id(authenticated))
231
+ row_status = :allowed
232
+ else
233
+ row_status = :denied
234
+ row_reason = :pointer_field_mismatch
235
+ end
236
+ end
237
+
238
+ statuses = [acl.status, clp.status, row_status]
239
+ status = if statuses.include?(:denied)
240
+ :denied
241
+ elsif statuses.include?(:unknown)
242
+ :unknown
243
+ else
244
+ :allowed
245
+ end
246
+ reasons = [acl.reason, clp.reason, row_reason]
247
+ details = { acl_via: acl.via, clp_via: clp.via }.compact
248
+
249
+ # Parent/user roles can recover a failed layer only when every other
250
+ # AND-ed layer is either already allowed or has its own role branch.
251
+ acl_recoverable = acl.status == :allowed || acl.role_claims.any?
252
+ clp_recoverable = clp.allowed? || clp.role_claims.any?
253
+ role_branch_exists = acl.role_claims.any? || clp.role_claims.any?
254
+ roles_could_help = status != :allowed && role_branch_exists &&
255
+ acl_recoverable && clp_recoverable
256
+
257
+ result = decision(status, operation, reasons, details)
258
+ if role_principal? && @object.parse_class == Parse::Model::CLASS_USER &&
259
+ operation == :read && !result.allowed?
260
+ result = decision(:unknown, operation, :user_self_requires_concrete_member)
261
+ end
262
+
263
+ Attempt.new(decision: result, roles_could_help: roles_could_help)
264
+ rescue StandardError => e
265
+ Attempt.new(
266
+ decision: decision(:unknown, operation, :permission_evaluation_failed,
267
+ error: e.class.name),
268
+ roles_could_help: false,
269
+ )
270
+ end
271
+
272
+ def acl_evaluation(operation, claims, authenticated:)
273
+ if user_self?(authenticated)
274
+ return layer(:allowed, via: :user_self)
275
+ end
276
+
277
+ state = if @object.respond_to?(:authorization_acl_state)
278
+ @object.authorization_acl_state
279
+ else
280
+ :unknown
281
+ end
282
+ return layer(:unknown, reason: :acl_unavailable) if state == :unknown
283
+
284
+ if @object.id.to_s.length.positive? && @object.respond_to?(:acl_changed?) &&
285
+ @object.acl_changed?
286
+ return layer(:unknown, reason: :acl_has_local_changes)
287
+ end
288
+ return layer(:allowed, via: :public_default) if state == :absent
289
+
290
+ acl = @object.acl
291
+ return layer(:unknown, reason: :acl_unavailable) if acl.nil?
292
+ keys = if operation == :read
293
+ acl.readable_by
294
+ else
295
+ acl.writable_by
296
+ end
297
+ keys = Array(keys).map(&:to_s)
298
+ roles = keys.select { |key| key.start_with?("role:") }
299
+ if keys.any? { |key| claims.include?(key) }
300
+ via = keys.include?(Parse::ACL::PUBLIC) ? :public : :principal
301
+ layer(:allowed, via: via, role_claims: roles)
302
+ else
303
+ layer(:denied, reason: :acl_denied, role_claims: roles)
304
+ end
305
+ end
306
+
307
+ def user_self?(authenticated)
308
+ return false unless authenticated && user_principal?
309
+ return false unless @object.parse_class == Parse::Model::CLASS_USER
310
+ principal_id = safe_user_id
311
+ !principal_id.empty? && principal_id == @object.id.to_s
312
+ end
313
+
314
+ def pointer_fields_match?(fields, user_id)
315
+ return false if user_id.to_s.empty?
316
+ return true if fields.any? { |field| local_pointer_value_matches?(field, user_id) }
317
+ document = @object.as_json(only_fetched: false)
318
+ Parse::CLPScope.filter_by_pointer_fields([document], fields, user_id).any?
319
+ end
320
+
321
+ def pointer_field_supported?(field)
322
+ local, type = pointer_field_definition(field)
323
+ return false if local.nil?
324
+ return true if type == :array
325
+ return false unless type == :pointer
326
+ target = @object.class.references[local]
327
+ Parse::Model.same_parse_class?(target, Parse::Model::CLASS_USER)
328
+ rescue StandardError
329
+ false
330
+ end
331
+
332
+ def pointer_field_definition(field)
333
+ local = @object.field_map.find do |key, remote|
334
+ key.to_s == field.to_s || remote.to_s == field.to_s
335
+ end&.first
336
+ [local, local && @object.class.fields[local]]
337
+ end
338
+
339
+ # Included pointers may be hydrated as full Parse::User objects. Their
340
+ # JSON representation has `__type: "Object"`, so the strict wire-format
341
+ # matcher correctly rejects it as a pointer; the typed local value still
342
+ # proves both the `_User` class and objectId without triggering a getter
343
+ # (and therefore without autofetching).
344
+ def local_pointer_value_matches?(field, user_id)
345
+ local, = pointer_field_definition(field)
346
+ return false if local.nil?
347
+ value = @object.instance_variable_get(:"@#{local}")
348
+ values = if value.respond_to?(:collection)
349
+ value.collection
350
+ elsif value.is_a?(Array)
351
+ value
352
+ else
353
+ [value]
354
+ end
355
+ values.any? do |candidate|
356
+ candidate.respond_to?(:parse_class) && candidate.respond_to?(:id) &&
357
+ Parse::Model.same_parse_class?(candidate.parse_class, Parse::Model::CLASS_USER) &&
358
+ candidate.id.to_s == user_id.to_s
359
+ end
360
+ rescue StandardError
361
+ false
362
+ end
363
+
364
+ def pointer_field_locally_changed?(fields)
365
+ return false if @object.id.to_s.empty?
366
+ changed = @object.respond_to?(:changed) ? @object.changed.map(&:to_s) : []
367
+ fields.any? do |field|
368
+ local = @object.field_map.find { |_key, remote| remote.to_s == field.to_s }&.first
369
+ changed.include?(field.to_s) || (local && changed.include?(local.to_s))
370
+ end
371
+ end
372
+
373
+ def expanded_claims
374
+ return @expanded_claims if @expanded_claims
375
+ claims = direct_claims.dup
376
+ names = if user_principal?
377
+ Parse::Role.all_for_user(
378
+ @principal,
379
+ max_depth: @max_role_depth,
380
+ client: @client,
381
+ strict: true,
382
+ )
383
+ else
384
+ @principal.all_parent_role_names(
385
+ max_depth: @max_role_depth,
386
+ client: @client,
387
+ strict: true,
388
+ )
389
+ end
390
+ Array(names).each do |name|
391
+ next if name.to_s.empty?
392
+ claims << "role:#{name}"
393
+ end
394
+ @expanded_claims = claims.freeze
395
+ rescue StandardError => e
396
+ @role_lookup_error = e
397
+ @expanded_claims = direct_claims.freeze
398
+ end
399
+
400
+ def public_claims
401
+ Set.new([Parse::ACL::PUBLIC]).freeze
402
+ end
403
+
404
+ def direct_claims
405
+ claims = Set.new([Parse::ACL::PUBLIC])
406
+ if user_principal?
407
+ user_id = safe_user_id
408
+ claims << user_id unless user_id.empty?
409
+ else
410
+ role_name = safe_role_name
411
+ claims << "role:#{role_name}" unless role_name.empty?
412
+ end
413
+ claims
414
+ end
415
+
416
+ def concrete_user_id(authenticated)
417
+ authenticated && user_principal? ? safe_user_id : nil
418
+ end
419
+
420
+ def authentication_state(explicit)
421
+ return :authenticated if explicit == true
422
+ return :anonymous if explicit == false
423
+ return :authenticated if role_principal?
424
+ return :unknown unless user_principal?
425
+ token = @principal.instance_variable_get(:@session_token)
426
+ token.is_a?(String) && !token.empty? ? :authenticated : :unknown
427
+ end
428
+
429
+ def valid_principal?
430
+ user_principal? || role_principal?
431
+ end
432
+
433
+ def user_principal?
434
+ defined?(Parse::User) && @principal.is_a?(Parse::User)
435
+ end
436
+
437
+ def role_principal?
438
+ defined?(Parse::Role) && @principal.is_a?(Parse::Role)
439
+ end
440
+
441
+ def safe_user_id
442
+ user_principal? ? @principal.id.to_s : ""
443
+ rescue StandardError
444
+ ""
445
+ end
446
+
447
+ # Reading Role#name can autofetch an id-only role. Permission inspection
448
+ # must stay local, so use only an already-hydrated value.
449
+ def safe_role_name
450
+ value = @principal.instance_variable_get(:@name) if role_principal?
451
+ value.to_s
452
+ rescue StandardError
453
+ ""
454
+ end
455
+
456
+ def role_identity_available?
457
+ if user_principal?
458
+ !safe_user_id.empty?
459
+ else
460
+ !safe_role_name.empty? && !@principal.id.to_s.empty?
461
+ end
462
+ rescue StandardError
463
+ false
464
+ end
465
+
466
+ def principal_client
467
+ @principal.client if @principal.respond_to?(:client)
468
+ rescue StandardError
469
+ nil
470
+ end
471
+
472
+ def layer(status, via: nil, reason: nil, role_claims: [])
473
+ LayerResult.new(
474
+ status: status,
475
+ via: via,
476
+ reason: reason,
477
+ role_claims: Array(role_claims).freeze,
478
+ )
479
+ end
480
+
481
+ def decision(status, operation, reasons = [], details = {})
482
+ Decision.new(
483
+ status: status,
484
+ operation: operation,
485
+ reasons: reasons,
486
+ details: details,
487
+ )
488
+ end
489
+ end
490
+
491
+ private_constant :Checker
492
+ end
493
+ end