RUBY ON RAILS · 15 MIN READ ·

Rails ActiveRecord Encryption: PII versleutelen at rest met deterministische en niet-deterministische modes

Rails ActiveRecord encryption gids: versleutel PII at rest met deterministische en niet-deterministische modes, key management, en veilig productiedata migreren.

Rails ActiveRecord Encryption: PII versleutelen at rest met deterministische en niet-deterministische modes

Een SaaS-founder appte me dinsdagochtend omdat zijn SOC 2-auditor een ticket had geopend met de titel “PII opgeslagen in plaintext” tegen drie kolommen in zijn users-tabel: e-mail, telefoon en geboortedatum. De auditor wilde encryptie at rest binnen de applicatie, niet alleen op disk-niveau, en de founder wilde weten hoe lang het zou kosten om dit op te lossen. Het eerlijke antwoord was twee middagen zorgvuldig werk als we Rails ActiveRecord encryption zouden gebruiken, en drie weken als we probeerden het opnieuw uit te vinden met een zelfgebouwde attr_encrypted-achtige oplossing. Dit is de walkthrough die ik uiteindelijk voor zijn team heb geschreven, opgeschoond tot een post: hoe je encryptie aanzet, wanneer je deterministische mode gebruikt, hoe je de app queryable houdt, en hoe je bestaande rijen migreert zonder onderhoudsvenster.

Na negentien jaar Rails heb ik veel teams zien overcompliceren op dit punt. De attr_encrypted gem was jarenlang de standaardkeuze, maar sinds Rails 7 wordt er in het framework zelf een solide, audited implementatie meegeleverd die de twee modes dekt die de meeste apps daadwerkelijk nodig hebben. Rails ActiveRecord encryption is nu het saaie antwoord, en het is bijna altijd het juiste.

Wat Rails ActiveRecord Encryption eigenlijk is

Rails ActiveRecord encryption, geïntroduceerd in Rails 7.0 en verstevigd in 7.1 en 8, is een encryptielaag per kolom die zich tussen je model en de database bevindt. Je declareert encrypts :email in een model, en Rails versleutelt transparant bij schrijven en ontsleutelt bij lezen. De ciphertext leeft in dezelfde kolom — meestal een text — en de plaintext raakt de database nooit. Disksnapshots, replicatiestreams, base backups, pg_dump-output en rogue read replicas bevatten allemaal alleen de ciphertext.

De cipher is AES-256-GCM. De sleutels worden afgeleid van drie secrets die je één keer configureert — een primary key, een deterministic key en een key derivation salt — en opgeslagen in Rails’ encrypted credentials. Elke versleutelde waarde wordt opgeslagen als een kleine binary blob met daarin de ciphertext, een nonce, een authentication tag en een header die bijhoudt welke sleutelversie en welk schema zijn gebruikt. Die header is de reden dat sleutelrotatie in dit systeem praktisch haalbaar is, en waarom ik de ingebouwde encryptie van Rails aanraad boven zelf iets bouwen.

Er zijn twee modes en het verschil is belangrijk. Non-deterministic encryptie gebruikt een willekeurige nonce per write, dus "alice@example.com" twee keer versleutelen levert twee verschillende ciphertexten op. Dit is de veiligere default — het lekt niets over herhalingen — maar het betekent dat je niet op die kolom kunt querien, want de database heeft geen manier om je versleutelde WHERE-clause te matchen tegen de opgeslagen ciphertext. Deterministic encryptie gebruikt een nonce die is afgeleid van de plaintext, dus dezelfde input levert altijd dezelfde output. Nu kun je where(email: "alice@example.com") doen en een index gebruiken, maar je hebt equality gelekt — iedereen met database-toegang kan zien welke rijen dezelfde waarde delen.

Kies per kolom, niet per app. Email is in elke app die ik uitrol deterministisch omdat je gebruikers erop moet kunnen opzoeken. Telefoon is meestal deterministisch om dezelfde reden. Geboortedatum is niet-deterministisch — je doet nooit WHERE dob = ?, en het is een sterke reidentificatievector. Burgerservicenummers, paspoortnummers en bankrekeningnummers zijn niet-deterministisch, tenzij je een dwingende reden hebt.

Rails ActiveRecord Encryption sleutels opzetten

De eerste stap is de drie secrets genereren en in encrypted credentials zetten. Rails levert een generator voor precies dit:

bin/rails db:encryption:init

Dit print een YAML-blok dat je in config/credentials.yml.enc plakt via bin/rails credentials:edit:

