Access Control
Mango has no accounts, roles or login of its own. Your application decides who the caller is; Mango enforces what that caller may see and do. The bridge between the two is an opaque user object and a chain of middlewares.
from mango.middleware import CollectionAccess, RowFilter, RedactFields, DenyTools, AuditLog
ROLE_COLLECTIONS = {"analyst": ["orders", "products"], "finance": ["orders", "invoices"], "admin": "*"}
agent.use(CollectionAccess(lambda user: ROLE_COLLECTIONS[user.role]))
agent.use(RowFilter("orders", lambda user: {} if user.role == "admin" else {"region": user.region}))
agent.use(RedactFields(lambda user: [] if user.role == "admin" else ["customers.email", "customers.phone"]))
agent.use(DenyTools(lambda user: [] if user.role == "admin" else ["save_text_memory"]))
agent.use(AuditLog())
answer = await agent.ask("how many orders this month?", user=current_user)
Three things to notice:
current_useris your object — a Django user, a dict decoded from a JWT, a dataclass. Mango never reads it; only the functions you wrote do.- Every built-in takes a function of the user, so role logic lives in your code, not in a configuration format Mango invented.
- A blocked call does not crash the turn. It becomes a tool error the LLM relays to the person ("this data is not available to you") without retrying or working around it.
How enforcement works
The agent loop has fixed interception points. Each registered middleware is called at each point, in registration order:
| Hook | Called | Can |
|---|---|---|
before_turn(ctx) | once, before anything else | block the whole turn (budgets) |
filter_tools(ctx, tool_defs) | once per turn | hide tools from the LLM |
before_tool(ctx, tool_name, args) | before every tool call | rewrite the args or block the call |
before_query(ctx, query) | before every run_mql / explain_query | rewrite the QueryRequest or block it |
after_query(ctx, query, rows) | after the rows come back | mask or drop rows |
after_tool(ctx, tool_name, args, result) | after every tool call | rewrite any ToolResult (schema, samples, stats) |
before_answer(ctx, text) | before the final answer is returned | rewrite the text (DLP, disclaimers) |
after_turn(ctx, stats) | when the turn is over | audit |
filter_schema(ctx, schema) | when the per-turn prompt is built | hide collections/fields from the prompt |
collection_policy(ctx) | once per turn | restrict collections inside the runner |
ctx is a TurnContext with user, question, session_id, turn_id and a state dict middlewares can share within the turn.
Two hooks deserve a note because they are what makes the restrictions airtight:
collection_policydoes not just check the tool call: the policy it returns is applied byMongoRunneritself, on listing, introspection, profiling and execution — including collections pulled in through$lookup,$graphLookupand$unionWith. A pipeline written by the LLM cannot reach around it.filter_schemagoverns what the prompt shows. Hidden collections and masked fields do not appear in the schema section, in value hints, or in memory examples — the LLM never learns they exist.
Built-in middlewares
All live in mango.middleware.
CollectionAccess(collections_for)
collections_for(user) returns a list of collection names, or "*" / None for unrestricted access.
Hidden collections are invisible everywhere: list_collections, the prompt schema, value hints, memory examples, and every query — $lookup/$unionWith targets included. system.* collections are always hidden.
RowFilter(collection, filter_for)
filter_for(user) returns an MQL filter document ({} for no restriction). It is:
- AND-ed into the filter of
find,countanddistincton that collection; - prepended as
$matchto aggregation pipelines on that collection; - pushed into
$lookupand$unionWithstages that read that collection — alocalField/foreignFieldlookup is rewritten to the pipeline form (let+$expr); $graphLookupon that collection is denied, because it has no place to host a filter.
Register one RowFilter per collection you need to scope.
RedactFields(fields_for)
fields_for(user) returns dotted "collection.field.path" entries. For those fields:
- a query that references them anywhere (filter, projection, sort,
$groupkey,$projectexpression,distinct_field) is denied; inspect_fieldon them is denied;- any value that still reaches a tool result — query rows, sample documents, schema sample values — is replaced with
[REDACTED]; - their sample values are stripped from the prompt schema.
DenyTools(tools_for)
tools_for(user) returns tool names. Denied tools are removed from the list offered to the LLM and refused if called anyway. The names memory_train, memory_import and memory_export gate the REST memory endpoints the same way.
Budget(max_turns_per_day=None, max_queries_per_day=None, key_for=None)
Per-user daily caps on turns and executed queries. Counters live in process memory and reset at UTC midnight; behind several workers put the cap in your gateway instead. key_for(user) identifies the user (defaults to id, sub, email or username).
AuditLog(sink=None, key_for=None, logger_name="mango.audit")
Emits one event per executed query, per denial and per turn:
{"event": "query", "ts": "…", "user": "ana", "session_id": "…", "turn_id": "…", "collection": "orders", "operation": "aggregate", "query": "{…}", "row_count": 12}
{"event": "denied", "ts": "…", "user": "ana", "session_id": "…", "turn_id": "…", "tool": "run_mql", "reason": "Collection 'salaries' is not accessible to this user."}
{"event": "turn", "ts": "…", "user": "ana", "session_id": "…", "turn_id": "…", "question": "…", "iterations": 3, "input_tokens": 4120, "output_tokens": 210, "tool_calls": ["run_mql"], "retries": 0, "denied": 1}
By default they are logged as JSON lines on the mango.audit logger; pass sink=callable to ship them anywhere.
Custom rules
Anything the built-ins do not cover is a function on a hook, registered with a decorator:
from mango.middleware import AccessDenied
@agent.before_query
def interns_cannot_see_salaries(ctx, query):
if ctx.user.role == "intern" and query.collection == "salaries":
raise AccessDenied("salaries are not available to interns")
return query
@agent.before_answer
def add_disclaimer(ctx, text):
return text if ctx.user.role == "admin" else f"{text}\n\n_Figures exclude restricted data._"
Available decorators: @agent.before_turn, @agent.filter_tools, @agent.before_tool, @agent.before_query, @agent.after_query, @agent.after_tool, @agent.before_answer, @agent.after_turn.
For anything stateful or multi-hook, subclass Middleware:
from mango.middleware import Middleware, AccessDenied
class BusinessHours(Middleware):
def before_turn(self, ctx):
if ctx.user.role == "contractor" and not 9 <= datetime.now().hour < 18:
raise AccessDenied("contractors may query only during business hours")
agent.use(BusinessHours())
What a denial looks like
From the LLM's side, a blocked tool call returns:
[ACCESS DENIED] Tool 'run_mql' was blocked by the access policy.
Reason: Collection 'salaries' is not accessible to this user.
Do not retry this call and do not try to work around the restriction …
From the client's side, the tool_result SSE event carries that text in preview, with success: false, and the final answer explains the limit in the user's language. A denial raised in before_turn ends the turn immediately: ask() raises AccessDenied, and the HTTP server sends {"type": "error", "code": "access_denied", "message": …}.
Sessions and users
Middlewares receive the user on every call, so permissions can change between turns. The HTTP layer additionally binds each session to the user that created it: presenting a session id with a different user is refused with 403.
Limits
RowFilterrewriteslocalField/foreignFieldlookups to$exprjoins, which can be slower than the simple form on large, unindexed collections.Budgetis process-local.- Middlewares run in registration order; a
RowFilterregistered before aCollectionAccessstill works, but keep access first for readability.