Skip to content
For engineers and architects
Agentic Apps
Authorization
Audit

One operation, five doors

The same business action reaches your system through a UI, a chat assistant, an API, an MCP tool, and a nightly schedule. If each door grows its own permission check, policy drifts. Define the operation once, compile it, and admit every caller the same way.

Eric Tarnowski
September 9, 2026
8 min read

Your project tracker has a "move to stage" action. A manager drags a card. A colleague types "move Atlas to Done" into the assistant. A partner system POSTs to /projects/{id}/stage. An agent on another team calls the MCP tool. A nightly job promotes anything idle for 14 days. Five callers, one business action.

Now count the places the permission check lives.

Why doors multiply

Each surface was built in a different sprint by a different person. The UI route checks the session's role. The API checks a scope claim in the JWT. The MCP server has an allowlist in a config file. The agent's tool has a decorator. The cron job runs as a service account that was granted admin in 2024 because it was faster.

You now have five permission checks, five audit shapes, and five places a bug can live. When the PMO decides that transitions to Done require the project manager's approval, someone fixes the UI and the API, forgets the cron, and the next morning 31 projects are Done.

That is door drift, and the market has measured its consequences. Salesforce's 2026 Connectivity Benchmark of 1,050 IT leaders found an average of 12 agents per enterprise, with 50 percent operating "in isolation, no data sharing, no coordination, no handoffs between systems" (Salesforce, via Enterprise DNA, April 2026). Okta's Global CISO Insights 2026 found 47 percent of companies could say they knew all the AI agents on their network and 46 percent controlled those agents' access to corporate data (Cybersecurity Dive, July 30, 2026). Retool, launching governance for AI-coded apps, said plainly that such apps lack "a comprehensive data permission model, a vetted and secure resource access layer, or detailed audit logs," and that "the governance risk lands entirely on the enterprise" (Retool, June 17, 2026).

Forrester's prescription is the right one: "a policy document can't control an autonomous, tool-invoking system," so identity and policy have to be "enforced as code rather than written down," and every agent should be treated as "a governed identity" with unique credentials and least privilege (Forrester, June 3, 2026). The question for an architect is what "enforced as code" looks like when the same action has five entry points.

Logical identity versus transport identity

The root mistake is letting a business operation acquire a different policy identity because of how it was called. project.stage.transition is one thing. HTTP, MCP, a UI command, an agent's tool call, and a schedule are transports. When policy is attached to the transport, you get five policies. When it is attached to the operation, you get one.

That sounds obvious. It is hard because each transport has real, native semantics you should not flatten. An HTTP route returns synchronously. An MCP tool is discovered by a model. A workflow target may run for days. A UI command has a page behind it. The design goal is a shared policy identity without a universal runtime that erases those differences.

The design

Here is the shape that has held up for us, stated without reference to any product.

Define the operation once. A logical operation declares its capability, its effect class (read, domain write, external write), its approval posture, its scope behavior, its input and output contracts, and its idempotency posture. Then bind it to native surfaces: a read or write route, an MCP tool, a direct host, an agent target, a workflow target, a schedule, an event ingress. The bindings are the doors. The operation is the thing behind all of them.

Compile the application into an immutable version. Take the strict source, resolve every referenced agent, workflow, tool source, and extension manifest to an exact identity or content digest, derive the operation catalog and bindings, and compute a content digest over the authority-bearing fields. Then check coverage: every exported native surface must map to a declared operation. A route cannot remain an unclassified back door because its name resembles a governed one. Activation moves one pointer to that version. Rollback moves it back.

Admit once, before dispatch. Every call to an exported target passes one fail-closed sequence. Authenticate the caller and preserve the whole actor chain, both who initiated the call and what kind of thing made the immediate request. Resolve the application, its active version, the caller's access profile, the logical target, the native binding, the exact resource references, the scope, and the continuation posture (does this call start new work, join an exact registered wait, or carry nothing). Only when all of those agree do you claim an idempotency identity, persist the execution record, write the audit evidence, and dispatch through the native host. Audit is written before anything runs, so a crash after dispatch leaves a record that says "left the platform, outcome unknown" rather than nothing.

Narrow children. A root call resolves against the current active version. A child call, say an agent's tool call inside a chat turn, must stay inside its parent's pinned authority and also pass live authorization. A child can never exceed its parent, and a policy change made after the parent started can still deny a later child.

Fail closed, and never fall back. Missing application state, an inactive version, an unknown target, an invalid scope, an inconsistent manifest, an uncertain authorization answer, an invalid continuation: each is a denial. During migration, do not write "legacy authorization OR new authorization." That preserves the weaker path forever. Require both until the new path is authoritative.

Do not disclose. An unauthorized resource is absent from listings and resolves as not-found. A refusal is recorded in the audit trail; the caller learns nothing about what exists.

Do not trust the page. A browser saying "the user is looking at project ATLAS" is a hint. Authority comes from the server, which resolves what is visible, signs a short-lived snapshot, and rechecks live authorization when a target uses it.

How Pyrana does it

An Agentic App is authored as strict configuration and compiled into an immutable, content-digested version. Activation moves one revisioned pointer, guarded by revision fences so two administrators cannot silently overwrite each other; rollback moves it back, bounded by evidence leases so a version whose dependents have moved on cannot be reactivated blindly. That lifecycle is live. So are access profiles and correlated scopes, OpenFGA relationship checks, row-level tenant isolation, and the extension contract. Per-invocation admission with a unified AppExecution record is built and enabled per App; an installation moves through legacy, shadow, dual, and App-enforced states, and dual means both must allow, never either.

