Not long ago, Python's casefold() merged two of my customers into one tenant. I wrote that incident up in its own post, and the rule I carried out of it felt permanent: when a normalized value becomes a security key, the normalization is attack surface. Any many-to-one transform over an identity is a merge, and in an entitlement check a merge is a grant.
This month I finished the sequel to that repo. on-behalf is permission-aware retrieval for RAG over SharePoint and Google Drive: instead of copying ACLs at ingestion, it asks the source whether the asking user can open each candidate document, at query time, as the user, before anything gets ranked. Emails are the join key between an asking user and a sharing grant there, so models.py carries the lesson from last time as one small function, docstring pointing back at the whole argument:
ASCII_WHITESPACE = " \t\n\r\v\f"
def normalise_email(value: str) -> str:
trimmed = value.strip(ASCII_WHITESPACE)
fold = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
return trimmed.translate(fold)
One correct implementation, in one place, born from a production incident. And in a review pass the day before publication, I found this in the package's own fakes:
user = GraphUser(user_id=user_id, email=email.strip().lower())
FakeGraph and FakeDrive, the deterministic permission models that ship inside the package, were normalizing every email with str.lower(). The repo was arguing against the exact bug it was carrying.
str.lower() is the polite one
lower() reads like the harmless sibling of casefold(). It skips the famous German fold: "ß".lower() is still "ß", where "ß".casefold() is "ss". But it is still a Unicode operation with many-to-one mappings inside it. U+212A is the Kelvin sign, a character that renders as a capital K in most fonts, and "K".lower() is a plain ASCII "k". One character, one method call, two identities become one.
I didn't leave that as a hypothetical. Before the fix, inside the fakes, a sharing link naming Kevlar@outside.example spelled with the Kelvin sign resolved to a PermissionProof for the ASCII user kevlar@outside.example. Two addresses the package's own normalise_email() keeps strictly apart, folded into one grant by the code whose job is to demonstrate the fence.
No test caught it going in, and the reason deserves a hard look: both sides of the comparison went through the same wrong fold. Link grantees were lowered, stored user emails were lowered, so every case-insensitivity test passed. A normalization bug in a join key is self-consistent. It doesn't crash and it never disagrees with itself. It just quietly merges identities that the doctrine one directory up says must never merge.
Fakes that demonstrate a guarantee are production code
This would be a smaller story if those were throwaway fixtures. They aren't. The fakes ship in on_behalf.fakes because they are the offline half of the product: they model SharePoint effective access (nested groups, folders that break inheritance, sharing links with expiry) and Drive's two auth modes, and they're what lets the demo and the 353-test suite run the full pipeline with no network, no credential, no key. When the suite proves "a sharing link is dead at the exact expiry instant", it proves it against these models. A test that validates the fence using a biased copy of the fence validates nothing.
So the fix was not a better fold in the fakes. It was deleting the second implementation. The fakes now import the same function everything else uses:
from ..models import normalise_email
user = GraphUser(user_id=user_id, email=normalise_email(email))
and the link-grantee matching goes through the same door:
user.email in {normalise_email(person) for person in link.people}
anchored by a test so the rule can't regress back into prose:
def test_link_people_matching_folds_ascii_only_never_unicode(
tenant: GraphTenant,
) -> None:
# "K" U+212A (Kelvin sign) lowercases to "k" under str.lower(); the fakes
# must use the same ASCII-only fold as the rest of the package, so a link
# naming the Kelvin-sign spelling is a different identity, not a grant.
tenant.items["d-pub"].links.append(
SharingLink(
link_id="lnk-kelvin",
scope=LinkScope.SPECIFIC_PEOPLE,
people=("Kevlar@outside.example",),
)
)
kevlar = tenant.add_user("u-kevlar", "kevlar@outside.example")
decision = _resolve(tenant, kevlar, "d-pub")
assert isinstance(decision, Denied)
The first character of that link's address is U+212A, not an ASCII K. They render identically on most screens, which is the whole problem, and why the comment spells it out.
Here's the part I keep coming back to. The doctrine existed the whole time. It was written down twice, once in the casefold post and once in the normalise_email docstring, and implemented correctly in exactly one place. The fakes just didn't call it. A security rule that lives in prose is advice. It becomes a property of the system on the day the function that implements it is the only door, and every caller, fakes included, has to walk through it.
The repo the bug was hiding in
The package deserved a cleaner arrival, because its own thesis is this same argument at a larger scale: a permission snapshot is a leak with a start date. Most "permission-aware" RAG connectors do this:
# ingestion, nightly
for doc in source.list_documents():
index.add(doc, allowed_users=source.read_acl(doc)) # a copy
# query time
hits = [h for h in index.search(q, k) if user in h.allowed_users] # the copy decides
That copy is wrong the day after it is right, in three distinct ways, and on-behalf ships a deliberately wrong SnapshotACLIndex (it raises a UserWarning at construction) so the three leaks are demonstrated by executable tests instead of described by paragraphs: a revoked direct grant the snapshot keeps honouring, a group the user left that the snapshot still counts them into, a sharing link that expired after ingestion. Each test asserts both directions, leak present in the snapshot and absent from the query-time path, so the demonstration can't pass by both sides being broken.
The offline demo runs one synthetic tenant through the same question at 09:00 and at 12:00, permissions moving at lunchtime, the index never re-ingested. Between the two runs one user loses a memo and another loses everything, while the index still holds all five documents, and the snapshot section closes on the sentence I'd put on a slide: the snapshot cannot see any of it, "because the event it needed to observe happened after the only moment it ever looked."
The fix is ordering, twice over:
candidates = index.candidates(query) # a lexical gate, unscored
decisions = source.can_open_many(user, ids) # the source answers, NOW
entitled = [d for d in candidates if proven(d)] # proof or absence, no third state
return rank(query, entitled)[:k] # score last, cut last
Entitlement runs before ranking, so a document the user can't open never competes for the top-k window. The check runs at query time because that is the only moment the answer is true for. Whatever can't be proven is excluded and counted, never served on faith: a denial carries its reason, a source outage darkens that source's documents instead of widening access, and a redundant guard re-verifies every hit that reaches the context, raising EntitlementBreach instead of filtering silently, because a silent drop hides a real bug in the layer above.
What this does not settle
Exactness cuts both ways. A user whose grant was written under some exotic spelling of their address stays denied under an ASCII-only fold. That is the chosen trade-off, and it fails in the closed direction; the merge fails in the open one.
The live adapters sidestep folding almost entirely, by design. They do no ACL arithmetic: can_open fetches the item as the user, with a token minted on the user's behalf, and a 200 is the proof while 403 and 404 are the denial. The email fold matters most in the offline permission models, which is exactly the code most teams would wave off as "just fixtures".
And the live adapters have not yet been run against a real tenant. They implement the documented API contracts and pass the offline suite; the repo's RUNBOOK is the path to a real Microsoft 365 tenant and a Google Workspace domain, and until that walk happens the README refuses to claim it.
What changed in my process is small and concrete: fakes and fixtures now go through the same pre-publish sweep as shipped code, the grep for .lower( and .casefold( near anything identity-shaped covers the whole tree, and every doctrine function gets at least one test that exercises it through the fakes' own call path. The Kelvin test is the first of those. A rule is exactly as wide as the set of callers forced through the function that implements it; everything outside that set is a README.
The repo, with the demo, the three leak tests, and the RUNBOOK for a real tenant: github.com/vinimabreu/on-behalf



Top comments (0)