active_record_encryption:
  primary_key: 6qmkiUJ4H8y1XoZ2QpFvKq7RtM3nBeWs
  deterministic_key: iEr8g0d5wJvT2yLpAxNqRvKu7HmBnCoP
  key_derivation_salt: n3qXvJ8w2LpMkTyRfAeSbGhUcQdWzY5V

Twee regels. Ten eerste: genereer een andere set per omgeving. Productie, staging en CI hebben elk hun eigen primary key, en je kopieert ze nooit tussen omgevingen — het punt van encryptie is dat alleen de omgeving die de sleutel bezit de data kan lezen. Ten tweede: back up productie-credentials ergens buiten de repo. Als je de primary key kwijtraakt, wordt elke versleutelde kolom onleesbare troep. AWS Secrets Manager, 1Password of een geprint vel in een afgesloten la werken allemaal; een Slack-DM aan jezelf niet.

Als je deployt met Kamal of standaard Rails credentials, leeft de master key in config/master.key (of RAILS_MASTER_KEY env var). Als je deployt op een platform dat je al een KMS-integratie geeft — AWS KMS, Google Cloud KMS — kun je de Rails primary key wrappen in de KMS-master en unwrappen bij boot. Voor de meeste Rails-shops zijn de ingebouwde encrypted credentials genoeg, en een KMS-unwrap-stap toevoegen is meer operationele oppervlakte dan het dreigingsmodel rechtvaardigt.

Versleutelde kolommen declareren

Zodra de sleutels op hun plek staan, is de modelkant een one-liner per kolom:

class User < ApplicationRecord
  encrypts :email, deterministic: true, downcase: true
  encrypts :phone, deterministic: true
  encrypts :date_of_birth
  encrypts :bank_account_number
end

downcase: true normaliseert de plaintext vóór encryptie. Dit is essentieel voor deterministische kolommen waar je wilt dat Alice@Example.com en alice@example.com matchen — zonder deze optie versleutelen ze naar verschillende ciphertexten en liegt je uniqueness-validatie. ignore_case: true is een verwante optie die de originele case behoudt voor weergave maar case-insensitief matcht; gebruik deze als de UI de invoer van de gebruiker letterlijk moet tonen.

De onderliggende kolom blijft een text of binary. Als je encryptie op een nieuwe kolom toevoegt, definieer die dan als t.text :email en laat Rails de payload-framing afhandelen. Converteer je een bestaande varchar(255)-kolom, verbreed die dan eerst omdat de ciphertext groter is dan de plaintext:

class WidenEncryptedColumns < ActiveRecord::Migration[8.0]
  def change
    change_column :users, :email, :text
    change_column :users, :phone, :text
    change_column :users, :date_of_birth, :text
  end
end

Draai dit achter strong migrations zodat je niet per ongeluk de hele tabel herschrijft onder een lock tijdens piekuren. Op grote tabellen wil je change_column_type_concurrently of het equivalente multi-step column-swap-patroon.

Deterministisch versus niet-deterministisch in de praktijk

Dit is het mentale model dat ik gebruik bij het kiezen van een mode. Als de kolom aan de WHERE-kant van een query staat, moet hij deterministisch zijn. Als hij in een unieke index staat, moet hij deterministisch zijn. Als hij voorkomt in een HAS_MANY :through-join, moet hij deterministisch zijn. Anders: default naar niet-deterministisch en wees veiliger.

Deterministische mode heeft één vervelend randgeval. Omdat dezelfde input altijd dezelfde output produceert, kan een aanvaller met leestoegang tot de database een rainbow table bouwen voor veelvoorkomende waarden — e-mailadressen die in breach-corpora voorkomen, de top honderd telefoonnummerprefixen, de tien meest gebruikte wachtwoorden. Als je dreigingsmodel “iemand krijgt een kopie van het databasebestand” bevat, lekken deterministisch versleutelde kolommen equality maar geen plaintext. Dat is veel beter dan plaintext, maar niet perfect. Voor extreem gevoelige kolommen die je niet hoeft te querien — BSN, paspoortnummer, medische data — kies niet-deterministisch en accepteer de query-hit.

Je kunt nog steeds zoeken op een niet-deterministische kolom, alleen niet met SQL. Laad een begrensde set kandidaten via een ander predikaat, ontsleutel in Ruby, en filter:

User.where(country: "NL")
    .find_each
    .select { |u| u.date_of_birth == Date.new(1985, 3, 12) }

