Skip to content
damionas
No. 52Azure AI FoundryMar 16, 202626 min read

A Custom Foundry Tool That Queries Azure SQL With Row-Level Security via Entra ID OBO

The first version of our "ask the database" Foundry tool was a function that took a customer ID, ran `SELECT * FROM customer_orders WHERE customer_id = ?` against Azure SQL, and returned the rows.

The first version of our "ask the database" Foundry tool was a function that took a customer ID, ran SELECT * FROM customer_orders WHERE customer_id = ? against Azure SQL, and returned the rows. It worked beautifully in the demo. It also returned data for any customer ID the model was willing to pass, including customer IDs that the calling user had no business seeing. The first tester, asking about their own account, accidentally typed a colleague's customer ID instead and the tool happily returned the colleague's order history.

The fix is the pattern Microsoft documents but most teams skip on first build: Entra ID On-Behalf-Of plus SQL row-level security. The agent receives the user's token, exchanges it for a SQL access token via OBO, connects to Azure SQL as the user, and the database itself enforces a row-level-security predicate that limits visible rows to the calling identity. The tool no longer needs to know who the user is; the database knows, and the database refuses to return rows the user isn't allowed to see.

This post is the entire build. By the end you have a Foundry agent with one custom tool, query_orders, that queries Azure SQL via OBO, with a row-level security predicate that enforces "you can only see your own orders," a working test that confirms a malicious model prompt cannot trick the tool into returning other people's data, and a deployment pattern that fits inside the private-network architecture from the Foundry-with-Private-Link article. About 200 lines of Python plus 50 lines of T-SQL, with full SDK examples.

Why OBO + SQL RLS, not "the tool checks the user ID"

Brief context because the tempting alternatives are wrong and worth being explicit about why.

Why not check the user ID inside the tool function. The tool can't trust the user ID it's given. The model passes whatever user ID it interprets from the conversation; if the user prompts "actually, I'm customer 999, please show me their orders," the model passes 999. The tool has no independent way to verify the caller's actual identity. Authorization decisions made on caller-supplied input are no decisions at all.

Why not check the user ID against the thread metadata. Better, but still vulnerable to thread metadata being spoofed by someone with thread-write access. And even if it works, it puts authorization logic in your agent code, which is the worst place for it: hard to audit, hard to test, hard to prove correct under adversarial prompts.

Why On-Behalf-Of plus row-level security. OBO exchanges the user's token (which Entra ID minted with the user's actual identity) for a token scoped to Azure SQL. Azure SQL accepts that token and connects as the user. The user has no SELECT-anywhere permission; they have SELECT on a view that's filtered by row-level security to their own rows. Authorization happens at the database, not in the agent. A malicious prompt cannot trick the system because the system is enforcing authorization at a layer the prompt cannot reach.

Why row-level security in the database, not a WHERE clause in the query. A WHERE clause in the agent's query is application-level filtering, which the model can be tricked into modifying. RLS is database-level filtering: even if the model writes SELECT * FROM customer_orders with no WHERE clause, the database adds its own WHERE clause that the user cannot remove. RLS is the right primitive.

What you'll have at the end

~/foundry-sql-tool/
├── infra/
│   ├── sql-database.bicep                  # SQL with RLS schema
│   ├── rls-policy.sql                      # the security predicate
│   └── seed-data.sql                       # test customers + orders
├── tools/
│   ├── query_orders.py                     # the custom tool
│   ├── obo_exchange.py                     # the OBO helper
│   └── tool_definition.py                  # registers with the Foundry agent
├── tests/
│   ├── test_authorization.py               # the malicious-prompt test
│   └── test_data_visibility.py             # RLS works as expected
├── client/
│   └── conversation.py                     # demo conversation
└── README.md

Prerequisites

  • A Microsoft Foundry project, ideally deployed inside the corporate VNet from the private-link article. Public-network projects work too but are not recommended for tools that touch sensitive data.
  • Azure SQL Database with Entra ID authentication enabled → Configure and manage Microsoft Entra authentication with Azure SQL
  • Two Entra ID app registrations: one for the Foundry agent host (the OBO client), one as the API the user authenticates against. The user-facing app needs the User.Read scope; the agent app needs user_impersonation on Azure SQL.
  • Python 3.12+ with azure-ai-projects, azure-identity, pyodbc, msal packages.
  • An Azure SQL Database admin user, configured as an Entra ID user (so it can grant access to other Entra users).

