Skip to content
← All notes
TechnicalOctober 7, 202613 minute read

Keeping Access Checks Out of Endpoint Logic — Part 5

How common access checks prepare endpoint handlers, while explicit policies and scoped queries protect resource operations.

Token validation, context, grants, policy, and resource lookup each establish a separate part of the access decision.

By this point, in part 4, we had identities, organization context, permissions, and a registration process. We could explain what a caller needed before it reached Inventory.

Then a developer had to write the endpoint.

My goal was that someone implementing item retrieval should spend most of their time on item retrieval. The common access work was necessary, but I did not want every handler to become another implementation of authentication and authorization.

That does not mean the developer stops making access decisions. They still choose the endpoint's policy and implement its resource rules. What we can share is the mechanism that applies those choices.

Let us start with the repeated work, then remove it in steps.

The route helpers and request functions below are pseudocode. They describe responsibilities rather than a complete framework integration. The PermissionExpression examples use our shared Python library with sample permissions.

The first handler has too many jobs

Follow Ada's item-read request into Inventory.

Before retrieving the item, the service must verify her token, determine the caller type, establish the required organization context, obtain grants, and evaluate the operation's requirement.

A handler that does all of that might look like this:

async def read_item(request, item_id):
    claims = await verify_access_token(request.token)
    caller = identify_caller(claims)
    context = resolve_context(request, caller)

    grants = await accounts.get_permissions(context)
    requirement = item_read_requirement(caller.type)
    if not requirement.evaluate(grants):
        raise Forbidden("You cannot read items.")

    item = await items.find(
        id=item_id,
        organization_id=context.organization_id,
    )
    if item is None:
        raise NotFound("Item not found")

    return serialize_item(item)

This sketch can describe the right sequence. The problem appears when we write the next handler.

An item-list endpoint needs many of the same checks. An edit endpoint needs them too. Another service then needs the same token and context rules in a different framework.

Copying the checks makes every handler a place where behavior can diverge. One could choose the wrong organization source. Another could query grants for the wrong caller. Even when each copy is correct, a change to common behavior now has several places to reach.

A shared function called from every handler is one improvement. It centralizes the implementation while leaving the invocation explicit.

I wanted to go further at the request boundary. Framework middleware, authentication hooks, decorators, and guards already provide ways to perform common checks before the business operation.

We used those mechanisms to prepare a trusted request context and apply the endpoint policy.

Extract the common checks, but keep their order

There is an order to this work.

We cannot select a user or service policy from an unverified subject. We cannot query user grants without the organization in which those grants apply. And we should not run the resource operation before the access decision is established.

The common request layer therefore develops the request in stages:

Token and request inputs
    -> verified identity
    -> caller and organization context
    -> grants for that caller
    -> endpoint permission decision
    -> resource operation

Each stage supplies information the next stage needs.

The token validator is responsible for the token's signature and required claims. The context layer turns the verified identity and accepted request inputs into a form the application can use. The permission layer compares the appropriate grants with the endpoint's requirement.

The handler receives the result of those checks.

We did not need one giant middleware function for every responsibility. Different components can own different stages, provided the framework runs them in the required order.

This is why I think in terms of responsibilities before framework names. An Actix guard and a Django integration can expose different interfaces while preserving the same sequence.

Once the shared work has run, the item handler becomes smaller:

async def read_item(request, item_id):
    item = await items.find(
        id=item_id,
        organization_id=request.context.organization_id,
    )
    if item is None:
        raise NotFound("Item not found")
    return serialize_item(item)

The organization restriction remains visible. It belongs to retrieving the correct resource, even when the identity and access checks happened earlier.

A prepared context does not rewrite database queries for us.

Ada and Example Sync do not supply context in the same way

Ada uses an organization-scoped user token. When she works in Organization A, the verified organization claim supplies that context.

Changing a request header does not convert her A token into B authority. This follows the issuance and renewal model from Part 2.

Example Sync uses its own service identity for scheduled work. Its operation also needs a target organization, but the service is not a human member of that organization.

In the teaching HTTP examples, a service names its target using X-Example-Organization. That is a sample header, not a credential.

The request layer treats the value as target context for the service operation. The service grant and the operation's target rules determine the permitted work. A target ID by itself does not supply that authority.

This distinction is worth following carefully. A service may have a capability that applies across organizations because its job requires that scope. That is a decision about the service operation and its grants. We must not describe it as a user's organization membership.

Both request paths can still produce a common context shape:

from dataclasses import dataclass
from typing import Literal

@dataclass(frozen=True)
class RequestContext:
    caller_id: str
    caller_type: Literal["user", "service"]
    organization_id: str

This particular example represents an operation that requires an organization. It does not claim that every endpoint has a non-null organization.

The benefit is that the item handler uses an established context without rediscovering how each caller supplied it.

The cost is that the boundary rules must stay explicit. A convenient common object cannot erase differences in where the values came from.

The endpoint author still chooses the policy

Moving checks out of handlers does not mean every endpoint has the same requirements.

