Class: LcpRuby::Authorization::InheritedParentValidator

Inherits:
ActiveModel::Validator
  • Object
show all
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: i18n_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: i18n_message) if result == :fail
    end
  end
end