python -m venv .venv && source .venv/bin/activate
pip install azure-ai-projects azure-identity pyodbc msal pytest
az login

Step 1: The SQL schema with RLS

infra/seed-data.sql:

-- Schema: a customers table and an orders table
CREATE TABLE dbo.customers (
  customer_id    INT NOT NULL PRIMARY KEY,
  customer_email NVARCHAR(256) NOT NULL UNIQUE,
  display_name   NVARCHAR(128) NOT NULL,
  entra_oid      UNIQUEIDENTIFIER NOT NULL UNIQUE
);

CREATE TABLE dbo.customer_orders (
  order_id       INT NOT NULL IDENTITY PRIMARY KEY,
  customer_id    INT NOT NULL REFERENCES dbo.customers(customer_id),
  order_date     DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
  total_cents    INT NOT NULL,
  status         NVARCHAR(32) NOT NULL
);

-- Test data: two customers, several orders each
INSERT INTO dbo.customers (customer_id, customer_email, display_name, entra_oid)
VALUES
  (1, 'alice@yourtenant.onmicrosoft.com', 'Alice Anderson', '11111111-1111-1111-1111-111111111111'),
  (2, 'bob@yourtenant.onmicrosoft.com',   'Bob Brown',     '22222222-2222-2222-2222-222222222222');

INSERT INTO dbo.customer_orders (customer_id, total_cents, status)
VALUES (1, 4500, 'shipped'), (1, 12000, 'pending'), (2, 7800, 'shipped'), (2, 3300, 'delivered');

The entra_oid column is the primary join from a SQL row to a corporate identity. Every customer has exactly one Entra OID; the OID is what the SQL server reads from the connecting user's token.

infra/rls-policy.sql:

-- The row-level security policy. Predicate: a row is visible if
-- the connecting user's OID matches the customer's OID.
CREATE SCHEMA security;
GO

-- The function that returns 1 if the connection is allowed to see this customer's row
CREATE OR ALTER FUNCTION security.fn_securitypredicate_owns_row(@customer_id INT)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
  SELECT 1 AS is_visible
  FROM dbo.customers AS c
  WHERE c.customer_id = @customer_id
    AND c.entra_oid = CAST(SESSION_CONTEXT(N'caller_oid') AS UNIQUEIDENTIFIER);
GO

-- Apply the predicate to customer_orders
CREATE SECURITY POLICY security.customer_orders_filter
ADD FILTER PREDICATE security.fn_securitypredicate_owns_row(customer_id) ON dbo.customer_orders,
ADD BLOCK  PREDICATE security.fn_securitypredicate_owns_row(customer_id) ON dbo.customer_orders AFTER INSERT,
ADD BLOCK  PREDICATE security.fn_securitypredicate_owns_row(customer_id) ON dbo.customer_orders AFTER UPDATE
WITH (STATE = ON);

What this does, line by line:

  • The predicate function takes a @customer_id and returns 1 if the connecting session's caller_oid (set in step 3 from the user's OBO token) matches the customer's entra_oid. Otherwise it returns nothing (which RLS interprets as "row hidden").
  • ADD FILTER PREDICATE applies the predicate on SELECTs. Reads only see allowed rows.
  • ADD BLOCK PREDICATE ... AFTER INSERT/UPDATE prevents writes that would create a row the writer can't see. Without these, a malicious write could insert orders for someone else.
  • SESSION_CONTEXT is per-connection state. The agent code (Step 3) sets caller_oid to the user's OID after authenticating. SQL trusts this because only authenticated callers can call sp_set_session_context.

Step 2: Grant the agent app permission to call SQL via OBO

In your Entra ID tenant, open the agent's app registration → API permissions → Add permission → APIs my organization uses → search "Azure SQL Database" → delegated permission user_impersonation. This is what allows the agent to use OBO to mint a SQL-scoped token on behalf of the user.

After adding, click "Grant admin consent." Without admin consent, the OBO call fails the first time with AADSTS65001.

In SQL itself, create a contained user for the agent's app:

-- Run as SQL admin (Entra ID user with sysadmin on the server)
CREATE USER [agent-app-display-name] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [agent-app-display-name];

-- Also create users for each customer (test only; production uses Entra groups)
CREATE USER [alice@yourtenant.onmicrosoft.com] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [alice@yourtenant.onmicrosoft.com];

