← Certain Answers
Part II · Five Failures · Chapter 5

Authorization and Negation

Hiding a row doesn’t hide the fact — it fabricates the opposite fact. This is the most counterintuitive failure in the book, the consequences are the worst, and the fix is the least popular.

Back to the original answer: “Three of these have open P0 tickets with no linked opportunity, suggesting a gap in account coverage.”

The agent computed that with an anti-join:

SELECT t.*
FROM tickets t
LEFT JOIN opportunities o ON o.account_id = t.account_id
WHERE o.id IS NULL;

The user is a support manager. Support managers don’t have Salesforce opportunity access. So the opportunity rows were filtered out by row-level security before the join ran.

The opportunities exist. The agent reported that they don’t.

5.1 Why filtering is exactly wrong here

Filtering invisible rows is the right behavior for a SELECT. It is precisely the wrong behavior for a NOT EXISTS, because negation inverts the polarity:

The polarity inversion

Hiding a row fabricates its negation

For positive queries (SELECT, EXISTS): hiding a row means “you don’t know about it.” Safe — the user sees a subset of reality.

For negative queries (NOT EXISTS, EXCEPT, anti-join): hiding a row means “it doesn’t exist.” Dangerous — the user sees a fabricated absence.

The failure is silent, confident, and actionable in the worst way. Somebody reads “no linked opportunity,” creates a duplicate opp, and now the CRM has two. Nobody will ever trace that back to an access control decision.

The general principle is that permission enforcement must commute with query evaluation:

\[ \text{filter}(\text{eval}(Q, D)) \stackrel{?}{=} \text{eval}(Q, \text{filter}(D)) \]

For plain selections and projections, this holds. It provably fails for:

5.2 Truman and Non-Truman

Rizvi, Mendelzon, Sudarshan and Roy named this fork at SIGMOD 2004, and the naming deserves to be standard vocabulary in agent engineering:

Key distinction

Two models of fine-grained access control

The Truman model (after The Truman Show): silently rewrites every query to the user’s visible slice. The user gets a coherent, self-consistent world that happens not to be the real one. It is convenient, it never throws errors, and it produces exactly the misleading anti-join results above.

The Non-Truman model: rejects queries that aren’t soundly answerable under the user’s authorization. It is less convenient. It throws errors at people. It is also the only honest option once a language model is going to narrate the result in English, because a refusal is recoverable and a fabricated absence is not.

For a dashboard, take Truman — the user can see the filter chips and knows the shape of their own access. For anything an agent narrates, take Non-Truman.

Correctness criteria

Wang, Yu, Li and Jajodia (VLDB 2007) formalized three criteria for fine-grained access control:

  1. Soundness: no unauthorized data appears in the answer.
  2. Security: no unauthorized data can be inferred from the answer.
  3. Maximality: the restriction is no more restrictive than necessary.

These three are exactly the properties you want from an agent’s data access layer. The Truman model satisfies soundness (no unauthorized rows appear) but violates security (unauthorized data can be inferred through its absence). The Non-Truman model satisfies all three, at the cost of sometimes refusing rather than answering.

5.3 The guarantee lattice

The authorization annotation — which we call the guarantee — tracks whether a result set is complete from the perspective of the querying principal:

\[ \texttt{full} \sqsupset \texttt{partial} \sqsupset \texttt{none} \]
LevelMeaningOperations allowed
fullThe principal can see all qualifying rows in this result setAll operations, including negation
partialSome rows may be filtered by RLS; the principal sees a subsetPositive queries only (SELECT, EXISTS, aggregation). Negation is rejected.
noneNo authorization guarantee availableNo operations — result cannot be used

The composition rule is the meet, as always:

\[ \text{guarantee}(A \bowtie B) = \text{guarantee}(A) \sqcap \text{guarantee}(B) \]

And the critical gate:

def check_negation(op: str, inputs: list[Guarantee]) -> Optional[str]:
    """Anti-joins and set difference require full guarantee on both inputs."""
    if op in ("anti_join", "except", "not_exists", "not_in"):
        for i, g in enumerate(inputs):
            if g.level != GuaranteeLevel.FULL:
                return (
                    f"Cannot compute {op}: input {i} has guarantee "
                    f"'{g.level.name}' (needs 'full'). The result would "
                    f"fabricate absences for rows filtered by access control. "
                    f"Refusing rather than returning a misleading answer."
                )
    return None  # safe
