Module: OpenLoam::Export

Defined in:
lib/open_loam/export.rb

Overview

CSV export of a tenant-scoped relation, POLICY- and ENCRYPTION-aware:

OpenLoam::Export.csv(Equipment.all, actor: current_actor)
  • only fields the actor's role may READ are columns (OpenLoam::Policy#readable?);
  • an ENCRYPTED column is NEVER exported in the clear — its cell is "[encrypted]" (the same redaction as the audit trail), so a bulk export can never become a plaintext dump of PII a role shouldn't see;
  • declared custom fields go through the same read check (OpenLoam::Policy#custom_field_readable?) — a dictionary field exports its stored code, so the file round-trips back through OpenLoam::Import;
  • tenant isolation is free — the relation is already scoped.

Prototype scale: builds the CSV in memory with the stdlib CSV. A very large export would stream row-by-row through an enumerator body — the same column logic, a different sink.

Constant Summary collapse

REDACTED =
"[encrypted]".freeze
SKIP_COLUMNS =

Never exported: tenant plumbing and optimistic-locking bookkeeping.

%w[tenant_id lock_version deleted_at].freeze

Class Method Summary collapse

Class Method Details

.cell(record, column) ⇒ Object



61
62
63
64
65
66
67
68
# File 'lib/open_loam/export.rb', line 61

def cell(record, column)
  value = case column[:kind]
          when :encrypted then REDACTED
          when :custom    then (record.custom_field(column[:name]) rescue nil)
          else record.public_send(column[:name])
          end
  OpenLoam::Csv.safe(value) # neutralize CSV formula injection (=, +, -, @, tab/CR)
end

.csv(scope, actor:) ⇒ Object



27
28
29
30
31
32
33
34
35
# File 'lib/open_loam/export.rb', line 27

def csv(scope, actor:)
  model = scope.klass
  columns = exportable_columns(model, actor)

  CSV.generate do |out|
    out << columns.map { |c| c[:header] }
    scope.find_each { |record| out << columns.map { |c| cell(record, c) } }
  end
end

.exportable_columns(model, actor) ⇒ Object

The ordered column spec: readable real columns (encrypted ones kept but redacted), then declared custom fields.



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/open_loam/export.rb', line 39

def exportable_columns(model, actor)
  policy = policy_for(model, actor)
  encrypted = model.respond_to?(:open_loam_encrypted_attributes) ? model.open_loam_encrypted_attributes : []
  # The blind-index columns behind searchable encrypted fields (e.g.
  # email_hash) are internal HMACs — never export them either.
  blind = model.respond_to?(:open_loam_searchable_encrypted_attributes) ? model.open_loam_searchable_encrypted_attributes.map { |a| "#{a}_hash" } : []

  columns = model.column_names.reject { |c| SKIP_COLUMNS.include?(c) || blind.include?(c) || c == "custom_fields" }
                 .select { |c| policy.readable?(c) }
                 .map { |c| { header: c, name: c, kind: encrypted.include?(c) ? :encrypted : :column } }

  if model.respond_to?(:custom_field_definitions)
    model.custom_field_definitions.order(:name).each do |definition|
      next unless policy.custom_field_readable?(definition.name)

      columns << { header: definition.name, name: definition.name, kind: :custom }
    end
  end

  columns
end

.policy_for(model, actor) ⇒ Object



70
71
72
# File 'lib/open_loam/export.rb', line 70

def policy_for(model, actor)
  OpenLoam::Policy.for_model(model, actor)
end