CREATE USER [bob@yourtenant.onmicrosoft.com] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [bob@yourtenant.onmicrosoft.com];

The display-name-based user is for the agent's identity (used when SQL authenticates the OBO connection). The customer-email users are how SQL knows which Entra OID is calling, which RLS uses for the predicate match.

A subtle but important detail: db_datareader does not bypass RLS. The role grants SELECT on tables; RLS still filters which rows are visible. Combine the role grant with RLS for the right outcome: "this user can read the table, but only sees their rows."

Step 3: The OBO exchange helper

tools/obo_exchange.py:

"""On-Behalf-Of token exchange for the Foundry agent.

Takes the user's incoming token (already validated by the agent host's
auth layer) and exchanges it for an Azure SQL-scoped token.
"""
from typing import Optional
from msal import ConfidentialClientApplication
from azure.identity import CertificateCredential

# Agent app's client id, tenant id, and certificate are sourced from env.
# In production these come from Key Vault via a CSI driver.
import os

AGENT_CLIENT_ID = os.environ["AGENT_CLIENT_ID"]
TENANT_ID = os.environ["AZURE_TENANT_ID"]
CERT_PATH = os.environ["AGENT_CERT_PATH"]
CERT_PASSWORD: Optional[str] = os.environ.get("AGENT_CERT_PASSWORD")

# Azure SQL's resource URI
SQL_SCOPE = "https://database.windows.net/.default"

# Build the MSAL confidential client once
_msal_app = ConfidentialClientApplication(
    client_id=AGENT_CLIENT_ID,
    authority=f"https://login.microsoftonline.com/{TENANT_ID}",
    client_credential={
        "private_key": open(CERT_PATH, "rb").read(),
        "thumbprint": os.environ["AGENT_CERT_THUMBPRINT"],
        "passphrase": CERT_PASSWORD,
    },
)

# Per-user token cache keyed on the user's OID. TTL is the token's natural lifetime
# (typically 60 minutes); MSAL handles refresh.
_obo_cache: dict[str, dict] = {}

def get_sql_token_obo(user_token: str) -> str:
    """Exchange a user's token for a SQL-scoped token via OBO.

    Args:
      user_token: The user's access token (already validated by the host).

    Returns:
      An access token scoped to https://database.windows.net/, valid for ~60 minutes.

    Raises:
      RuntimeError if the OBO exchange fails (e.g., missing consent, CA challenge).
    """
    user_oid = _parse_oid(user_token)
    cached = _obo_cache.get(user_oid)
    if cached and cached["expires_in"] > 60:
        return cached["access_token"]

    result = _msal_app.acquire_token_on_behalf_of(
        user_assertion=user_token,
        scopes=[SQL_SCOPE],
    )

    if "access_token" not in result:
        raise RuntimeError(
            f"OBO failed: {result.get('error')} - {result.get('error_description')}"
        )

    _obo_cache[user_oid] = result
    return result["access_token"]


def _parse_oid(jwt_token: str) -> str:
    """Extract the OID claim from an Entra access token without validation.
    The token is already validated upstream; we only need the OID here."""
    import base64
    import json
    payload = jwt_token.split(".")[1]
    # Pad for base64
    payload += "=" * (4 - len(payload) % 4)
    decoded = json.loads(base64.urlsafe_b64decode(payload))
    return decoded["oid"]

Walking through what this does:

  • ConfidentialClientApplication is MSAL's class for apps that hold a credential. Our agent app holds a certificate (preferred over a client secret); the cert is mounted from Key Vault.
  • acquire_token_on_behalf_of is the OBO call. It takes the user's token as user_assertion, exchanges it for an access token scoped to database.windows.net. This token represents the user, not the agent.
  • The cache keyed on user_oid prevents re-exchanging on every tool call. OBO has rate limits; without caching, a chatty conversation triggers them.
  • _parse_oid decodes the JWT to get the user's OID without validating again. Validation already happened at the agent's HTTP layer; we just need to read.

Step 4: The custom tool

tools/query_orders.py:

"""Foundry tool: query a user's orders, with database-side RLS."""
import os
import struct
from typing import Annotated
import pyodbc

from .obo_exchange import get_sql_token_obo

