{"id":3878,"date":"2026-07-26T03:47:51","date_gmt":"2026-07-26T03:47:51","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/26\/ai-analytics-row-level-security-let-users-ask-questions-without-leaking-data\/"},"modified":"2026-07-26T03:47:51","modified_gmt":"2026-07-26T03:47:51","slug":"ai-analytics-row-level-security-let-users-ask-questions-without-leaking-data","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/26\/ai-analytics-row-level-security-let-users-ask-questions-without-leaking-data\/","title":{"rendered":"AI Analytics Row-Level Security: Let Users Ask Questions Without Leaking Data"},"content":{"rendered":"<div>\n<div><\/div>\n<p>The database or BI layer knows who is asking. The app can apply tenant filters, role checks, and column restrictions.<\/p>\n<p>AI analytics often breaks that chain: <\/p>\n<div>\n<pre><code>Human user \u2192 app session \u2192 AI service \u2192 service account \u2192 database query <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Now the warehouse sees one identity: the AI service account.<\/p>\n<p>That account usually needs broad read access so it can answer many kinds of questions. If you do nothing else, a basic natural language question can become a cross-tenant data leak.<\/p>\n<p>The model is not necessarily malicious. It may simply do what models do: search for the data that seems relevant. If the path is open, it will use it.<\/p>\n<p>A safe design treats the AI analyst as an untrusted planner, not as the enforcement layer.<\/p>\n<h2> <a name=\"what-rowlevel-security-means-for-ai-analytics\" href=\"#what-rowlevel-security-means-for-ai-analytics\"> <\/a> What row-level security means for AI analytics <\/h2>\n<p>Row-level security means users only see the records they are allowed to see, even when they ask broad questions.<\/p>\n<p>For example:<\/p>\n<ul>\n<li>A customer admin sees only their company\u2019s accounts.<\/li>\n<li>A regional manager sees only accounts in their region.<\/li>\n<li>A support agent sees limited account health but not billing details.<\/li>\n<li>A trial user sees sample data, not real customer revenue.<\/li>\n<li>A contractor sees assigned projects, not the full workspace.<\/li>\n<\/ul>\n<p>With AI analytics, this must hold across every path:<\/p>\n<ul>\n<li>generated SQL<\/li>\n<li>semantic metric calls<\/li>\n<li>chart queries<\/li>\n<li>cached answers<\/li>\n<li>exported CSVs<\/li>\n<li>follow-up questions<\/li>\n<li>tool retries<\/li>\n<li>background agent jobs<\/li>\n<\/ul>\n<p>If the user asks, \u201cShow all churn risks,\u201d the system should not trust the model to remember <code>where tenant_id = ...<\/code>. The platform should enforce that scope below or beside the model.<\/p>\n<h2> <a name=\"a-safer-architecture-for-customerfacing-ai-analytics\" href=\"#a-safer-architecture-for-customerfacing-ai-analytics\"> <\/a> A safer architecture for customer-facing AI analytics <\/h2>\n<p>Use a layered design: <\/p>\n<div>\n<pre><code>User question \u2193 Auth context \u2193 AI planner \u2193 Analytics gateway \u2193 Policy engine \u2193 Semantic layer \u2193 Query compiler \u2193 Database \/ warehouse \u2193 Scoped result + evidence \u2193 Answer or chart <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The important point: the AI planner proposes intent. It does not get raw database freedom.<\/p>\n<p>The analytics gateway should own five jobs:<\/p>\n<ol>\n<li>Verify the user and tenant.<\/li>\n<li>Decide which datasets and metrics are visible.<\/li>\n<li>Compile safe queries from approved semantic definitions.<\/li>\n<li>Apply row-level and column-level filters.<\/li>\n<li>log the question, query, result shape, and policy decision.<\/li>\n<\/ol>\n<p>This creates a boundary the model cannot prompt its way around.<\/p>\n<h2> <a name=\"do-not-let-the-model-write-final-sql-unchecked\" href=\"#do-not-let-the-model-write-final-sql-unchecked\"> <\/a> Do not let the model write final SQL unchecked <\/h2>\n<p>Natural language to SQL is tempting. It demos well. It also fails in ways that are easy to miss.<\/p>\n<p>A model may:<\/p>\n<ul>\n<li>forget the tenant filter<\/li>\n<li>join on the wrong key<\/li>\n<li>select sensitive columns<\/li>\n<li>infer hidden tables from schema names<\/li>\n<li>use expensive queries<\/li>\n<li>change metric definitions<\/li>\n<li>include deleted or test records<\/li>\n<li>bypass a business rule that the UI always applies<\/li>\n<\/ul>\n<p>A better pattern is <strong>intent to semantic query<\/strong>.<\/p>\n<p>The model extracts the user\u2019s intent, then your system maps it to approved metrics and dimensions. <\/p>\n<div>\n<pre><code><span>{<\/span><span> <\/span><span>\"metric\"<\/span><span>:<\/span><span> <\/span><span>\"active_accounts\"<\/span><span>,<\/span><span> <\/span><span>\"dimensions\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"plan\"<\/span><span>,<\/span><span> <\/span><span>\"region\"<\/span><span>],<\/span><span> <\/span><span>\"filters\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"field\"<\/span><span>:<\/span><span> <\/span><span>\"period\"<\/span><span>,<\/span><span> <\/span><span>\"operator\"<\/span><span>:<\/span><span> <\/span><span>\"last_30_days\"<\/span><span> <\/span><span>}<\/span><span> <\/span><span>],<\/span><span> <\/span><span>\"visualization\"<\/span><span>:<\/span><span> <\/span><span>\"bar_chart\"<\/span><span> <\/span><span>}<\/span><span> <\/span><\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Then your query compiler creates SQL using server-side policy. <\/p>\n<div>\n<pre><code><span>select<\/span> <span>plan<\/span><span>,<\/span> <span>region<\/span><span>,<\/span> <span>count<\/span><span>(<\/span><span>*<\/span><span>)<\/span> <span>as<\/span> <span>active_accounts<\/span> <span>from<\/span> <span>account_metrics<\/span> <span>where<\/span> <span>tenant_id<\/span> <span>=<\/span> <span>:<\/span><span>tenant_id<\/span> <span>and<\/span> <span>deleted_at<\/span> <span>is<\/span> <span>null<\/span> <span>and<\/span> <span>activity_date<\/span> <span>&gt;=<\/span> <span>current_date<\/span> <span>-<\/span> <span>interval<\/span> <span>'30 days'<\/span> <span>and<\/span> <span>region<\/span> <span>=<\/span> <span>any<\/span><span>(:<\/span><span>allowed_regions<\/span><span>)<\/span> <span>group<\/span> <span>by<\/span> <span>plan<\/span><span>,<\/span> <span>region<\/span> <span>order<\/span> <span>by<\/span> <span>active_accounts<\/span> <span>desc<\/span><span>;<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The model can ask for \u201cactive accounts by region.\u201d It cannot remove <code>tenant_id<\/code>, invent a metric, or select payroll data.<\/p>\n<h2> <a name=\"start-with-an-access-context-object\" href=\"#start-with-an-access-context-object\"> <\/a> Start with an access context object <\/h2>\n<p>Every AI analytics request should begin with a server-created access context. Do not let the browser or the model provide this object. <\/p>\n<div>\n<pre><code><span>type<\/span> <span>AnalyticsAccessContext<\/span> <span>=<\/span> <span>{<\/span> <span>userId<\/span><span>:<\/span> <span>string<\/span><span>;<\/span> <span>tenantId<\/span><span>:<\/span> <span>string<\/span><span>;<\/span> <span>role<\/span><span>:<\/span> <span>\"<\/span><span>owner<\/span><span>\"<\/span> <span>|<\/span> <span>\"<\/span><span>admin<\/span><span>\"<\/span> <span>|<\/span> <span>\"<\/span><span>analyst<\/span><span>\"<\/span> <span>|<\/span> <span>\"<\/span><span>support<\/span><span>\"<\/span> <span>|<\/span> <span>\"<\/span><span>viewer<\/span><span>\"<\/span><span>;<\/span> <span>allowedDatasets<\/span><span>:<\/span> <span>string<\/span><span>[];<\/span> <span>allowedMetrics<\/span><span>:<\/span> <span>string<\/span><span>[];<\/span> <span>rowFilters<\/span><span>:<\/span> <span>{<\/span> <span>tenant_id<\/span><span>:<\/span> <span>string<\/span><span>;<\/span> <span>regions<\/span><span>?:<\/span> <span>string<\/span><span>[]<\/span> <span>};<\/span> <span>deniedColumns<\/span><span>:<\/span> <span>string<\/span><span>[];<\/span> <span>requestId<\/span><span>:<\/span> <span>string<\/span><span>;<\/span> <span>};<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>This object becomes the policy input for every later step.<\/p>\n<p>The user may ask a broad question. The model may suggest a query. But the access context decides what data exists for this session.<\/p>\n<h2> <a name=\"build-a-small-metric-catalog\" href=\"#build-a-small-metric-catalog\"> <\/a> Build a small metric catalog <\/h2>\n<p>A metric catalog keeps the model from redefining your business.<\/p>\n<p>Without a catalog, \u201crevenue\u201d may mean booked revenue in one answer, paid invoices in another, and forecasted expansion in a third. That destroys trust.<\/p>\n<p>A simple metric definition can start like this: <\/p>\n<div>\n<pre><code><span>const<\/span> <span>metrics<\/span> <span>=<\/span> <span>{<\/span> <span>churn_risk_accounts<\/span><span>:<\/span> <span>{<\/span> <span>id<\/span><span>:<\/span> <span>\"<\/span><span>churn_risk_accounts<\/span><span>\"<\/span><span>,<\/span> <span>label<\/span><span>:<\/span> <span>\"<\/span><span>Churn-risk accounts<\/span><span>\"<\/span><span>,<\/span> <span>sqlExpression<\/span><span>:<\/span> <span>\"<\/span><span>count(distinct account_id)<\/span><span>\"<\/span><span>,<\/span> <span>allowedRoles<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>owner<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>admin<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>analyst<\/span><span>\"<\/span><span>],<\/span> <span>defaultFilters<\/span><span>:<\/span> <span>{<\/span> <span>is_test_account<\/span><span>:<\/span> <span>false<\/span><span>,<\/span> <span>deleted<\/span><span>:<\/span> <span>false<\/span> <span>},<\/span> <span>safeDimensions<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>plan<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>region<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>owner_team<\/span><span>\"<\/span><span>],<\/span> <span>freshnessTargetMinutes<\/span><span>:<\/span> <span>60<\/span> <span>}<\/span> <span>};<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The model can explain this metric to the user, but it should not create the definition on the fly.<\/p>\n<h2> <a name=\"enforce-rowlevel-security-in-more-than-one-place\" href=\"#enforce-rowlevel-security-in-more-than-one-place\"> <\/a> Enforce row-level security in more than one place <\/h2>\n<p>There are three common enforcement patterns.<\/p>\n<h3> <a name=\"1-databasenative-rls\" href=\"#1-databasenative-rls\"> <\/a> 1. Database-native RLS <\/h3>\n<p>This uses the database\u2019s row-level security features. The query runs with a user or tenant context, and the database enforces the rule.<\/p>\n<p>Example in Postgres: <\/p>\n<div>\n<pre><code><span>alter<\/span> <span>table<\/span> <span>account_metrics<\/span> <span>enable<\/span> <span>row<\/span> <span>level<\/span> <span>security<\/span><span>;<\/span> <span>create<\/span> <span>policy<\/span> <span>tenant_isolation_policy<\/span> <span>on<\/span> <span>account_metrics<\/span> <span>using<\/span> <span>(<\/span><span>tenant_id<\/span> <span>=<\/span> <span>current_setting<\/span><span>(<\/span><span>'app.tenant_id'<\/span><span>)::<\/span><span>uuid<\/span><span>);<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Then set the tenant before the query: <\/p>\n<div>\n<pre><code><span>select<\/span> <span>set_config<\/span><span>(<\/span><span>'app.tenant_id'<\/span><span>,<\/span> <span>:<\/span><span>tenant_id<\/span><span>,<\/span> <span>true<\/span><span>);<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>This is strong because the rule lives near the data. It is harder for app code to forget.<\/p>\n<h3> <a name=\"2-analytics-gateway-filtering\" href=\"#2-analytics-gateway-filtering\"> <\/a> 2. Analytics gateway filtering <\/h3>\n<p>The gateway injects tenant and role filters into every compiled query.<\/p>\n<p>This is practical when you query warehouses or multiple data stores where native RLS is uneven.<\/p>\n<p>The key rule: filters must be added by trusted code, not by the model.<\/p>\n<h3> <a name=\"3-scoped-semantic-datasets\" href=\"#3-scoped-semantic-datasets\"> <\/a> 3. Scoped semantic datasets <\/h3>\n<p>The user only sees a subset of datasets and dimensions. If a support user cannot access revenue, the model never receives revenue tables, revenue metrics, or revenue examples in its prompt.<\/p>\n<p>This reduces both security risk and model confusion.<\/p>\n<p>Most small teams should combine patterns 2 and 3 first. Add database-native RLS where your stack supports it cleanly.<\/p>\n<h2> <a name=\"protect-columns-not-just-rows\" href=\"#protect-columns-not-just-rows\"> <\/a> Protect columns, not just rows <\/h2>\n<p>Row-level security answers, \u201cWhich records can this user see?\u201d<\/p>\n<p>AI analytics also needs column-level controls for emails, invoice notes, API keys, compensation, internal scores, support transcripts, and other sensitive fields. The model does not need most of this to answer common analytics questions.<\/p>\n<p>Create a column policy: <\/p>\n<div>\n<pre><code><span>const<\/span> <span>columnPolicy<\/span> <span>=<\/span> <span>{<\/span> <span>viewer<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>account_name<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>plan<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>usage_count<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>created_month<\/span><span>\"<\/span><span>],<\/span> <span>analyst<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>account_name<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>plan<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>usage_count<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>mrr_band<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>region<\/span><span>\"<\/span><span>],<\/span> <span>support<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>account_name<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>plan<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>health_status<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>open_ticket_count<\/span><span>\"<\/span><span>],<\/span> <span>owner<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>*<\/span><span>\"<\/span><span>]<\/span> <span>};<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Prefer bands and aggregates over raw sensitive values. \u201cMRR band: $1k-$5k\u201d is often enough for AI analytics. You do not need to expose exact invoices for every question.<\/p>\n<h2> <a name=\"add-query-budgets-before-users-discover-expensive-questions\" href=\"#add-query-budgets-before-users-discover-expensive-questions\"> <\/a> Add query budgets before users discover expensive questions <\/h2>\n<p>Natural language makes expensive analytics easier to trigger.<\/p>\n<p>A user can ask:<\/p>\n<blockquote>\n<p>Compare every customer cohort by feature usage, ticket sentiment, renewal risk, onboarding source, and plan since launch.<\/p>\n<\/blockquote>\n<p>That may sound reasonable. It may also scan half your warehouse.<\/p>\n<p>Add budgets for rows returned, query runtime, joins, date range, follow-up depth, chart series, returned context, and repeated-question caching.<\/p>\n<p>Your query guard should reject plans with too many rows, too many joins, blocked columns, or a date range the user\u2019s plan does not allow.<\/p>\n<p>Return a helpful refusal:<\/p>\n<blockquote>\n<p>I can answer that if we narrow the date range or choose fewer breakdowns. Try \u201clast 90 days by plan and region.\u201d<\/p>\n<\/blockquote>\n<p>This protects cost and improves UX.<\/p>\n<h2> <a name=\"make-followup-questions-inherit-scope\" href=\"#make-followup-questions-inherit-scope\"> <\/a> Make follow-up questions inherit scope <\/h2>\n<p>Follow-up questions are a hidden leak path.<\/p>\n<p>User: \u201cShow churn risk for my region.\u201d<\/p>\n<p>AI: \u201cHere are the Midwest accounts at risk.\u201d<\/p>\n<p>User: \u201cNow compare that with everyone else.\u201d<\/p>\n<p>The phrase \u201ceveryone else\u201d is dangerous. It could mean every region in the tenant, every tenant, or all customers in the warehouse.<\/p>\n<p>Follow-ups must inherit the original access context and policy, not raw result rows unless needed.<\/p>\n<p>Store conversation state like this: <\/p>\n<div>\n<pre><code><span>{<\/span><span> <\/span><span>\"conversation_id\"<\/span><span>:<\/span><span> <\/span><span>\"conv_123\"<\/span><span>,<\/span><span> <\/span><span>\"tenant_id\"<\/span><span>:<\/span><span> <\/span><span>\"tenant_456\"<\/span><span>,<\/span><span> <\/span><span>\"user_id\"<\/span><span>:<\/span><span> <\/span><span>\"user_789\"<\/span><span>,<\/span><span> <\/span><span>\"active_scope\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"regions\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"midwest\"<\/span><span>],<\/span><span> <\/span><span>\"datasets\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"account_metrics\"<\/span><span>],<\/span><span> <\/span><span>\"metrics\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"churn_risk_accounts\"<\/span><span>]<\/span><span> <\/span><span>},<\/span><span> <\/span><span>\"last_query_id\"<\/span><span>:<\/span><span> <\/span><span>\"qry_abc\"<\/span><span> <\/span><span>}<\/span><span> <\/span><\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>When the user asks a follow-up, the gateway resolves ambiguous words against policy, not against the model\u2019s imagination.<\/p>\n<h2> <a name=\"log-evidence-for-every-answer\" href=\"#log-evidence-for-every-answer\"> <\/a> Log evidence for every answer <\/h2>\n<p>AI analytics needs an answer receipt.<\/p>\n<p>At minimum, log the user, tenant, original question, normalized intent, selected metrics, policy decision, query hash, row count, returned columns, freshness, model, and refusal reason if any.<\/p>\n<p>Example: <\/p>\n<div>\n<pre><code><span>{<\/span><span> <\/span><span>\"request_id\"<\/span><span>:<\/span><span> <\/span><span>\"req_01\"<\/span><span>,<\/span><span> <\/span><span>\"tenant_id\"<\/span><span>:<\/span><span> <\/span><span>\"tenant_456\"<\/span><span>,<\/span><span> <\/span><span>\"user_id\"<\/span><span>:<\/span><span> <\/span><span>\"user_789\"<\/span><span>,<\/span><span> <\/span><span>\"question\"<\/span><span>:<\/span><span> <\/span><span>\"Which accounts are slipping this month?\"<\/span><span>,<\/span><span> <\/span><span>\"intent\"<\/span><span>:<\/span><span> <\/span><span>\"churn_risk_accounts_by_owner\"<\/span><span>,<\/span><span> <\/span><span>\"policy\"<\/span><span>:<\/span><span> <\/span><span>\"allow\"<\/span><span>,<\/span><span> <\/span><span>\"filters_applied\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"tenant_id\"<\/span><span>,<\/span><span> <\/span><span>\"allowed_regions\"<\/span><span>,<\/span><span> <\/span><span>\"not_deleted\"<\/span><span>],<\/span><span> <\/span><span>\"columns_returned\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"account_name\"<\/span><span>,<\/span><span> <\/span><span>\"owner_team\"<\/span><span>,<\/span><span> <\/span><span>\"risk_band\"<\/span><span>],<\/span><span> <\/span><span>\"row_count\"<\/span><span>:<\/span><span> <\/span><span>42<\/span><span>,<\/span><span> <\/span><span>\"freshness_minutes\"<\/span><span>:<\/span><span> <\/span><span>18<\/span><span>,<\/span><span> <\/span><span>\"query_hash\"<\/span><span>:<\/span><span> <\/span><span>\"sha256:...\"<\/span><span> <\/span><span>}<\/span><span> <\/span><\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h2> <a name=\"common-implementation-mistakes\" href=\"#common-implementation-mistakes\"> <\/a> Common implementation mistakes <\/h2>\n<h3> <a name=\"mistake-1-putting-the-tenant-filter-in-the-prompt\" href=\"#mistake-1-putting-the-tenant-filter-in-the-prompt\"> <\/a> Mistake 1: Putting the tenant filter in the prompt <\/h3>\n<p>Bad: \u201cAlways remember to filter by tenant_id.\u201d<\/p>\n<p>Good: the query compiler always injects tenant scope from server-side auth context.<\/p>\n<p>Prompts are guidance. Policy is enforcement.<\/p>\n<h3> <a name=\"mistake-2-giving-schema-access-to-everyone\" href=\"#mistake-2-giving-schema-access-to-everyone\"> <\/a> Mistake 2: Giving schema access to everyone <\/h3>\n<p>Do not show the model every table name and column. Hidden tables can leak meaning even if rows are blocked. A table name can be sensitive by itself.<\/p>\n<h3> <a name=\"mistake-3-returning-raw-rows-when-aggregates-would-work\" href=\"#mistake-3-returning-raw-rows-when-aggregates-would-work\"> <\/a> Mistake 3: Returning raw rows when aggregates would work <\/h3>\n<p>If the user asks for a trend, return a trend. Do not send 10,000 rows to the model so it can summarize them.<\/p>\n<h3> <a name=\"mistake-4-caching-answers-without-scope-keys\" href=\"#mistake-4-caching-answers-without-scope-keys\"> <\/a> Mistake 4: Caching answers without scope keys <\/h3>\n<p>Cache keys must include tenant, role, metric version, and policy version. <\/p>\n<div>\n<pre><code>tenant_456:role_analyst:metric_v4:policy_v12:churn-risk-last-30-days <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h3> <a name=\"mistake-5-treating-charts-as-safe-by-default\" href=\"#mistake-5-treating-charts-as-safe-by-default\"> <\/a> Mistake 5: Treating charts as safe by default <\/h3>\n<p>Charts can leak. A chart with one bar can reveal a single customer\u2019s revenue. Add minimum group sizes and suppression rules.<\/p>\n<h2> <a name=\"a-simple-rollout-plan\" href=\"#a-simple-rollout-plan\"> <\/a> A simple rollout plan <\/h2>\n<p>You do not need a huge platform on day one.<\/p>\n<p>Start here:<\/p>\n<ol>\n<li>Pick three safe metrics customers already understand.<\/li>\n<li>Define allowed dimensions for each metric.<\/li>\n<li>Create an access context from server-side auth.<\/li>\n<li>Build a small analytics gateway.<\/li>\n<li>Compile semantic queries instead of trusting raw SQL.<\/li>\n<li>Inject tenant and role filters in code.<\/li>\n<li>Block denied columns.<\/li>\n<li>Limit rows, joins, and date ranges.<\/li>\n<li>Log an answer receipt.<\/li>\n<li>Test cross-tenant and role-based prompts before launch.<\/li>\n<\/ol>\n<p>For example, ship usage trends, active accounts, and support ticket volume first.<\/p>\n<p>Avoid high-risk areas like billing disputes, payroll, medical records, security logs, raw messages, and unrestricted SQL exports until your boundaries are proven.<\/p>\n<h2> <a name=\"test-cases-you-should-run\" href=\"#test-cases-you-should-run\"> <\/a> Test cases you should run <\/h2>\n<p>Add these to CI or a staging eval suite:<\/p>\n<div>\n<table>\n<thead>\n<tr>\n<th>Test<\/th>\n<th>Expected result<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>User asks for another tenant\u2019s revenue<\/td>\n<td>Refuse or return only scoped data<\/td>\n<\/tr>\n<tr>\n<td>Viewer asks for exact MRR<\/td>\n<td>Return allowed aggregate or deny<\/td>\n<\/tr>\n<tr>\n<td>User asks for hidden table names<\/td>\n<td>Refuse without confirming table existence<\/td>\n<\/tr>\n<tr>\n<td>Follow-up says \u201cshow everyone\u201d<\/td>\n<td>Keep tenant and role scope<\/td>\n<\/tr>\n<tr>\n<td>Query requests too many rows<\/td>\n<td>Ask user to narrow the question<\/td>\n<\/tr>\n<tr>\n<td>Chart group has one account<\/td>\n<td>Suppress or bucket the value<\/td>\n<\/tr>\n<tr>\n<td>Model suggests raw SQL<\/td>\n<td>Compile through semantic layer only<\/td>\n<\/tr>\n<tr>\n<td>Cached answer exists for admin<\/td>\n<td>Do not show it to viewer<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>The goal is not to prove the model is obedient. It is to prove the boundary holds when the model is creative.<\/p>\n<h2> <a name=\"final-checklist\" href=\"#final-checklist\"> <\/a> Final checklist <\/h2>\n<p>Before giving customers an AI analyst, make sure you can answer yes to these:<\/p>\n<ul>\n<li>Does every request start with server-side identity?<\/li>\n<li>Are tenant filters enforced outside the prompt?<\/li>\n<li>Can the model only use approved metrics and dimensions?<\/li>\n<li>Are sensitive columns blocked by role?<\/li>\n<li>Are queries budgeted by rows, time, joins, and date range?<\/li>\n<li>Do follow-up questions inherit the same scope?<\/li>\n<li>Are chart outputs protected against small-group leaks?<\/li>\n<li>Are answer receipts logged?<\/li>\n<li>Can you replay why an answer was generated?<\/li>\n<li>Do tests prove one tenant cannot reach another tenant\u2019s data?<\/li>\n<\/ul>\n<p>If not, your AI analytics feature may be a data leak with a chat box.<\/p>\n<h2> <a name=\"faq\" href=\"#faq\"> <\/a> FAQ <\/h2>\n<h3> <a name=\"what-is-ai-analytics-rowlevel-security\" href=\"#what-is-ai-analytics-rowlevel-security\"> <\/a> What is AI analytics row-level security? <\/h3>\n<p>AI analytics row-level security is the practice of enforcing user, tenant, and role-based row filters when an AI system answers questions about data. The model may interpret the question, but trusted application or database code decides which records the user can access.<\/p>\n<h3> <a name=\"is-prompting-enough-to-protect-tenant-data\" href=\"#is-prompting-enough-to-protect-tenant-data\"> <\/a> Is prompting enough to protect tenant data? <\/h3>\n<p>No. A prompt can remind the model to use tenant filters, but it is not a security boundary. Tenant scope should be enforced by the database, analytics gateway, semantic layer, or query compiler.<\/p>\n<h3> <a name=\"should-an-ai-analyst-generate-sql-directly\" href=\"#should-an-ai-analyst-generate-sql-directly\"> <\/a> Should an AI analyst generate SQL directly? <\/h3>\n<p>It can generate draft SQL for internal review, but customer-facing systems should avoid executing unchecked SQL from a model. A safer pattern is to convert user intent into approved metrics, dimensions, and filters, then compile SQL with server-side policy.<\/p>\n<h3> <a name=\"how-do-i-handle-natural-language-followup-questions-safely\" href=\"#how-do-i-handle-natural-language-followup-questions-safely\"> <\/a> How do I handle natural language follow-up questions safely? <\/h3>\n<p>Store conversation scope separately from model text. Follow-ups should inherit the same tenant, role, metric, and dataset limits. Ambiguous phrases like \u201ceveryone else\u201d should be resolved by policy, not by model guesswork.<\/p>\n<h3> <a name=\"what-should-i-log-for-ai-analytics-audits\" href=\"#what-should-i-log-for-ai-analytics-audits\"> <\/a> What should I log for AI analytics audits? <\/h3>\n<p>Log the user, tenant, original question, normalized intent, selected metrics, applied filters, returned columns, row count, freshness, query hash, model version, and policy decision. This creates evidence for debugging and customer trust.<\/p>\n<h3> <a name=\"how-can-small-teams-start-without-building-a-full-bi-platform\" href=\"#how-can-small-teams-start-without-building-a-full-bi-platform\"> <\/a> How can small teams start without building a full BI platform? <\/h3>\n<p>Start with a few approved metrics, a small semantic catalog, strict tenant filters, column blocks, query limits, and answer receipts. Avoid raw SQL execution and sensitive datasets until your policy and tests are reliable.<\/p>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/jackm-singularity\/ai-analytics-row-level-security-let-users-ask-questions-without-leaking-data-2b08\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>The database or BI layer knows who is asking. The app can apply tenant filters, role checks, and column restrictions. AI analytics often breaks that chain: Human user \u2192 app session \u2192 AI service \u2192 service account \u2192 database query Now the warehouse sees one identity: the AI service account. That account usually needs broad [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":3877,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-3878","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3878","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=3878"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3878\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/3877"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=3878"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=3878"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=3878"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}