Module: ConcernsOnRails::Models::Anonymizable

Extended by:
ActiveSupport::Concern
Defined in:
lib/concerns_on_rails/models/anonymizable.rb

Overview

Declarative right-to-erasure ("GDPR-lite") for personal data. The fourth member of the sensitive-data suite: Maskable masks display, Sanitizable strips HTML, Encryptable protects at rest — Anonymizable DESTROYS.

class User < ApplicationRecord
include ConcernsOnRails::Models::Anonymizable

anonymizable :email, with: :email                 # unique fake address
anonymizable :first_name, :last_name, with: :redact
anonymizable :ssn, with: :nullify
anonymizable :bio, with: ->(value) { value && "removed by user request" }
end

user.anonymize!        # one UPDATE: strategies + anonymized_at stamp
user.anonymized?       # => true
User.not_anonymized    # scope (and .anonymized)
User.where(...).anonymize_all!   # batch; returns the count

HOW IT WRITES — deliberately update_columns (single UPDATE, no validations, no callbacks): erasure must not be blocked by a presence/ format validation, and must not run callbacks that would copy the OLD values somewhere new (Auditable's capture hook is the canonical example). update_columns serializes each value through the model's attribute types — Encryptable's custom type included — so a field that is also encryptable stores a fresh ciphertext envelope of the anonymized value, never plaintext. The record is reloaded afterwards so in-memory readers see the anonymized values through the types.

Strategy presets (with:):

:nullify    — nil
:redact     — "[REDACTED]"
:hash       — SHA-256 hex of the value (deterministic pseudonymization:
            the same input digests the same, so datasets keyed on the
            value still join — NOT full anonymization)
:email      — "anon-<random-hex>@anonymized.invalid" (random + unique,
            so NOT NULL / unique-index email columns survive erasure;
            .invalid is an RFC 2606 reserved TLD — it can never send)
:random_hex — 32 random hex chars (unique tokens/usernames)
a callable  — ->(value) { ... } or ->(value, record) { ... }; nil-in
            nil-out is the preset convention, custom callables choose

Notes:

* The stamp column (default :anonymized_at, `stamp: false` to opt out)
is what makes `anonymized?`, the scopes, and anonymize_all!'s
idempotency work — add it (a datetime) unless you truly can't.
* Auditable interaction: if any anonymized field is also audited, the
trail already holds historical plaintext, so anonymize! clears the
audit column in the SAME update (opt out per-macro with
`clear_audit_trail: false`). The trail is one column — clearing is
all-or-nothing.
* Encryptable interaction: works transparently (see HOW IT WRITES).
* Erasure is terminal: unsaved changes on the instance are discarded by
the post-write reload.

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

LABEL =
"ConcernsOnRails::Models::Anonymizable".freeze
DEFAULT_STAMP =
:anonymized_at
UNSET =

Distinguishes "option not passed" from an explicit value, so repeat macro calls merge fields without silently resetting earlier options.

Object.new
PRESETS =
{
  nullify: ->(_value) {},
  redact: ->(value) { value.nil? ? nil : "[REDACTED]" },
  hash: ->(value) { value.nil? ? nil : Digest::SHA256.hexdigest(value.to_s) },
  email: ->(value) { value.nil? ? nil : "anon-#{SecureRandom.hex(10)}@anonymized.invalid" },
  random_hex: ->(value) { value.nil? ? nil : SecureRandom.hex(16) }
}.freeze

Instance Method Summary collapse

Instance Method Details

#after_anonymizeObject



157
# File 'lib/concerns_on_rails/models/anonymizable.rb', line 157

def after_anonymize; end

#anonymize!Object

Erase the configured fields in a single UPDATE (see the module docs for why validations and callbacks are deliberately skipped). Returns true.

Raises:

  • (ArgumentError)


161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/concerns_on_rails/models/anonymizable.rb', line 161

def anonymize!
  raise ArgumentError, "#{LABEL}: anonymize! cannot be called on a new record" if new_record?

  payload = anonymizable_payload
  transaction do
    before_anonymize
    update_columns(payload)
    after_anonymize
  end
  # update_columns leaves DB-serialized values (e.g. ciphertext) in the
  # in-memory attributes; reload so readers decode through the types.
  reload
  true
end

#anonymized?Boolean

True when the stamp column is set; always false with stamp: false (there is nothing to observe).

Returns:

  • (Boolean)


178
179
180
181
# File 'lib/concerns_on_rails/models/anonymizable.rb', line 178

def anonymized?
  stamp = self.class.anonymizable_stamp
  stamp ? self[stamp].present? : false
end

#before_anonymizeObject

Lifecycle hooks — override in the model. Run inside the anonymize! transaction, so a raising hook rolls the erasure back.



156
# File 'lib/concerns_on_rails/models/anonymizable.rb', line 156

def before_anonymize; end