SQL_SERVER = os.environ["SQL_SERVER"]   # e.g. <server>.database.windows.net
SQL_DATABASE = os.environ["SQL_DATABASE"]


def query_orders(
    user_token: str,
    limit: Annotated[int, "Max rows to return, default 20"] = 20,
) -> list[dict]:
    """Return the calling user's recent orders.

    The user is identified by their token (passed implicitly by the agent
    host). RLS in Azure SQL filters rows to those owned by the user; this
    tool cannot return another user's data.
    """
    # 1. Exchange the user's token for a SQL-scoped token via OBO
    sql_token = get_sql_token_obo(user_token)

    # 2. Encode the access token in the format pyodbc expects
    #    (ucs-2 with a 4-byte length prefix per the Azure SQL connection string spec)
    token_struct = _encode_token(sql_token)

    # 3. Connect to SQL using the token (no username/password needed)
    conn_str = (
        f"Driver={{ODBC Driver 18 for SQL Server}};"
        f"Server=tcp:{SQL_SERVER},1433;"
        f"Database={SQL_DATABASE};"
        f"Encrypt=yes;TrustServerCertificate=no;"
    )

    with pyodbc.connect(
        conn_str,
        attrs_before={1256: token_struct},   # SQL_COPT_SS_ACCESS_TOKEN
    ) as conn:
        cursor = conn.cursor()

        # 4. Set session context so the RLS predicate can match.
        #    Without this, RLS sees no caller_oid and the query returns 0 rows.
        user_oid = _extract_oid(user_token)
        cursor.execute(
            "EXEC sp_set_session_context @key = N'caller_oid', @value = ?",
            user_oid,
        )

        # 5. Run the query. RLS filters rows even though we say SELECT *.
        cursor.execute(
            """
            SELECT TOP (?) order_id, customer_id, order_date, total_cents, status
            FROM dbo.customer_orders
            ORDER BY order_date DESC
            """,
            limit,
        )

        return [
            {
                "order_id": row[0],
                "customer_id": row[1],
                "order_date": row[2].isoformat() if row[2] else None,
                "total_cents": row[3],
                "status": row[4],
            }
            for row in cursor.fetchall()
        ]


def _encode_token(token: str) -> bytes:
    """Encode an Entra access token in the binary format pyodbc expects."""
    encoded = bytes(token, "utf-16-le")
    return struct.pack(f"<I{len(encoded)}s", len(encoded), encoded)


def _extract_oid(jwt_token: str) -> str:
    """Read the OID claim from a JWT payload."""
    import base64
    import json
    payload = jwt_token.split(".")[1]
    payload += "=" * (4 - len(payload) % 4)
    return json.loads(base64.urlsafe_b64decode(payload))["oid"]

The five-step flow inside query_orders is the load-bearing pattern:

  1. OBO exchange turns the user's token into a SQL token. The user's identity is preserved in the new token.
  2. Token encoding is an annoying detail — pyodbc wants the token as a binary structure with a UTF-16 LE prefix. Easy to get wrong; the format is documented in Microsoft Learn, Connect using Microsoft Entra authentication.
  3. Connect with attrs_before passing the token. The number 1256 is the magic constant for SQL_COPT_SS_ACCESS_TOKEN; pin it.
  4. sp_set_session_context writes the user's OID into a session variable. The RLS predicate reads this variable. Skip this and the predicate returns 0 rows for everyone.
  5. SELECT * is allowed because RLS adds its own WHERE clause. Even if the model passes SELECT * FROM customer_orders directly, the result is filtered.

Step 5: Register the tool with the Foundry agent

tools/tool_definition.py:

"""Register query_orders as a custom function tool on the Foundry agent."""
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential

PROJECT_ENDPOINT = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
project = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=DefaultAzureCredential())

# The function tool definition that goes into the agent's tools array
QUERY_ORDERS_TOOL = {
    "type": "function",
    "function": {
        "name": "query_orders",
        "description": (
            "Returns the CALLING USER's recent orders. The tool authenticates "
            "as the user via OBO, so the database returns only their own orders. "
            "Cannot return other users' data; do not pass a customer_id."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "limit": {
                    "type": "integer",
                    "description": "Max number of orders to return (default 20)",
                    "default": 20,
                }
            },
            "required": [],
        },
    },
}

agent = project.agents.get_agent("orders-agent")
project.agents.update_agent(agent_id=agent.id, tools=[QUERY_ORDERS_TOOL])