An item operation needs an organization. An account-profile operation can identify the user without selecting an organization. A public information endpoint may accept requests without an identity.

An endpoint might also show public information to everyone and add user-specific information for authenticated callers.

Those are product choices. The developer must state which behavior the endpoint supports.

Endpoint kind Identity requirement Context requirement
Organization item read Accepted user or service identity. Required organization context.
Account profile User identity. No organization required for the profile operation.
Public information No identity required. Only the context the public operation defines.
Optional personalization Public access plus an authenticated branch. Defined separately for each branch.

These are teaching categories. They are not inferred from an empty permission list.

A public policy should be explicit. Optional authentication also needs defined behavior for a supplied but invalid credential; it should not accidentally gain meaning from an exception being ignored.

That is a useful limit on shared code. The common layer can enforce the selected policy. It cannot decide whether the product wanted a public endpoint.

The same applies to caller types. An endpoint that accepts users does not automatically accept services because both use JWTs.

Start with one simple requirement

For an ordinary item read, the endpoint initially requires a caller-specific read grant:

@require_permissions(
    user=["user:catalog:item:read"],
    service=["service:catalog:item:read"],
)
async def read_item(request, item_id):
    ...

This decorator is a policy sketch. The verified caller type chooses which requirement is evaluated.

For Ada, the grant lookup concerns Ada in Organization A. For Example Sync, it concerns grants assigned to svc:example-sync.

Do not combine those lists.

If Inventory calls Accounts to retrieve Ada's grants, Inventory authenticates that outgoing lookup with its own service identity. Its grant lets it ask the question. The returned grants still describe Ada.

This is the two-caller distinction from Part 1 in an actual authorization path.

For now, keep Accounts as the grant source. Part 7 will change the lookup path after we examine its cost. The endpoint's required capability remains the same when the grant source changes.

The first allow and deny cases are simple. Ada with the required user read grant proceeds to the resource lookup. Ada without it receives a permission denial. Example Sync must satisfy the service requirement, not Ada's requirement.

A valid token establishes the principal we evaluate. It is not an alternative way to satisfy a missing grant.

A customer expectation changed our read policy

Initially, we treated reading and writing as separate capabilities.

That is a defensible design. Some systems need callers that can submit information without reading existing information. Independent grants can express that.

But it did not match the ordinary resource workflow we wanted to expose.

When a customer gives someone editing access, that person normally needs to open the item, inspect its current details, and then make the change. Requiring a separate read assignment made that workflow harder to explain.

We changed our policy so that ordinary mutation capabilities can satisfy the related read requirement. Deletion sits above the relevant lower capabilities in that policy.

The important phrase is "in that policy." The matcher does not know that the English word delete is stronger than read.

We express the accepted grants explicitly:

from leysir_permissions import PermissionExpression

read_item_policy = PermissionExpression.any([
    "user:catalog:item:read",
    "user:catalog:item:create",
    "user:catalog:item:edit",
    "user:catalog:item:delete",
])

edit_item_policy = PermissionExpression.any([
    "user:catalog:item:edit",
    "user:catalog:item:delete",
])

delete_item_policy = PermissionExpression.any([
    "user:catalog:item:delete",
])

Now we can explain the result without assuming hidden action ordering:

assert read_item_policy.evaluate(["user:catalog:item:edit"])
assert edit_item_policy.evaluate(["user:catalog:item:delete"])
assert not delete_item_policy.evaluate(["user:catalog:item:edit"])
assert not edit_item_policy.evaluate(["user:catalog:item:read"])
assert not edit_item_policy.evaluate(["user:catalog:item:create"])

An edit grant satisfies the read policy because the read policy lists it. It does not add a new stored read grant to every user. A create grant does not automatically imply editing just because both actions mutate data.

This keeps the ordinary workflow convenient while making the implications reviewable.

The cost is that this read policy cannot represent a write-only role. A domain that needs that distinction must choose another requirement. The policy should follow the operation, not a universal assumption that more destructive actions always deserve broader access.

OR means one accepted path is enough

The read policy above is an OR: any listed grant can satisfy it.

This is useful when several capabilities legitimately authorize the same operation. It is not a request for the caller to possess every listed grant.

Suppose Ada has item editing but not an exact item-read key. The read requirement succeeds. Bayo has only the item-read key, so the read also succeeds while editing remains denied.

A service key in either user's grant list must not satisfy the user requirement. The caller-type segment still has meaning:

assert not read_item_policy.evaluate([
    "service:catalog:item:read",
])
assert not read_item_policy.evaluate([])

These small cases are useful because they reveal what the policy actually says.

The number of entries in a list does not tell the reader whether it means AND or OR. The expression must make that choice visible.

AND is useful when an operation combines responsibilities

Now consider a detailed view that combines item details with stock history.

Reading items alone should not authorize the history data. We need both capabilities for that combined view.

Define the history requirement, then combine it with the existing item-read policy:

stock_history = PermissionExpression.all([
    "user:catalog:stock-history:read",
])

item_with_history = read_item_policy.and_(stock_history)