Drill 5.1

An agent needs to answer: “Which accounts in region APAC have no active subscription?” The accounts table has RLS (the user sees only their own region). The subscriptions table has no RLS (all subscriptions visible). What guarantee level does each input carry? Is the anti-join safe?

Show answer

Accounts: guarantee = partial (RLS filters to user’s region). Subscriptions: guarantee = full (no RLS). For the anti-join: we need “accounts that do NOT have a subscription.” The accounts side is the positive input (we want accounts FROM this set), and the subscriptions side is the negated input (where NO match exists). The subscriptions side is full, so the negation is safe — we genuinely know there is no subscription. But wait: are there APAC accounts the user cannot see? The RLS on accounts means the user only sees their own accounts — the anti-join will correctly report “these accounts of mine have no subscription,” but might miss other APAC accounts. The result is a lower bound on the count (a completeness issue, not a fabrication issue). The anti-join is safe because the negated side is complete. The accounts side being partial is a completeness issue, not a negation-soundness issue.

5.4 Rollups have no principal

The monthly_account_arr table from Chapter 3 was built by a nightly pipeline running as a service account. It has already aggregated across every visibility boundary in your organization. Serving it to a regional rep who should only see APAC hands them global numbers, and no ACL check on the query will catch it, because the mixing happened at build time.

The query is reading a table the user is authorized to read. The table just happens to contain other people’s data, pre-mixed.

The rule

Tag every materialized rollup with the visibility predicate it was built under:

materialized_views:
  monthly_account_arr:
    grain: [account_id, month]
    built_by: service_account
    visibility_predicate: null  # UNTAGGED — built over all data

  regional_monthly_arr:
    grain: [region, month]
    built_by: service_account
    visibility_predicate: "region = principal.region"

Serve a rollup to a principal only if that principal’s visibility subsumes the rollup’s predicate:

def can_serve_rollup(principal, rollup) -> bool:
    """A principal can use a rollup only if their access covers its scope."""
    if rollup.visibility_predicate is None:
        # Untagged: built over all data. Only serve to principals
        # with unrestricted access to the base data.
        return principal.has_unrestricted_access(rollup.base_table)
    # Tagged: check that principal's visibility subsumes the predicate
    return principal.visibility_subsumes(rollup.visibility_predicate)

An untagged rollup is unenforced — fall through to base facts and eat the latency. This is the correct default: the safe answer is slower, not wrong.

Gap in the literature

Nobody has written about this

When searched, no dedicated treatment of rollup-visibility leakage was found in either the academic or practitioner literature. Given that every semantic layer on the market ships aggregate awareness and pre-aggregation as a headline performance feature, this is a concerning gap. The rule above is simple but it appears to be novel as a stated principle.

5.5 The tracker attack, accelerated

The classical inference-control literature — Denning’s trackers, query-set-size restriction, and the differential privacy line from PINQ through Elastic Sensitivity — assumed a determined human analyst issuing queries by hand.

An agent issues a hundred aggregate queries in a loop without getting bored.

# The tracker attack: recover a single account's ARR
total = query("SELECT SUM(arr) FROM accounts WHERE region = 'APAC'")
without = query("SELECT SUM(arr) FROM accounts WHERE region = 'APAC' AND account_id != 'A-2201'")
target_arr = total - without  # revealed through subtraction

This has always been possible. It was previously impractical because a human would need to issue the queries manually, and a security team might notice the pattern. With an agent in the loop, the cost drops to zero and the queries look like normal analytical work.

Defenses

Three layers, in increasing order of engineering cost:

  1. Query budgets: limit the number of aggregate queries a principal can issue per entity per session. Cheap to implement, easy to explain, catches the basic differencing attack.
  2. Minimum group size: reject aggregates over groups smaller than k (typically k = 5 or 10). Standard in statistical disclosure control. Prevents single-entity targeting.
  3. Differential privacy: add calibrated noise to aggregate results. The gold standard but expensive to implement correctly and requires a privacy budget that degrades over queries.

For most agent deployments, layer 1 is sufficient and layer 2 covers the residual. Layer 3 is for when you’re serving sensitive aggregate data to principals who are explicitly adversarial (external partners, regulatory interfaces).