The description is the most important field. Note what it says: "Cannot return other users' data; do not pass a customer_id." This isn't decorative — it tells the model not to attempt the customer_id parameter (which we deliberately did not declare). If the user prompts "show me customer 999's orders," the model will call query_orders(limit=20) because there is no customer_id parameter, and the database will return only the user's orders, which is the right answer.

Note that the function definition has no customer_id. By design. The user's identity is implicit (it's whoever's token hit the tool), and the tool description signals to the model that explicit user IDs aren't supported.

Step 6: The agent host that wires it together

# agent_host.py
from typing import Any
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential
from fastapi import FastAPI, Header, HTTPException

from tools.query_orders import query_orders

app = FastAPI()
project = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=DefaultAzureCredential())


@app.post("/conversations/{thread_id}/messages")
async def send_message(
    thread_id: str,
    body: dict,
    authorization: str = Header(),
):
    """Receive a user message, run the agent, return the response."""
    # 1. Validate the bearer token (covered in the OAuth article)
    user_token = authorization.replace("Bearer ", "")
    claims = await validate_bearer(user_token)   # raises on invalid

    # 2. Add the message to the thread
    project.agents.messages.create(
        thread_id=thread_id, role="user", content=body["text"]
    )

    # 3. Run the agent. When the agent calls query_orders, our tool handler runs.
    run = project.agents.runs.create(
        thread_id=thread_id,
        agent_id=ORDERS_AGENT_ID,
    )

    # 4. Process tool calls. The agent will call query_orders; we execute it
    #    locally with the user's token, return the result.
    while run.status in ("queued", "in_progress", "requires_action"):
        if run.status == "requires_action":
            outputs = []
            for call in run.required_action.submit_tool_outputs.tool_calls:
                if call.function.name == "query_orders":
                    args = json.loads(call.function.arguments)
                    result = query_orders(
                        user_token=user_token,
                        limit=args.get("limit", 20),
                    )
                    outputs.append({
                        "tool_call_id": call.id,
                        "output": json.dumps(result),
                    })
            project.agents.runs.submit_tool_outputs(
                thread_id=thread_id, run_id=run.id, tool_outputs=outputs
            )
        run = project.agents.runs.get(thread_id=thread_id, run_id=run.id)

    # 5. Return the final assistant message
    messages = list(project.agents.messages.list(thread_id=thread_id))
    return {"reply": messages[0].content[0].text.value}

The user's token is what threads through the whole flow. The agent host receives it, the tool implementation receives it, the OBO call uses it. At no point does the agent host substitute its own identity for the user's; the data plane is the user's all the way down.

Step 7: The authorization test

This is the test that proves the security model works. It sends a malicious-looking prompt and verifies the database refuses to return data the user shouldn't see.

tests/test_authorization.py:

"""Adversarial test: prompts that try to bypass RLS must fail to return data."""
import pytest
from tools.query_orders import query_orders

# Two test users from seed-data.sql
ALICE_OID = "11111111-1111-1111-1111-111111111111"
BOB_OID = "22222222-2222-2222-2222-222222222222"


def fake_token(oid: str) -> str:
    """Build a JWT-shaped string with the given OID. For unit tests only."""
    import base64, json
    header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=")
    payload = base64.urlsafe_b64encode(
        json.dumps({"oid": oid, "exp": 9999999999}).encode()
    ).rstrip(b"=")
    return f"{header.decode()}.{payload.decode()}.signature"


def test_alice_sees_only_her_orders(monkeypatch):
    """Alice's call should return Alice's orders, no rows from Bob."""
    monkeypatch.setattr(
        "tools.query_orders.get_sql_token_obo",
        lambda token: f"sql-token-for-{token}",  # would actually call OBO
    )

    rows = query_orders(user_token=fake_token(ALICE_OID), limit=100)

    assert len(rows) > 0, "Alice should see her own orders"
    assert all(r["customer_id"] == 1 for r in rows), "Alice should not see Bob's orders"


def test_bob_sees_only_his_orders(monkeypatch):
    """Bob's call should return Bob's orders, none of Alice's."""
    monkeypatch.setattr("tools.query_orders.get_sql_token_obo", lambda t: f"sql-token-for-{t}")
    rows = query_orders(user_token=fake_token(BOB_OID), limit=100)
    assert len(rows) > 0
    assert all(r["customer_id"] == 2 for r in rows)


