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_user is 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:

HookCalledCan
before_turn(ctx)once, before anything elseblock the whole turn (budgets)
filter_tools(ctx, tool_defs)once per turnhide tools from the LLM
before_tool(ctx, tool_name, args)before every tool callrewrite the args or block the call
before_query(ctx, query)before every run_mql / explain_queryrewrite the QueryRequest or block it
after_query(ctx, query, rows)after the rows come backmask or drop rows
after_tool(ctx, tool_name, args, result)after every tool callrewrite any ToolResult (schema, samples, stats)
before_answer(ctx, text)before the final answer is returnedrewrite the text (DLP, disclaimers)
after_turn(ctx, stats)when the turn is overaudit
filter_schema(ctx, schema)when the per-turn prompt is builthide collections/fields from the prompt
collection_policy(ctx)once per turnrestrict 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_policy does not just check the tool call: the policy it returns is applied by MongoRunner itself, on listing, introspection, profiling and execution — including collections pulled in through $lookup, $graphLookup and $unionWith. A pipeline written by the LLM cannot reach around it.
  • filter_schema governs 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, count and distinct on that collection;
  • prepended as $match to aggregation pipelines on that collection;
  • pushed into $lookup and $unionWith stages that read that collection — a localField/foreignField lookup is rewritten to the pipeline form (let + $expr);
  • $graphLookup on 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, $group key, $project expression, distinct_field) is denied;
  • inspect_field on 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

  • RowFilter rewrites localField/foreignField lookups to $expr joins, which can be slower than the simple form on large, unindexed collections.
  • Budget is process-local.
  • Middlewares run in registration order; a RowFilter registered before a CollectionAccess still works, but keep access first for readability.