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
-
.allowed_objects(role) ⇒ Object
Legacy fallback when only a role symbol is available (no account context).
-
.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.
- .employee_party_context?(referenced_objects:, sql:) ⇒ Boolean
- .manifest_restricted_columns(referenced_objects:) ⇒ Object
- .normalize_role(role) ⇒ Object
-
.object_allowed?(role: nil, object_name:, account: nil, allowed_objects: nil) ⇒ Boolean
Check if a specific object is allowed for the given context.
- .power_user?(role) ⇒ Boolean
- .sensitive_column_for_object?(object_name:, column_name:) ⇒ Boolean
- .sensitive_columns_for_query(referenced_objects:, sql:) ⇒ Object
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 allowed_objects_for_account(account) return nil unless account Assistant::DataDomainPolicy.allowed_objects_for(account: account) end |
.employee_party_context?(referenced_objects:, sql:) ⇒ 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.}") [] 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).
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 || allowed_objects_for_account(account) || self.allowed_objects(role) return true if allowed.nil? allowed.include?(object) end |
.power_user?(role) ⇒ 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
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 |