DEV Community

allowfy
allowfy

Posted on Fully Autonomous

A valid agent token isn't permission to cancel every order

An agent can have a valid token and still ask to change the wrong order.

Imagine Alice authorizes an agent to manage her orders. The agent sends a cancellation request, but the order ID belongs to Bob. A token signature check can succeed. So can a proof-of-possession check. Neither makes Bob's order Alice's.

This is a useful boundary to test before connecting an agent to a write endpoint.

Discovery, grants, and policy answer different questions

In the Personal Agent Protocol, or Poppy, /.well-known/poppy.json tells an agent how to connect. Poppy also covers sign-in, sessions, scopes, and interfaces; it is not just that discovery file. The specification is currently a draft.

OAuth grants scoped access. The application still has to enforce what that grant means for the requested resource and action.

DPoP addresses a different problem: constraining token presentation to a client that can prove possession of the corresponding private key. It does not decide whether that client may cancel this particular order. Standard DPoP also does not sign the request body; see RFC 9449, section 11.7.

A request can therefore pass the protocol checks and fail the application's authorization policy. That is expected, not a protocol failure.

Make one policy small enough to test

For this example, the rule is deliberately narrow: a customer may cancel only their own pending orders, inside their tenant, with write access.

The inputs below are trusted server-side objects. Principal comes from verified authentication/session middleware and a server-side mapping to the customer and tenant. Order comes from the database, not ownership fields supplied in the request body. Constructing these dataclasses from arbitrary client JSON would defeat the example.

poppy:write is a Draft 0.2 scope. The code is a policy example, not a Poppy implementation or conformance test.

Save this as policy_example.py and run it with Python 3.10 or later:

"""Educational policy example, not production authentication code."""
from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Principal:
    tenant_id: str
    customer_id: str
    scopes: frozenset[str]


@dataclass(frozen=True)
class Order:
    order_id: str
    tenant_id: str
    customer_id: str
    status: str


def can_cancel(p: Principal, order: Order) -> bool:
    # Inputs come from trusted authentication and server-side order loading.
    return (
        "poppy:write" in p.scopes
        and p.tenant_id == order.tenant_id
        and p.customer_id == order.customer_id
        and order.status == "pending"
    )


if __name__ == "__main__":
    writer = Principal("shop-a", "alice", frozenset({"poppy:write"}))
    own_order = Order("order-42", "shop-a", "alice", "pending")
    cases = [
        ("owner with write access", writer, own_order, True),
        ("read-only grant", replace(writer, scopes=frozenset({"poppy:read"})), own_order, False),
        ("no grant", replace(writer, scopes=frozenset()), own_order, False),
        ("another customer's order", writer, replace(own_order, customer_id="bob"), False),
        ("another tenant, same customer ID", writer, replace(own_order, tenant_id="shop-b"), False),
        ("already shipped", writer, replace(own_order, status="shipped"), False),
        ("already cancelled", writer, replace(own_order, status="cancelled"), False),
        ("unknown state", writer, replace(own_order, status="unrecognized"), False),
    ]
    for label, principal, order, expected in cases:
        actual = can_cancel(principal, order)
        if actual is not expected:
            raise AssertionError(f"{label}: expected {expected}, got {actual}")
    print(f"{len(cases)} policy checks passed")
Enter fullscreen mode Exit fullscreen mode

The output is 8 policy checks passed. Notice the cross-tenant case deliberately keeps the customer ID unchanged. An ID that matches inside one tenant must not accidentally identify the same customer in another.

The owner comparison is appropriate only for this example's owner-only policy. Shared accounts, staff roles, and delegated access need their own relationship checks. This is not a universal fix for object-level authorization.

What these checks do not prove

These are local policy tests, not HTTP integration tests. They do not validate tokens, issuer/audience, expiry or revocation, DPoP proofs, replay handling, or TLS. Those controls belong upstream and need separate tests.

A boolean decision is also not a safe execution transaction. Recheck mutable authorization and order state at the commit boundary, using an appropriate transaction or conditional write. If your policy requires approval for a specific action, validate a server-held approval bound to the principal/client, tenant, order, action, and current revision. A request field like approved: true is not that approval.

For a real endpoint, repeat the negative cases through the HTTP layer: change the order ID, switch the tenant context, use read-only credentials, and retry after the order changes state. OWASP API1:2023 explains why authorization must check the requested action on the requested object.

The useful question is not only β€œIs this token valid?” It is β€œDoes this principal have permission to perform this action on this resource, in its current state?”


Disclosure: Published from the Allowfy team account. This article was generated using AI tools and checked against the linked technical references. The Python sample was executed locally. It is an educational example, not a description of Allowfy's implementation.

Top comments (0)