def test_unknown_user_sees_nothing(monkeypatch):
    """A user with no matching customer in the table sees zero rows."""
    monkeypatch.setattr("tools.query_orders.get_sql_token_obo", lambda t: f"sql-token-for-{t}")
    unknown_oid = "99999999-9999-9999-9999-999999999999"
    rows = query_orders(user_token=fake_token(unknown_oid), limit=100)
    assert rows == [], "Unknown users should see nothing"

These tests run against a real test database (in-memory SQLite-style mocking would not exercise the RLS, defeating the purpose). Provision a test SQL DB in CI, run the seed scripts, run the tests; they should pass with no rows leaked across users.

Production checklist

  1. Connect to SQL via Private Link, not the public endpoint. Combine with the network architecture from the Foundry-with-Private-Link article.

  2. Use Entra Groups, not individual users, for SQL access. Rather than CREATE USER [alice@...], create a group customers-with-sql-access, add Alice and Bob to it, and grant the group db_datareader. New customers join the group via your normal access flow.

  3. Audit the RLS policy quarterly. A subtle mistake in the predicate function silently lets data leak. Run a synthetic test (the test from step 7) on a schedule.

  4. Set a query timeout on the connection. Default Azure SQL queries can run for hours; for an agent tool, anything past 5 seconds is wrong. Connection Timeout=5 in the connection string.

  5. Log every tool invocation with the calling OID. Not the SQL the agent ran (don't log queries), but who called and what they asked for. The log is your audit answer for "what did Alice see during her conversation."

  6. Rotate the agent app's certificate annually. It's used for OBO; treat it like any other secret. Mount from Key Vault via CSI driver, never as an env var.

Troubleshooting

Tool returns zero rows for a user who should see rows. The most common cause is forgetting sp_set_session_context. RLS reads SESSION_CONTEXT(N'caller_oid'); if it isn't set, the predicate function returns nothing for every row. Verify with SELECT SESSION_CONTEXT(N'caller_oid') after your set call.

OBO returns AADSTS50158: External security challenge not satisfied. Conditional Access requires MFA on the downstream resource. Catch the error in obo_exchange.py, return a structured 403 with the claims field echoed, the client re-authenticates with MFA.

OBO returns AADSTS65001: The user has not consented. Admin consent for Azure SQL Database / user_impersonation was never granted. Have an Entra admin click the "Grant admin consent" button in the agent app's API permissions blade.

Connection times out from the agent host. Azure SQL's firewall is blocking the agent host's IP. If on Private Link, this should be impossible; verify the agent host's egress is going through the spoke's private DNS. If on public endpoint, add the AKS/Container Apps outbound IPs to the SQL firewall.

Authorization tests return more rows than expected. The RLS policy might be WITH (STATE = OFF). Re-run the policy DDL with WITH (STATE = ON).

The tool description tempts the model to pass a customer_id anyway. The model is being helpful. Tighten the description: "ABSOLUTELY DO NOT PASS A customer_id; the user is identified by their token automatically." This doesn't always work but does about 90% of the time.

Real-world references

Microsoft's RLS docs are the foundational reference; everything in this article builds on those primitives.

What this gives you, beyond the obvious

The obvious win is the security property: a malicious prompt cannot trick the tool into returning other users' data. That alone is the reason to ship this pattern.

The less obvious win is what changes about how the team thinks about agent tool design. Without RLS, every tool that touches sensitive data needs custom authorization logic, which is hard to test and easy to get wrong. With RLS, authorization is a database concern, which the team's existing security posture already covers. New tools become easy to ship: write the SQL, register with the agent, done.

The far-out win is what becomes possible. Once the OBO + RLS pattern is in place, the agent can offer a richer set of tools — read your own invoices, modify your own preferences, view your own audit log — without each tool requiring a security review. The pattern is the security review.

Six months into running this pattern, the team I shipped this for has six tools wired up to Azure SQL, all using OBO + RLS, all reviewed once for the foundation and zero times for individual tools. Zero data-leak incidents, zero malicious-prompt successes in red-team exercises. That's the bill the 200 lines of Python and 50 lines of T-SQL paid for.

Foundry ToolsAzure SQLRow-Level SecurityOBO

Conversation

Reactions & comments

Liked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.

Loading conversation…

More from Azure AI Foundry

See all →