Skip to content
Guides/Detail

Your entitlement check should return the tenant id

19/08/2026 (edited)
Tech
1216 Words
6Min read

Most permission checks answer one question: may this caller proceed, yes or no.

A multi-tenant server has to answer two. May they proceed, and whose data is this? If the check answers only the first, the second is a separate lookup, and that separate lookup is where cross-tenant bugs live.

So do not return a boolean. Return the tenant id.

def resolve_seat(conn, seat_id, allow_degraded=False, *, now=None, trial_days=None):
    """Entitlement by verified seat principal (the live-wire path). Resolves
    seat -> company, requires the seat to be active, then applies the single
    policy. Returns the company_id (the tenant identity). Fails closed."""

Now a tool cannot do its work without having passed the gate, because the gate is what tells it which rows to touch. Forgetting the check stops being a discipline problem and becomes a compile-time-shaped one: there is no tenant id to use.

This is the pattern behind a hosted MCP server that has run multi-tenant in production since 02/07/2026, with seats, trials, comps and runner tokens.

The shape this replaces

The usual arrangement looks reasonable:

if not is_entitled(caller):
    raise Forbidden()
tenant = tenant_for(caller)      # second lookup, separate code path
rows = query(tenant, ...)

Three things can now drift apart. The gate can pass while the lookup returns a different tenant. A new tool can do the lookup and skip the gate. A refactor can reorder them.

None of that is possible when the gate is the lookup.

Why must two auth paths share one policy function?

Because you will have more than one, and the second one is where the rule quietly diverges.

Ours has two: a verified seat principal on the live wire, and an opaque bearer reference for the legacy path. Different lookups, different columns, same question. So the policy lives in exactly one function that both call:

def _entitled(status, tenant_id, *, plan_tier=None, allow_degraded, trial_started_at,
              now, trial_days, trial_tz=None):
    """The single entitlement policy, shared by both resolve paths so the rule can
    never drift."""

Each path does its own resolution, then hands the result to the same judge. When the trial rule changes, it changes once.

The failure this prevents is not dramatic. It is a policy fix landing on one path and not the other, and nobody noticing for a month because both paths are green.

Failing closed is a direction, not a list of cases

Every unknown is a refusal:

if row is None:
    raise EntitlementError("unknown seat")          # fail closed

Unknown seat, unknown token, unknown tenant, seat not active. Also: no database configured, no request context, a schema surprise. All deny.

The reason to state it as a direction rather than a checklist is that you cannot enumerate the cases in advance. What you can do is decide, once, which way an unanticipated failure should fall, and then make every branch obey it.

One tenant seeing another tenant’s data is unrecoverable. A support ticket saying “it says I am not entitled and I am” is a bad afternoon. Choose the afternoon.

Listing is not authorisation

An MCP server can filter which tools appear in tools/list. It is tempting to treat that as a permission system. It is not one, because a client can call a tool it was never shown.

Keep the two ideas apart. Ours hides fifteen operator tools from the customer list while every one of them stays gated at call time by the checks it always had:

def visible_tools(tools: list) -> list:
    """Filter a tool list for the current caller."""
    if caller_is_operator():
        return tools
    return [t for t in tools if t.name not in OPERATOR_ONLY_TOOLS]

Visibility is a product decision. A reviewer who connects and sees your provisioning, comping and usage-reporting tools is looking at an un-curated surface, and that is a reason to hide them. It is not a reason to stop gating them.

The resolver behind it fails closed toward the smaller surface, which is worth saying out loud because it is the opposite of what a normal auth check does:

def caller_is_operator() -> bool:
    """True when the current caller is one of ours. Fails closed and never raises.

    tools/list must not be able to 500: any lookup problem resolves to "customer",
    which shows the smaller surface. Being wrong here hides a tool from an
    operator; being wrong the other way shows the back office to a reviewer."""

Both failure modes are real. One inconveniences a colleague. The other is what a directory reviewer sees.

Check the comp before you check the subscription

A comped account is entitled in its own right, evaluated before subscription status is consulted at all:

if plan_tier == "comp_100":
    # Comped for life: access is independent of subscription_status, so a Stripe
    # cancellation/deletion (which flips status to canceled) can never lock out a comp.
    return tenant_id

Get the order wrong and the failure arrives months later, through a billing event nobody connected to access. A subscription is cancelled or deleted, status flips to canceled, and an account you promised would never be locked out is locked out.

A commercial promise should not be revocable by a webhook.

Why did a 28-day trial run for 29 days?

Because day one is zero elapsed days, and the comparison used <=.

# `elapsed_days` counts FULL days since signup, so day N is elapsed N-1: day 1 is
# elapsed 0 and day 28 is elapsed 27. The condition is therefore strictly less-than.
# It read `<=` until 07/08/2026, which granted 29 days for a 28-day trial.

Every document said 28. The code said 29. A customer on day 29 still had access when the price list had already moved them to full price, and nothing failed, so nothing reported it.

The related decision is where the day boundary sits. Expiring a fixed number of hours after the signup instant hands over most of an extra day, because people do not sign up at midnight. Ours expires at local midnight in the timezone captured at signup, and when that timezone is unusable it falls back to the instant rule deliberately, because that fallback can only ever be generous:

The fallback can therefore give someone a few extra hours, and can never lock them out during their final day. Locking early is the failure with a real cost.

Both halves of that are billing decisions expressed as code. Write down which direction each one errs in, next to the code that errs in it, or the next person will “fix” it to be symmetrical.

The short version

Four rules, in the order they pay off:

  1. The entitlement check returns the tenant id, so the work cannot happen without it.
  2. Every auth path shares one policy function, so the rule cannot drift.
  3. Unknown means refused, everywhere, including the failures you did not think of.
  4. Hidden is not gated. Curate the list; authorise the call.
Back to guides
End of Post