Module: Ruact::SignedReferences

Included in:
Ruact
Defined in:
lib/ruact/signed_references.rb

Overview

Story 13.2 (FR96) — the canonical record-reference primitive: a signed, scoped, expiring SignedGlobalID token instead of a raw id or attribute dump.

A controller that puts @post.id (or an attribute hash) into props hands the client a forgeable, unscoped, non-expiring reference: swap id: 7 for id: 8 and, if the action trusts params[:id] raw, it reaches a record it should not. Ruact.signed_global_id mints a SignedGlobalID instead — HMAC-signed by the app secret (tamper-proof), bound to a for: purpose (scoped to one use-site), and expires_in:-bounded — and Ruact.locate_signed resolves it back, raising InvalidSignedGlobalIDError (→ a clean 4xx) on a tampered, expired, or wrong-purpose token. This is the structural antidote to forged-reference attacks, the inbound-safety counterpart to the serialize-only invariant (Story 13.1).

This is opt-in and explicit — it follows the same grain as ruact_props (an explicit allowlist, not auto-detection). The developer reaches for the helper; ruact never silently rewrites every ActiveRecord::Base in props, and never auto-coerces inbound params into records.

Examples:

Outbound — sign a reference in props, resolve it in an action

# controller producing props
def edit
  @post_ref = Ruact.signed_global_id(@post, for: :post_edit, expires_in: 1.hour)
end

# server function receiving the reference back from the client
def update
  post = Ruact.locate_signed(params[:post_ref], for: :post_edit)
  post.update!(post_params)
end

Instance Method Summary collapse

Instance Method Details

#locate_signed(token, for: UNSET) ⇒ GlobalID::Identification

Resolve a signed token back to its record, verifying signature, expiry, and purpose. The inverse of #signed_global_id.

Parameters:

  • token (String, nil)

    the signed token received from the client.

  • for (Symbol, String) (defaults to: UNSET)

    the purpose the token must match — resolved the same way as #signed_global_id (omitted → config → raise).

Returns:

  • (GlobalID::Identification)

    the located record.

Raises:

  • (Ruact::InvalidSignedGlobalIDError)

    when the token is tampered, expired, or scoped to a different purpose (locate_signed returns nil). The structured-error chain maps this to a clean 4xx — no ActiveRecord::RecordNotFound leak, no raw-id trust.

  • (Ruact::Error)

    when a purpose cannot be resolved.



91
92
93
94
95
96
97
98
99
100
# File 'lib/ruact/signed_references.rb', line 91

def locate_signed(token, for: UNSET)
  __ruact_require_global_id!
  purpose = __ruact_resolve_purpose(binding.local_variable_get(:for))
  record  = GlobalID::Locator.locate_signed(token, for: purpose)
  return record unless record.nil?

  raise Ruact::InvalidSignedGlobalIDError,
        "Signed reference could not be verified for purpose #{purpose.inspect} " \
        "(it may be tampered, expired, or scoped to a different purpose)."
end

#signed_global_id(record, for: UNSET, expires_in: UNSET) ⇒ String

Mint a SignedGlobalID token string for a record, scoped to a for: purpose and bounded by expires_in:.

Parameters:

  • record (GlobalID::Identification)

    an ActiveRecord (or any GlobalID-locatable) record — anything responding to #to_sgid.

  • for (Symbol, String) (defaults to: UNSET)

    the purpose binding the token to one use-site. Omitted → Configuration#signed_global_id_default_purpose; if neither is set (or it resolves to nil), raises Error — ruact refuses to sign an unscoped reference.

  • expires_in (ActiveSupport::Duration, nil) (defaults to: UNSET)

    token lifetime. Omitted → Configuration#signed_global_id_default_expires_in; if neither is set, raises Error. Pass an explicit nil to deliberately mint a non-expiring token (a reviewed per-call choice).

Returns:

  • (String)

    the signed token, safe to serialize as a prop (it is a plain String, so the Flight serializer carries it unchanged).

Raises:

  • (Ruact::Error)

    when the record is not GlobalID-locatable, or when a purpose/expiry cannot be resolved.



65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/ruact/signed_references.rb', line 65

def signed_global_id(record, for: UNSET, expires_in: UNSET)
  __ruact_require_global_id!
  purpose = __ruact_resolve_purpose(binding.local_variable_get(:for))
  expiry  = __ruact_resolve_expiry(expires_in)

  unless record.respond_to?(:to_sgid)
    raise Ruact::Error,
          "Ruact.signed_global_id expects a GlobalID-locatable record (one responding to " \
          "#to_sgid, e.g. an ActiveRecord model); got #{record.class}."
  end

  record.to_sgid(for: purpose, expires_in: expiry).to_s
end