assert item_with_history.evaluate([
    "user:catalog:item:edit",
    "user:catalog:stock-history:read",
])
assert not item_with_history.evaluate([
    "user:catalog:item:edit",
])
assert not item_with_history.evaluate([
    "user:catalog:stock-history:read",
])

The first example succeeds because editing satisfies the item-read branch and the history grant satisfies the other branch.

The other examples each satisfy only half of the operation.

This is a policy decision about the combined response. If the product instead allows a partial response without history, that is another endpoint behavior. We should not silently return restricted fields merely because the caller can read the item.

The expression gives us a way to state the requirement. It does not choose the product behavior.

Nest a policy only when the operation needs it

Suppose a reporting role has a broader grant that intentionally authorizes the complete detailed view.

We now have two accepted routes: the reporting grant, or the combination of item access and history access.

The grouping matters:

detailed_view = PermissionExpression.any([
    "user:catalog:report:read",
]).or_(item_with_history)

assert detailed_view.evaluate(["user:catalog:report:read"])
assert detailed_view.evaluate([
    "user:catalog:item:edit",
    "user:catalog:stock-history:read",
])
assert not detailed_view.evaluate(["user:catalog:item:edit"])
assert not detailed_view.evaluate([])

These examples continue from the earlier blocks.

The logical form is report OR (item AND history). We can test that expression without constructing a request or querying a database.

That is why the evaluator has a narrow job. It compares a requirement with supplied grants. It does not authenticate the caller or select the organization.

Complex expressions are available, but more nesting is not automatically better. If a policy becomes difficult to explain to another developer, it may reveal that the operation combines responsibilities that deserve a clearer boundary.

Permission boundaries should follow the work

Choosing which operation a permission describes was often harder than implementing the evaluator.

Items and variants showed why.

A database can model them as separate objects. To the person managing stock, editing an item and adjusting its variants can be one responsibility.

If we assign unrelated permissions mechanically for each table, a person may open an item editor and find that half the normal workflow is blocked.

We chose item permissions as the policy for related item-and-variant work where that is one capability. The teaching example follows that decision.

There is no rule that every child resource must share its parent's permission. The question is whether the product needs separate control. A separately managed financial record, for example, would need its own policy analysis even if it belongs to an item.

Sharing an operation grant also does not remove the parent relationship from the query:

async def find_variant(context, item_id, variant_id):
    return await variants.find(
        id=variant_id,
        item_id=item_id,
        organization_id=context.organization_id,
    )

The variant must belong to the requested item and organization. A valid item-edit grant cannot make an unrelated variant part of that relationship.

List and single-resource reads often use the same read capability too. The difference is in the resource query and response shape, not necessarily in the responsibility assigned to the user.

A list operation still filters every returned row to the allowed context. Applying the same permission does not mean the list can query every organization.

Follow the complete request and its stopping points

We can now return to the handler with a clear division of work.

The diagram follows token validation, context resolution, grant evaluation, and resource access. It also shows where a request stops.

A protected Inventory request moves through token validation, required context, grant lookup, permission evaluation, and an organization-scoped resource query. Invalid identity, unavailable decisions, denied grants, and missing resources stop the operation at their respective boundaries.

Each check supplies a different part of the decision. The handler runs only after its required access checks pass.

Consider four requests.

Ada sends a valid token for A and has an accepted read grant in A. Inventory uses A when it retrieves the item.

Bayo sends a valid token but tries an edit without a matching grant. The permission decision denies the operation with 403.

Example Sync sends a service token and a target organization. Inventory evaluates the service grant and target rules. Ada's user groups are not a substitute for that service grant.

A caller has an accepted grant but requests an item outside the resolved organization. The scoped lookup does not return that item. The operation uses its not-found behavior.

An unavailable authority source is different again. Failure to obtain a decision is not evidence that the caller has a grant. The protected operation must stop rather than proceed on that assumption.

These outcomes should remain distinguishable in application behavior and observation data. Otherwise, a developer investigating a denial cannot tell whether the identity failed, the grant was absent, or a dependency was unavailable.

Common access logic still has a limit

Sometimes users and services reach the same endpoint but require different business behavior. That distinction belongs in the handler or business service, using the verified context.

Middleware should not become the place where every Inventory rule lives.

I wanted it to remove repeated identity and access mechanics so that endpoint code could concentrate on the operation. The developer still defines the policy, uses the correct resource boundaries, and handles any caller-specific business behavior.

That gives us the shared endpoint from Part 1 in a practical form: different caller rules, a prepared context, and one resource implementation where the operation is the same.

We now have another reuse problem. The system uses several languages and frameworks. Copying these rules into each integration would recreate the repetition at a different level.

Part 6 (not yet linked) follows one permission rule across those boundaries and explains which parts we share, generate, and adapt.

Continue the conversation

Comments

Write a comment

Checking this browser…

Use 1 to 80 characters. We save this name with your verified browser session. To choose another name, select Forget this browser.

Plain text only. Maximum 500 characters. Comments cannot be edited. 0/500

Waiting for the security check…

No comments yet. You can start the conversation.