Class: LcpRuby::Authorization::InheritedParentValidator
- Inherits:
-
ActiveModel::Validator
- Object
- ActiveModel::Validator
- LcpRuby::Authorization::InheritedParentValidator
- Defined in:
- lib/lcp_ruby/authorization/inherited_parent_validator.rb
Overview
Auto-applied to every dynamic AR model. Closes the design’s “create attack vector” (§4.2 / §6.10): without this, a user with ‘crud: [create]` on a child model could submit `<fk>: <id>` pointing at a parent record they cannot see, because `inherited_record_check` skips the cascade for `:create` (the child does not yet exist, so no parent association traversal).
Wiring on dynamic models — always-wire:
`ModelFactory::Builder#apply_inherited_parent_validator` calls
`validates_with(InheritedParentValidator)` unconditionally on every
dynamic AR model. The validator itself early-returns at runtime
when the model's currently-resolved permission has no
`inherits_from`. Always-wiring covers DB-backed `inherits_from`
declarations (`permission_source: :model`) that are not visible at
boot time but appear at the registry level — the runtime lookup
resolves through `Permissions::SourceResolver`.
Wiring on ‘bind_to:` host AR models — smart-auto + opt-in:
Hosts opt INTO this validator (rather than receiving it
unconditionally) to keep host-managed AR classes' `validators`
arrays free of no-op LCP entries. The applicator
(`ModelFactory::InheritedParentValidatorApplicator`) is registered
in `BIND_TO_APPLICATOR_MAP`. `Engine.apply_bind_to_features`
smart-auto-includes it when the YAML permission for the bound
model declares `inherits_from`. Configurators with DB-backed perms
on bind_to models opt in explicitly via
`bind_to_apply: [inherited_parent_validator]`.
The check itself uses the same per-request ‘Authorization::Cache` as the read-time cascade, so it imposes no extra cost on indexes or shows.
Skip semantics:
* Permission has no `inherits_from` — most models. Validator
no-ops at runtime; the per-save overhead is one hash lookup.
* No `LcpRuby::Current.user` — seeds, console, raw model API, and
ExecutorJob without handler-specific setup all hit this path.
Validation no-ops silently to match the platform-wide convention
that authorization checks pass in system contexts.
* FK is nil — delegate to the configurator's `belongs_to ...,
required: true` declaration. "FK must be present" and "FK must
point at a visible record" are distinct concerns.
Bypass paths (not enforced; documented as gaps):
* `update_columns` / `update_all` / `insert_all` / `upsert_all` skip
ActiveRecord validations per Rails design.
Instance Method Summary collapse
Instance Method Details
#validate(record) ⇒ Object
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/lcp_ruby/authorization/inherited_parent_validator.rb', line 50 def validate(record) inherits = inherits_from_for(record) return unless inherits user = LcpRuby::Current.user return unless user results = inherits[:parents].map do |parent_name| assoc_name = inherits[:via] || AssociationLookup.belongs_to_name_for(record.class, parent_name) next [ :skip, nil ] if assoc_name.nil? fk_value = fk_value_for(record, assoc_name) next [ :skip, nil ] if fk_value.nil? # AR presence validation handles required FKs parent_scope = Cache.parent_policy_scope(parent_name, user) if parent_scope.exists?(id: fk_value) [ :pass, assoc_name ] else [ :fail, assoc_name ] end end # Mode handling here is intentionally NOT shared with # `PermissionEvaluator#inherited_record_check` (which performs the # read-time cascade). The two call sites have genuinely different # output shapes: # - Read-time: returns boolean (visible / not visible) via # `parents_pass.all?` / `.any?` — :skip is collapsed into # "no visibility" because read-time has no notion of # "let AR presence handle it". # - Create-time (here): adds errors to a record per failed # association, with `:skip` (FK nil) deliberately neutral # so AR's own presence validation handles required-FK errors. # A unified helper would either be too generic (re-deriving # behavior at each call site) or fragmented (one helper per # shape). Keeping them separate is the cleaner choice. case inherits[:mode] when "union" # Reject only when no parent passes. Single error on first failed # association (which one is arbitrary; users only need ANY parent # visible). All-skip results in no error — AR presence handles # the required-FK case for the user. if results.none? { |r, _| r == :pass } && (failed = results.find { |r, _| r == :fail }) record.errors.add(failed[1], :inaccessible_parent, message: ) end else # intersection (default) # Reject every failed association so the user sees which FKs are problems. # :skip results contribute nothing — AR presence handles them. results.each do |result, assoc| record.errors.add(assoc, :inaccessible_parent, message: ) if result == :fail end end end |