@dataclass
class QueryBudget:
    max_aggregates_per_entity: int = 10
    max_aggregates_per_session: int = 100
    min_group_size: int = 5

    def check(self, query_plan, principal, session) -> Optional[str]:
        # Check min group size
        if query_plan.estimated_group_size() < self.min_group_size:
            return "Aggregate over group smaller than minimum (5). Rejected."
        # Check per-entity budget
        entities = query_plan.targeted_entities()
        for entity in entities:
            count = session.aggregate_count_for(principal, entity)
            if count >= self.max_aggregates_per_entity:
                return f"Query budget exhausted for entity {entity}."
        return None
Drill 5.2

An agent is asked: “Which of our enterprise accounts have no active support contract?” The agent has access to the accounts table (full visibility for this user) and the contracts table (RLS: user sees only contracts they manage). Trace the guarantee annotations. Should the system answer, refuse, or qualify?

Show answer

Accounts: full (user sees all enterprise accounts). Contracts: partial (RLS filters to managed contracts). The query is an anti-join: accounts NOT IN contracts. The negated side (contracts) is partial. The system must refuse: “Cannot determine which accounts lack a support contract, because your access to contracts is restricted. Accounts that appear to have no contract may have one managed by another team.” The alternative — answering with fabricated absences — would list accounts that DO have contracts (just not managed by this user) as having none.

5.6 The guarantee annotation in practice

Putting it together, the guarantee annotation flows through the query plan:

from enum import IntEnum
from dataclasses import dataclass
from typing import Optional

class GuaranteeLevel(IntEnum):
    FULL = 2
    PARTIAL = 1
    NONE = 0

@dataclass(frozen=True)
class Guarantee:
    level: GuaranteeLevel
    reason: Optional[str] = None  # why it's partial

    @staticmethod
    def full():
        return Guarantee(GuaranteeLevel.FULL)

    @staticmethod
    def partial(reason: str):
        return Guarantee(GuaranteeLevel.PARTIAL, reason=reason)

    def meet(self, other: "Guarantee") -> "Guarantee":
        if self.level <= other.level:
            return self
        return other

    def allows_negation(self) -> bool:
        return self.level == GuaranteeLevel.FULL

    def allows_aggregation(self) -> bool:
        return self.level >= GuaranteeLevel.PARTIAL

At each node in the query plan, the validator checks:

  1. Leaf nodes: what RLS policies apply? If any, the guarantee is partial.
  2. Join nodes: meet of inputs.
  3. Anti-join / NOT EXISTS / EXCEPT nodes: if any input is not full, reject.
  4. Aggregate nodes: if input is none, reject. If partial, allow but propagate the annotation (the aggregate is over a subset, which is a completeness issue but not a fabrication).

Exercises

Implement
  1. Build the Guarantee validator for a query plan tree. Given a plan with annotated leaves (each leaf knows its RLS policy), propagate guarantees bottom-up. At anti-join nodes, emit a rejection with an explanation that does not disclose what the user cannot see (the rejection itself must be secure — it cannot say “there are 5 opportunities you can’t see”). Test against these cases:
    • tickets LEFT JOIN opportunities WHERE opp.id IS NULL with user lacking opportunity access → reject
    • accounts WHERE account_id NOT IN (SELECT account_id FROM churned) with full access to both → allow
    • SELECT region, COUNT(*) FROM accounts GROUP BY region with partial access → allow (positive aggregate), but annotate that the counts reflect only visible accounts
  2. Implement the rollup subsumption check. Given a set of materialized views with tagged visibility predicates, and a principal with a known visibility scope, write a function that returns which views are safe to serve and which must fall through to base facts.
Extend
  1. Design the rejection message for the Non-Truman model. The message must: (a) tell the user the query cannot be answered, (b) explain why in a way that helps them (e.g., “requires access to opportunities”), (c) not disclose the content of what they cannot see (cannot say “there are 3 opportunities for this account”). Write a template system that generates safe rejection messages for the five most common anti-join patterns in your domain.
  2. Consider a multi-principal query: an agent acting on behalf of a team, where different team members have different access. The agent needs to answer a question for the team. Design the guarantee semantics: does the agent use the union of all principals’ access (maximally permissive, but results may contain data some members cannot see) or the intersection (maximally restrictive, but all members can verify the answer)? What are the security implications of each choice? Is there a middle path?

Further reading