Dit is prima bij honderd rijen en verschrikkelijk bij honderdduizend. Ontwerp je indexen rond de deterministische kolommen die je wél queryt, en behandel niet-deterministische kolommen meestal als write-only.

Bestaande productiedata migreren

Het lastigste onderdeel van het aanzetten van Rails ActiveRecord encryption in een bestaande app is dat je al plaintext rijen hebt zitten in de kolom die je gaat versleutelen. Rails levert een support_unencrypted_data-mode voor precies deze overgang:

# config/application.rb
config.active_record.encryption.support_unencrypted_data = true

Met deze aan werken reads of de rij nu versleuteld is of niet, en writes versleutelen altijd. Je deployt deze flag, draait een background-migratie die elke rij aanraakt, en zet de flag daarna uit. De migratie is een gewone Ruby-loop:

class EncryptExistingUsers < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!

  def up
    User.find_each(batch_size: 1000) do |user|
      user.encrypt
    end
  end
end

#encrypt is een door Rails geleverde instance-methode die de plaintext opnieuw leest, de ciphertext opnieuw schrijft, en niets doet als de rij al versleuteld is. Voor tabellen met miljoenen rijen zet ik dit in een Solid Queue-job en verdeel ik het werk over shards op primary key range — een pluck(:id).each_slice(10_000) bovenaan en één enqueued job per slice, zodat je de migratie kunt herstarten zonder de al klaar zijnde slices opnieuw te doen.

Zodra elke rij versleuteld is, verwijder je support_unencrypted_data en deploy je opnieuw. Je hebt nu een harde invariant: elke read moet ontsleutelen, elke write moet versleutelen, en elke rij die op de een of andere manier als plaintext binnenkomt raist ActiveRecord::Encryption::Errors::Decryption bij read. Dat laatste is waar je alerting op moet monitoren — één plaintext rij die de migratie overleeft is wat mij betreft een PagerDuty.

Als je users-tabel ook audit-geschiedenis heeft die door PaperTrail wordt bijgehouden — zie mijn post over audit logging met PaperTrail — vergeet dan niet dat de versions-tabel geserialiseerde attributen opslaat. Die moet je ook versleutelen, anders wordt je compliance-winst tenietgedaan door een audit-trail die alsnog plaintext lekt.

Sleutelrotatie

Elke versleutelde waarde draagt een header die vastlegt welke sleutelversie hem heeft versleuteld, dus sleutels roteren is een kwestie van een nieuwe sleutel toevoegen zonder de oude te verwijderen. Rails leest de versie bij read time en ontsleutelt met de matching sleutel; nieuwe writes gebruiken altijd de huidige primary. In config/credentials.yml.enc:

active_record_encryption:
  primary_key:
    - <new key here>
    - <old key here>
  deterministic_key:
    - <new key here>
    - <old key here>
  key_derivation_salt: n3qXvJ8w2LpMkTyRfAeSbGhUcQdWzY5V

Het eerste item is de huidige primary; de rest is alleen voor decrypt. Deploy dit, en draai vervolgens ofwel een background re-encryptie om elke rij naar de nieuwe sleutel te migreren en de oude entry te verwijderen, ofwel laat beide sleutels staan tot de bewaartermijn van de oude data verstreken is en je die in één keer kunt droppen.

Je roteert om twee redenen: een sleutel is gecompromitteerd (zelden) of je beleid schrijft jaarlijkse rotatie voor (vaak). Bij compromittering: onmiddellijk roteren en dezelfde dag de re-encryptie-job starten. Bij geplande rotatie: elk jaar een nieuwe sleutel toevoegen en sleutels laten vallen die de laatste twee jaar geen rij hebben versleuteld. pg_stat_statements en een snelle query op de ciphertext-header vertellen je de verdeling.

Veelvoorkomende valkuilen

Fixture-data. Fixtures leven als plaintext YAML, en de testsuite probeert ze direct te inserten. Rails handelt dit af als je het vertelt: zet config.active_record.encryption.encrypt_fixtures = true in config/environments/test.rb, en fixtures versleutelen bij laden.

Full-text search. Je kunt niet ILIKE-en op een deterministisch versleutelde kolom, en al helemaal niet op een niet-deterministische. Als e-mail-zoeken een productfeature is — “vind gebruikers op gedeeltelijke e-mailmatch” voor een support-tool — dan indexeer je ofwel een aparte hash-kolom, houd je een niet-versleutelde email_local_part-kolom aan (met juridische goedkeuring), of verplaats je de zoekfunctie naar een dedicated index zoals OpenSearch met eigen access controls.