Here is an excerpt of a compiled version, illustrative.

# illustrative: excerpt of compiled App version it-pm@sha256:1220a7ff…f843
operations:
  project.stage.transition:
    capability: project.write
    effect_class: domain_write
    approval: policy:stage.transition.to_done   # PM lane when target stage is Done
    scope: project
    idempotency: required
    bindings:
      ui:       POST /bff/projects/{id}/stage
      mcp:      project_change_stage
      http:     POST /api/v1/projects/{id}/stage
      schedule: nightly-stale-promotion

Now the five callers, one afternoon.

A project manager, user:pm.sato, signed in through Entra ID, drags ATLAS to Done. The actor chain is one link. Admission resolves her profile, "PM in Portfolio North," the exact resource project:ATLAS, the UI binding, and the operation. The Done transition requires the PM lane; she is the PM; the obligation is satisfied by the caller and the call dispatches.

A colleague, user:j.lee, asks the App's assistant to move ATLAS to Done. The chain is user:j.lee -> agent:pm-assistant@v4. The agent's tool call re-enters admission as a child. j.lee holds a viewer profile on ATLAS. The operation is absent from what the agent may do on that resource, so the assistant reports that it cannot make the change. The record shows a refused admission under j.lee's identity. The agent never held authority of its own to borrow.

A partner integration, service:jira-bridge, POSTs to the HTTP route with OAuth client credentials scoped to a project set. Same operation, same policy. It passes for the projects in its scope and sees nothing else.

An agent on the release team calls the MCP tool while acting for user:a.ruiz under a delegated token. The chain is user:a.ruiz -> agent:release-bot. The MCP door does not get a different policy because a model discovered it.

At 02:00 the nightly workflow, its own principal workflow:stale-promotion@v2, starts with continuation posture NEW and tries to promote an idle project to Done. The approval posture on that transition names the PM lane. The invocation freezes and waits. The nightly workflow waited for a person, which is exactly what the PMO asked for.

# illustrative: five AppExecution rows, one operation
exec_01J9R2A0  op=project.stage.transition  app=it-pm@1220a7ff  actor=user:pm.sato                        via=ui        result=applied
exec_01J9R2A7  op=project.stage.transition  app=it-pm@1220a7ff  actor=user:j.lee->agent:pm-assistant@v4   via=agent     result=refused
exec_01J9R2B3  op=project.stage.transition  app=it-pm@1220a7ff  actor=service:jira-bridge                  via=http      result=applied
exec_01J9R2C1  op=project.stage.transition  app=it-pm@1220a7ff  actor=user:a.ruiz->agent:release-bot       via=mcp       result=applied
exec_01J9R2D8  op=project.stage.transition  app=it-pm@1220a7ff  actor=workflow:stale-promotion@v2          via=schedule  result=awaiting_approval

Same operation, same App version, same policy, five real identities. If an auditor asks who moved ATLAS, the answer is a row.

The extension case

The project graph in that App does not live in Pyrana. It lives in the client's own extension, a sidecar in the client's repository with its own database and its own stage-transition invariants. Two client extensions run in production this way today.

The extension publishes a content-digested manifest declaring its routes, MCP tools, required capabilities, effect types, and outbound invocations. The compiler pins that digest into the App version and checks that every route and tool maps to a declared operation. At runtime, admission issues a short-lived signed token whose claims bind tenant, actor chain, extension, capabilities, and guardrail scope. The sidecar verifies the token, then applies the stage transition inside the same database transaction as the project update.

It returns a typed result and typed effects. One of those can be a queued outbound invocation: the client wants the Done transition mirrored into Clover, so the sidecar asks for it.

# illustrative: effect returned by the it-pm sidecar
effects:
  - type: tool_source.invoke
    mode: queued
    invocation: clover.projects.sync
    arguments: { project_id: "ATLAS", stage: "done" }
    source_revision: 418
    idempotency_key: eff_3b9d…c07e

The sidecar holds no Clover credential. Pyrana validates the effect against the pinned manifest, policy, and idempotency posture, supplies the platform-held connection, performs the call, owns retry, and returns a signed callback correlated to the original effect. If Clover's answer is ambiguous, the effect goes to reconciliation rather than being re-emitted by the sidecar. The client's domain code stays native to its domain, and the platform keeps admission, credentials, retry, and audit.

Audit your own stack for door drift

Pick one business action that matters, and answer these before your next architecture review.

  • How many code paths can trigger it, and can you list them from configuration rather than from memory?
  • Is the permission check for each path the same function, or five functions that are supposed to agree?
  • Does an agent calling it carry the person's identity, its own identity, or a shared service account?
  • When a scheduled job calls it, who approved, and where is that written?
  • If you tightened the policy today, which paths would still run under the old rule tomorrow?
  • Can you produce the record of one call that was refused, with the caller's real identity on it?

The last one is the tell. If the refused call is not in the audit trail, the door was never really governed.

Sources

Continue the Conversation

Building production-grade agentic AI requires context, orchestration, and human-in-the-loop design. We'd love to show you how Pyrana delivers it end to end.

Request a Demo