Why RBAC Alone Isn't Enough for Enterprise Data Agents
DEV Community

Why RBAC Alone Isn't Enough for Enterprise Data Agents

A user can be blocked from a sensitive column and still receive sensitive information derived from data they are allowed to access. That changes the authorization problem for enterprise data agents. Traditional access control asks: Can this user read this database object? An AI analytics system also needs to ask: Is this user allowed to receive what the system can infer from those objects? Consider a simple example. A user cannot access: employee.salary. But the same user can access: department.total_cost, department.employee_count. A capable data agent can derive: estimated_average_salary = department.total_cost / department.employee_count. No forbidden salary column was queried. The database permission model may have worked perfectly. The answer may still disclose information the policy intended to protect. This is why: Table access โ‰  Answer access.

RBAC Still Matters

This is not an argument against role-based access control. RBAC remains a critical foundation. A typical model might define:

Role: Sales Manager

  • ALLOW: customer, sales_order, product, regional_revenue
  • DENY: employee.salary, payroll, compensation_detail

At the database layer, those controls should continue to be enforced. The problem is that an AI agent introduces several stages above the database:

  1. Natural Language
  2. Intent Resolution
  3. Semantic Resolution
  4. Context Retrieval
  5. Relationship Planning
  6. SQL Generation
  7. Execution
  8. Answer Generation

Authorization therefore has more surfaces than a traditional application issuing predefined SQL. Natural Language โ†“ I

The Inference Gap

Let's formalize the salary example. Suppose policy says:

{
  "resource": "employee.salary",
  "action": "read",
  "effect": "deny"
}

But:

{
  "resource": "department.total_cost",
  "action": "read",
  "effect": "allow"
}

and:

{
  "resource": "department.employee_count",
  "action": "read",
  "effect": "allow"
}

The agent creates: f(total_cost, employee_count) โ†’ estimated_average_salary. Every input is authorized. The derived concept may not be. Call this the inference gap:

Authorized Inputs โ†“ Reasoning / Aggregation โ†“ Restricted Information

Traditional object-level authorization may not express that boundary.

Add Semantic Authorization

Users ask questions in business concepts. So policy should increasingly understand business concepts too. Instead of governing only: employee.salary, define a semantic concept:

{
  "concept": "employee_compensation",
  "direct_access": "deny",
  "derived_access": "deny"
}

Now a request such as: What is the average salary of the engineering team? can be resolved first: Intent โ†’ Employee Compensation. Then evaluated: Employee Compensation โ†’ DENY before SQL generation begins. This is semantic authorization. It lets policy operate at the same abstraction level as the user's question.

Authorization Should Start Before SQL Generation

A common architecture is:

Question โ†“ Retrieve Schema โ†“ Generate SQL โ†“ Database Permission Check โ†“ Execute

The problem is that the model may already have received context it should not use. A stronger pipeline is:

Question โ†“ Identity โ†“ Intent Resolution โ†“ Semantic Policy โ†“ Authorized Context โ†“ Authorized Relationship Graph โ†“ Query Planning โ†“ SQL Generation โ†“ Database Enforcement โ†“ Answer Policy

The important change is: Authorization constrains reasoning context before it constrains execution.

Build an Authorized Context Resolver

Imagine the enterprise semantic layer contains: Revenue, Gross Margin, Customer Risk, Payroll Cost, Employee Compensation, Product Profitability. A generic context retriever might return all concepts semantically related to the question. That is risky. Instead:

Candidate Context โ†“ Identity + Policy โ†“ Authorized Context โ†“ LLM

Pseudocode:

def resolve_authorized_context(question, user):
    intent = resolve_intent(question)
    candidates = retrieve_semantic_context(intent)
    allowed = [item for item in candidates if policy.can_use(user, item)]
    return allowed

The real implementation will need stronger policy semantics, but the architectural boundary matters. Do not give the model unauthorized context and hope the final SQL check fixes everything.

Relationships Need Authorization Too

Suppose relationship discovery finds: Employee โ†“ Department โ†“ Cost Center โ†“ Financial Cost. The path is structurally valid. But a Sales user may not be allowed to traverse it. So distinguish: Trusted Relationship from: Authorized Relationship. A relationship object could carry policy metadata:

{
  "source": "department",
  "target": "cost_center",
  "status": "trusted",
  "policy": {
    "allowed_roles": ["finance", "hr"]
  }
}

Then query planning uses a user-specific graph:

def authorized_graph(graph, user):
    return graph.filter(lambda edge: policy.can_traverse(user, edge))

This gives us another useful rule: Valid relationship โ‰  Authorized relationship.

Query Planning Should Operate on the Authorized Graph

Assume the full relationship graph contains:

  • Customer โ”€ Order โ”€ Payment
  • Employee โ”€ Department โ”€ Cost Center
  • Supplier โ”€ Contract โ”€ Pricing

For a Sales user: Customer โ”€ Order โ”€ Payment may be available. But: Employee โ”€ Department โ”€ Cost Center may be removed from the planning graph. The SQL generator never sees that path. That is safer than generating the query first and rejecting it later.

Direct Access and Derived Access Are Different Policies

Some concepts need two policy dimensions. Example:

{
  "concept": "customer_credit_risk",
  "direct_access": {
    "roles": ["risk", "finance"]
  },
  "derived_access": {
    "roles": ["risk", "finance"]
  }
}

Why distinguish them? Because an organization might allow: Department Cost but restrict: Individual Compensation. Or permit individual operational metrics while restricting a derived risk score. The derived concept may have different sensitivity from its inputs.

Answer-Level Policy Is the Final Boundary

Even with pre-query authorization, a final result check is useful. The pipeline may produce:

  • SQL Valid โœ“
  • Database Access โœ“
  • Relationship Valid โœ“
  • Execution โœ“
  • Answer Policy โœ•

The system should not return the result. Conceptually:

result = execute(sql)
answer_concepts = classify_result_semantics(question=question, plan=query_plan, result=result)
for concept in answer_concepts:
    if not policy.can_receive(user, concept):
        raise PolicyDenied
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.