Module: Assistant::DataPolicy

Defined in:
app/services/assistant/data_policy.rb

Overview

Central policy for analytics SQL access and sensitive data handling.
Keeps role/object/column restrictions in one place so tools and brokers
enforce the same rules.

Object-level access is resolved by DataDomainPolicy (YAML-driven, role-aware).
Column-level redaction uses CommentManifest restricted flags + hardcoded PII rules.

Constant Summary collapse

EMPLOYEE_ROLE =
:employee
MANAGER_ROLE =
:manager
ADMIN_ROLE =
:admin
POWER_USER_ROLES =
[MANAGER_ROLE, ADMIN_ROLE].freeze
ALWAYS_BLOCKED_OBJECTS =

Objects that are blocked for all roles in AI tool usage.
These contain credentials/tokens/secrets and are out of scope for analysis.

NOTE on Versions DB (postgres_versions):
The versions table stores PaperTrail audit diffs in object_changes JSONB.
This includes sensitive data from versioned models (OAuth tokens in
Authentication rows, Stripe card objects in OrderTransaction rows, etc.).
Currently protected by admin-only access (see ChatToolBuilder::ROLE_DEFAULTS).
If ever opened to non-admins, add item_type allowlisting and JSONB key
redaction here. Communication/SMS body content is NOT sensitive — it is
legitimate business data for search and analysis.

NOTE on AI chat tables:
assistant_conversations / assistant_messages and their related tables store
private per-user chat content. Querying these would allow any user to read
other users' conversation history, which is a privacy violation. Blocked for
ALL roles including admins. noticed_* tables are blocked for the same reason
(they store personal notification payloads referencing conversation content).

%w[
  oauth_access_tokens
  oauth_access_grants
  oauth_applications
  api_authentications
  active_storage_blobs
  active_storage_attachments
  assistant_conversations
  assistant_messages
  assistant_tool_calls
  assistant_conversation_shares
  noticed_events
  noticed_notifications
].freeze
OBJECT_SENSITIVE_COLUMNS =

Object-scoped sensitivity rules based on real schema columns.
Redaction is context-aware: contact/address data is sensitive when it is
tied to employee parties, but can remain visible for customer analytics.

{
  'employee_records' => %w[
    payroll_identifier
    visa_type
    visa_start_date
    visa_end_date
    employment_start_date
    employment_end_date
  ],
  'parties' => %w[
    dob
  ],
  'contact_points' => %w[
    detail
    notes
    restricted_notes
    system_notes
    extension
    area_code
    country_code
  ],
  'addresses' => %w[
    street1
    street2
    street3
    zip
    address_text
    normalized_address
    lat
    lng
  ]
}.freeze

Class Method Summary collapse

Class Method Details

.allowed_objects(role) ⇒ Object

Legacy fallback when only a role symbol is available (no account context).



110
111
112
113
114
115
116
117
118
119
120
# File 'app/services/assistant/data_policy.rb', line 110

def allowed_objects(role)
  if power_user?(role)
    nil # nil means all objects except always-blocked ones
  else
    # Employees via the legacy role-based path may only query pre-defined
    # analytical views (object names starting with "view_"). These are safe
    # aggregations built for analytics access and do not expose raw
    # operational table data directly.
    Assistant::CommentManifest.object_names.select { |name| name.start_with?('view_') }
  end
end

.allowed_objects_for_account(account) ⇒ Object

Returns nil (unrestricted) or a Set of allowed object names,
resolved from the user's CanCanCan roles via DataDomainPolicy.



103
104
105
106
107
# File 'app/services/assistant/data_policy.rb', line 103

def ()
  return nil unless 

  Assistant::DataDomainPolicy.allowed_objects_for(account: )
end

.employee_party_context?(referenced_objects:, sql:) ⇒ Boolean

Returns:

  • (Boolean)


158
159
160
161
162
163
164
165
# File 'app/services/assistant/data_policy.rb', line 158

def employee_party_context?(referenced_objects:, sql:)
  objects = Array(referenced_objects).map(&:to_s).map(&:downcase)
  return true if objects.include?('employee_records')
  return false unless objects.include?('parties')

  text = sql.to_s
  text.match?(/\btype\s*=\s*['"]Employee['"]/i)
end

.manifest_restricted_columns(referenced_objects:) ⇒ Object



167
168
169
170
171
172
173
174
# File 'app/services/assistant/data_policy.rb', line 167

def manifest_restricted_columns(referenced_objects:)
  return [] unless defined?(Assistant::CommentManifest)

  Assistant::CommentManifest.restricted_columns_for_objects(object_names: referenced_objects)
rescue StandardError => e
  Rails.logger.warn("[Assistant::DataPolicy] Manifest restricted lookup failed: #{e.message}")
  []
end

.normalize_role(role) ⇒ Object



87
88
89
90
91
# File 'app/services/assistant/data_policy.rb', line 87

def normalize_role(role)
  role.to_sym
rescue StandardError
  EMPLOYEE_ROLE
end

.object_allowed?(role: nil, object_name:, account: nil, allowed_objects: nil) ⇒ Boolean

Check if a specific object is allowed for the given context.
Accepts either an account (preferred) or a role (legacy fallback).

Returns:

  • (Boolean)


124
125
126
127
128
129
130
131
132
# File 'app/services/assistant/data_policy.rb', line 124

def object_allowed?(role: nil, object_name:, account: nil, allowed_objects: nil)
  object = object_name.to_s.downcase
  return false if ALWAYS_BLOCKED_OBJECTS.include?(object)

  allowed = allowed_objects || () || self.allowed_objects(role)
  return true if allowed.nil?

  allowed.include?(object)
end

.power_user?(role) ⇒ Boolean

Returns:

  • (Boolean)


93
94
95
# File 'app/services/assistant/data_policy.rb', line 93

def power_user?(role)
  POWER_USER_ROLES.include?(normalize_role(role))
end

.sensitive_column_for_object?(object_name:, column_name:) ⇒ Boolean

Returns:

  • (Boolean)


134
135
136
137
138
# File 'app/services/assistant/data_policy.rb', line 134

def sensitive_column_for_object?(object_name:, column_name:)
  object = object_name.to_s.downcase
  column = column_name.to_s.downcase
  (OBJECT_SENSITIVE_COLUMNS[object] || []).include?(column)
end

.sensitive_columns_for_query(referenced_objects:, sql:) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'app/services/assistant/data_policy.rb', line 140

def sensitive_columns_for_query(referenced_objects:, sql:)
  objects = Array(referenced_objects).map(&:to_s).map(&:downcase)
  return [] if objects.empty?

  columns = manifest_restricted_columns(referenced_objects: objects)

  if objects.include?('employee_records')
    columns.concat(OBJECT_SENSITIVE_COLUMNS.fetch('employee_records'))
  end

  return columns.uniq unless employee_party_context?(referenced_objects: objects, sql: sql)

  columns.concat(OBJECT_SENSITIVE_COLUMNS.fetch('parties')) if objects.include?('parties')
  columns.concat(OBJECT_SENSITIVE_COLUMNS.fetch('contact_points')) if objects.include?('contact_points')
  columns.concat(OBJECT_SENSITIVE_COLUMNS.fetch('addresses')) if objects.include?('addresses')
  columns.uniq
end