Serialisatiegrenzen. Overal waar je een model serialiseert naar een log-regel, een error-report of een event-payload — lekt die ontsleutelde waarde. Sentry PII-scrubbing helpt hier; zie mijn Sentry PII scrubbing post voor de config. Maar logs zijn de grootste overtreder: Rails.logger.info user.inspect omzeilt al je zorgvuldige encryptie, dus zet config.filter_parameters zo dat elke versleutelde attribuutnaam wordt geredigeerd.

Third-party integraties. Stripe, Postmark, en elke CRM waarnaar je synct hebben de plaintext-waarden nodig. Dat is prima — de encryptie is at rest, niet in flight — maar het betekent dat je Sidekiq-jobs ontsleutelen op de weg naar buiten. Audit de outbound-kant van elke integratie en bevestig dat de transportlaag (TLS) en de ontvangende kant (hun bewaartermijn) matchen met je risicobereidheid.

Wanneer je Rails ActiveRecord Encryption kunt overslaan

Er zijn drie gevallen waarin ik founders ervan heb afgepraat. Als de hele database op een volledig managed platform staat dat customer-managed-key encryptie at rest levert en het enige dreigingsmodel is “AWS wordt gedagvaard”, dan is disk-level encryptie genoeg — application-level encryptie voegt operationele oppervlakte toe zonder marginale security-winst. Als de kolom een berekend derivaat is van een andere kolom die niet versleuteld is (een zoekindex opgebouwd uit gebruikersnamen), verplaats je met versleutelen van het derivaat alleen het probleem. En als de hele rij naar een dedicated vault zoals HashiCorp Vault Transit of AWS KMS Envelope verhuist voor een compliance-framework dat die specifieke vendor voorschrijft, gebruik dan de SDK van die vendor en sla de ingebouwde encryptie voor die kolom over.

Voor de andere negentig procent van apps — waar het ticket van de auditor zegt “versleutel PII op applicatielaag” en je vrijdag klaar wilt zijn — is Rails ActiveRecord encryption het antwoord.

FAQ

Kun je een Rails versleutelde kolom querien?

Alleen als je hem hebt gedeclareerd met deterministic: true. Niet-deterministische encryptie produceert een andere ciphertext per write, dus de database kan een WHERE-clause niet matchen. Deterministische encryptie produceert een stabiele ciphertext, wat querien en indexeren mogelijk maakt — ten koste van het lekken van equality naar iedereen met database-toegang.

Wat gebeurt er als je de Rails encryption primary key kwijtraakt?

Elke versleutelde kolom wordt onherstelbaar. Rails gebruikt AES-256-GCM, een echte cipher zonder achterdeur; er is geen reset-knop. Back up productiesleutels in een secrets manager, niet in de repo, en roteer ze via de multi-key array in credentials.yml.enc zodat je altijd een decrypt-only geschiedenis hebt.

Hoe migreer je een bestaande tabel naar Rails ActiveRecord Encryption?

Zet config.active_record.encryption.support_unencrypted_data = true, deploy, en draai daarna een background-job die elke rij itereert en record.encrypt aanroept. Zodra elke rij versleuteld is, zet je de flag uit en deploy je opnieuw. Op tabellen boven een miljoen rijen: splits het werk in Solid Queue-jobs per primary key range zodat je kunt pauzeren en hervatten.

Nee. Full-text indexen lezen de onderliggende kolomwaarden, en versleutelde kolommen zien eruit als willekeurige binary. Als je zoekfunctionaliteit op een PII-kolom nodig hebt, houd de doorzoekbare representatie dan in een aparte kolom met eigen access controls, of verplaats de zoekfunctie naar een dedicated engine met kolom-niveau autorisatie. Probeer niet full-text te zoeken op versleutelde ciphertext.


Hulp nodig bij het aanzetten van Rails ActiveRecord encryption zonder onderhoudsvenster, of wil je een review van hoe je app end-to-end omgaat met PII? TTB Software levert sinds 2007 productie-Rails en werkt met founders en engineeringteams als fractional CTO. Na negentien jaar zijn de saaie antwoorden meestal nog steeds de juiste.

#rails-activerecord-encryption #rails-encryption-at-rest #rails-pii-encryption #active-record-encrypts #rails-deterministic-encryption #rails-gdpr-compliance

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software