Orchestrate

Agent permissions and SAP authorizations

Give an AI agent its own narrow permissions, combine them with the user's SAP authorizations, keep duties separated and test that every denial holds.

Updated Oct 8, 2026Foundational 9 minDeep 40 min
Foundational layer · 9 min read

The 60-second version

An AI agent that works in SAP needs permissions, just like a person. The question is whose.

There are two sets of permissions in play. The user has SAP roles: Ana may see sales orders in her sales organization. The agent should have its own limits: the order-exception agent may read orders and draft release requests, nothing more.

The safe rule is simple: the agent may do only what both the user and the agent are allowed to do. SAP's reference architecture calls this the intersection of user and agent permissions.

Three things follow:

  • Never give an agent a powerful shared login. It would let every user do what that login can do.
  • Keep duties apart. The agent that asks for a release must not also approve it.
  • Test the "no" cases. An agent's permissions are proven by what it is refused, not by what works in the demo.

Why it matters to the business

SAP authorizations encode decades of control design: who may see which customers, who may change prices, who may release a credit block. Auditors test these controls every year. An agent can quietly bypass all of them if it runs with the wrong identity.

Example. A team builds an agent for blocked sales orders. To move fast, the agent calls S/4HANA with one technical user that has wide access. In the pilot, everything works. Then a clerk in sales organization 1010 asks about an order in 1710, and gets the answer. SAP never knew who was asking. The clerk's own role would have refused it.

The risks are concrete:

  • Data exposure. Users see data their roles don't allow, through the agent.
  • Unauthorized change. OWASP lists excessive agency as a top risk for AI applications: too many tools, too many permissions, too little human approval.
  • Segregation of duties (SoD) failure. If one identity can both request and approve a credit release, the control your auditors rely on is gone.
  • No accountability. When the log says "technical user", nobody can say which person caused a change, or whether the agent acted on its own.

Getting this right early is cheap. Retrofitting identity into a live agent means redesigning its connections, roles and tests.

How SAP does it

As of October 2026, SAP's Architecture Center describes the target design for agent identity. Parts of it are still being released, so check the current status before you plan around it.

  • Agents get identities. SAP Cloud Identity Services stores agents next to people in its Identity Directory, each with a global user ID.
  • Two operating models. An agent either works in a user's context, or runs autonomously with its own technical identity, tokens and audit trail.
  • Intersection at runtime. SAP states that the effective permission is the intersection of the user's and the agent's permissions, enforced by the Agent Gateway at every hop.
  • No backend credentials for outside agents. External agents sign in through SAP Cloud Identity Services. The gateway, not the agent, holds the connection to the SAP system.
  • SAP's own roles still decide. The target application checks access with its existing authorization concept: roles in S/4HANA, business roles in SAP S/4HANA Cloud.
  • Not everything is live. SAP dates the policy enforcement point between agents and APIs, tools or MCP servers to the second half of 2026. Its Agentic AI page says two-way communication with third-party and self-hosted agents through the Agent Gateway is not yet supported.

For segregation of duties, SAP Cloud Identity Access Governance offers access analysis and SoD risk monitoring. Agent identities and their roles should go through the same analysis as people's. How Joule applies the user's own authorizations is covered in Grounding on SAP data with authorizations.

Three ways an agent can hold permissions: a decision guide

Pattern Who SAP sees Use it when Main risk
On behalf of the user (delegated) The user, with the agent named alongside A person asks the agent for help in their own work Users with broad roles give the agent broad reach, unless the agent has its own limits too
Own identity (autonomous) The agent's own technical identity Scheduled or event-driven work with no person in the loop, like a nightly check of blocked orders The agent's role becomes a standing privilege; it needs tight scope and an owner
Shared technical user One login for every user and agent Never for user-facing work; at most a sandbox prototype on made-up data Every user inherits its rights; SoD and audit break

A good default for an assistant: delegated, with the agent's own limits on top. For background agents: own identity, read-mostly, and changes routed to a person.

Questions to ask

  1. Which identity does the agent use when it calls SAP: the user's, its own, or a shared login?
  2. What is the agent's own permission set, written down? Which tools, which SAP APIs, which organizational units?
  3. Is the effective permission the intersection of the user's and the agent's? Where in the design is that enforced?
  4. Can the agent change anything in SAP without a person approving it? Show me where approval is enforced in code.
  5. Has the agent's role been through our SoD analysis, like any other role?
  6. Who owns the agent's identity? Who reviews its access, and how often?
  7. In the logs, can we tell which person asked, which agent acted, and what SAP decided?
  8. What is our test that proves a clerk in one sales organization can't see another's orders through the agent?
  9. Which parts of SAP's agent identity design are generally available today, and which are roadmap?

Common misconceptions

  • "The agent only does what the user asks, so it needs no limits of its own." A prompt injection can make the agent ask for things the user never asked. Its own limits cap the damage.
  • "A technical user is fine if we filter in our code." Then your code re-implements SAP's authorization checks, and any bug leaks data. Prefer SAP checking the real user.
  • "Read-only is always safe." Reading data the user may not see is a breach. Read scope matters as much as write scope.
  • "The model knows it isn't allowed to approve." A model's judgment is not a control. OWASP calls for complete mediation: the downstream system checks every request.
  • "SoD is a finance topic, not an AI topic." An agent with the wrong role is a new identity with a conflict. It belongs in the same analysis.
  • "If the demo works, the permissions work." Demos test what is allowed. Permission tests check what is refused.

Key terms

  • Authorization: the right to perform an activity on certain data, such as displaying orders in one sales organization.
  • Least privilege: giving each identity only the permissions its job needs.
  • Delegated access: an agent acts for a user, and both are known to the systems it calls.
  • Technical user: a non-personal login used by a system or service. In SAP S/4HANA Cloud, a communication user plays this role for integrations.
  • Intersection of permissions: the agent may do only what both the user and the agent are allowed to do.
  • Segregation of duties (SoD): splitting critical steps, such as requesting and approving, between different people.
  • Excessive agency: OWASP's term for an AI system with too many tools, permissions or autonomy.
  • Complete mediation: checking every request in the system that owns the data, not trusting the caller.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 1An agent helps Ana, an order clerk in one sales organization. What should decide what the agent can see in SAP?

    Answer: C. SAP's architecture describes the effective permission as the intersection of user and agent permissions. Ana's roles stop her seeing other sales organizations; the agent's own limits stop it doing more than its job, even if Ana could.
  2. 2A team proposes one technical user with wide access for its assistant, plus filtering in code. What is the main business risk?

    Answer: B. SAP only sees the technical user, so its own checks can't tell users apart. Any bug in the filter code shows data that the user's role would have refused, and the audit log can't say who asked.
  3. 3A nightly agent checks all blocked orders with no person involved. Which identity pattern fits?

    Answer: D. SAP describes autonomous agents with their own technical identity, tokens and audit trail. Its role should be tight and have an owner, because there is no user to intersect with.
  4. 4The agent can draft a credit release request. Who should be able to approve it?

    Answer: C. Requesting and approving are separated so no single identity can complete a critical step alone. An agent that approves its own drafts, or a requester who approves their own, breaks segregation of duties.
  5. 5What is the strongest evidence that an agent's permissions are right?

    Answer: B. Permissions are proven by what is denied. A system prompt or the model's judgment is not a control; OWASP calls for the downstream system to check every request.
  6. 6Your partner says SAP's Agent Gateway will enforce all agent policies. What should you ask?

    Answer: D. SAP's architecture pages date the policy enforcement point for APIs, tools and MCP to H2 2026, and say third-party and self-hosted agents can't yet communicate through the gateway. Plan with what is available now.
Deep layer · 40 min read

Mental model: two keys on every door

Think of each SAP action as a door with two locks. The user's key opens doors their SAP roles allow. The agent's key opens doors its own role allows. A request goes through only if both keys turn.

flowchart LR
  R[Agent asks:<br/>read order 4801] --> T{Token valid,<br/>for this gateway?}
  T -- no --> X1[401: rejected]
  T -- yes --> U{User's roles<br/>allow it?}
  U -- no --> D[Denied + trace]
  U -- yes --> A{Agent's role<br/>allows it?}
  A -- no --> D
  A -- yes --> S[Call SAP]

The user's key stops the agent from becoming a way around SAP's authorizations. The agent's key stops a hijacked or confused agent from using everything the user can. SAP's Architecture Center states the same rule as the intersection of user and agent permissions. OWASP's version is complete mediation: the system that owns the data checks every request, whatever the model decided.

Everything in this topic is about building, granting and testing those two keys.

How it works

SAP's lock: authorization objects and roles

In ABAP-based SAP systems, a permission is checked against an authorization object. SAP Learning describes an object as a group of up to ten fields that are checked together. New objects include ACTVT, the activity, by default, with the values 01 create, 02 change, 03 read and 06 delete. Other fields hold organizational values such as a company code or sales organization.

An authorization is a set of allowed values for an object's fields. Roles bundle authorizations, and users get roles. At runtime, ABAP code runs AUTHORITY-CHECK for an object with the values the action needs. The check passes only if one authorization for that object allows all the values. For reads, SAP Learning recommends CDS access controls as the first choice; they filter what a user gets back.

What this means for an agent:

  • The check happens inside SAP, against the identity SAP sees. If SAP sees a technical user, it checks the technical user's roles. The person behind the request is invisible.
  • Organizational fields are your scope. An order clerk's role is usually limited by organizational values. That limit is what a shared login throws away.
  • Activities separate read from change. An agent that needs to read should hold 03, not 01 or 02.

Object names and field values differ by application and system. Ask your security team which objects a given API checks, or find out with the trace tools below.

SAP S/4HANA Cloud: business roles and restrictions

In SAP S/4HANA Cloud Public Edition, administrators work with business roles built from business catalogs. Each catalog has restriction types, and each field gets an access category: Write, Read or Value Help. A field can be restricted to specific values or unrestricted. SAP Learning recommends restricting, and using unrestricted only in rare cases.

For system-to-system access, S/4HANA Cloud uses communication arrangements. An arrangement links a communication scenario to a communication system (the caller) and a communication user (its technical login). SAP's tutorial for the Business Partner API creates an arrangement from scenario SAP_COM_0008; the scenario contains the API, and the arrangement gives the communication user its authorization. In other words, a technical integration gets the rights of a whole scenario. That is right for an integration, and too much for an agent acting for one clerk.

The agent's own key

The agent needs its own permission set, decided before any code:

Decide Example for the order-exception agent
Tools sales_order_get, release_request_draft; no approve tool
SAP activities Read sales orders; create release requests in the agent's own store only
Organizational scope All sales organizations it serves, intersected with the user's
Operating model Delegated for assistant use; a separate read-only identity for the nightly run
Owner A named person who reviews this access every quarter

OWASP's excessive agency guidance maps directly onto this table. Excessive functionality is too many tools. Excessive permissions is a downstream identity with more rights than the tools need. Excessive autonomy is changes without approval. Its remedies are the same: minimize tools and permissions, execute in the user's context with minimum scope, require approval for high-impact actions, and enforce authorization downstream.

Carrying both identities: delegated tokens

The user signs in to your assistant and gets a token for the assistant. The agent should not forward that token to the tools. It should exchange it for a new one that:

  • names the user as subject (sub),
  • names the agent as the actor (act),
  • targets only the tool gateway (aud),
  • carries only the tools this step needs (scope),
  • expires in minutes (exp).

This is the OAuth 2.0 Token Exchange pattern from RFC 8693. A client sends a subject_token (the user's) and, optionally, an actor_token (the agent's), with the grant type urn:ietf:params:oauth:grant-type:token-exchange. RFC 8693 separates two modes. In impersonation, the new token makes the actor indistinguishable from the user. In delegation, the actor keeps its own identity, recorded in the act claim:

{ "sub": "ana", "act": { "sub": "order-exception-agent" }, "aud": "https://gateway.lab/tools" }

Delegation is what you want for agents: every downstream check and log line can see both the person and the agent. RFC 8693 also defines may_act, which states who is allowed to act for a subject.

Audience: no passthrough

A token issued for one service must not be accepted by another. The MCP specification of 2026-07-28 makes this explicit for MCP servers: they must validate that a token was issued for them as the audience, and must not accept or pass on any other tokens. MCP clients must name the target server in a resource parameter when they request a token. When a token lacks a scope, the server answers 403 with insufficient_scope, and the client can request more through a step-up flow.

The reason is the confused deputy: a service holding a token meant for someone else can be tricked into using it. If your agent forwards the user's sign-in token to a tool server, and that server forwards it to SAP, nobody in the chain checked whether the token was meant for them. MCP authorization itself is optional in the specification; for anything that reaches SAP data, treat it as required. The protocol is covered in The Model Context Protocol (MCP).

Segregation of duties for agents

SoD rules list pairs of actions that one identity must not hold together, such as requesting and approving a release. Two checks apply:

  • Design time: analyze every role, including the agent's, for conflicting pairs. SAP Cloud Identity Access Governance provides access analysis and SoD risk monitoring for this.
  • Run time: even when a person legitimately holds both rights, the approval step refuses an approval by the person who made the request.

An agent should never hold the approving side. The approval is a separate step, run by a person, that no agent token can reach. SAP tools for agents built the draft tool; this topic adds who may approve it.

Seeing why a check failed

When an agent is refused, someone has to find out why. In ABAP systems, SAP Learning describes two tools:

  • SU53 shows recent failed authorization checks for a user: the object, the values the program needed, and the values the user holds.
  • STAUTHTRACE records every authorization check while you reproduce the problem, with the object, fields, values and return code.

SAP Learning adds a warning that applies doubly to agents: don't turn a trace straight into a new role. The trace shows what the agent tried; that may include things it should never do. Your own gateway should keep the same kind of record: which principal failed, on which check, needing what. The lab's trace command does that.

Build it yourself: a two-key permission lab

You will build unit11/agent_permissions.py, a local model of how an agent's permissions combine with a user's. It has SAP-style authorization objects and roles, an agent with its own role, delegated tokens that name both the user and the agent, an approval step with a segregation-of-duties check, an SU53-style trace, and a permission test matrix you can run after every change.

Before you start: complete Set up your computer for this course and Set up for Unit 11. This lab uses only modules that come with Python, plus python-dotenv from the course setup if you have it.

flowchart LR
  U[User signs in] --> E[Token exchange:<br/>user + agent]
  E --> G[Gateway: audience,<br/>scope, arguments]
  G --> C1[Check user's role]
  C1 --> C2[Check agent's role]
  C2 --> R[Read or draft]
  R --> P[Person approves<br/>with SoD check]
  C1 -. denied .-> T[(Trace)]
  C2 -. denied .-> T

What you need

  • Your course folder orchestrate-course with its .venv.
  • About 40 minutes.
  • Cost: free. No account, no key, no model call, no SAP system. Everything runs on your computer.

Step 1: Open your course folder and turn on the virtual environment

  1. Open VS Code, choose File > Open Folder, and open orchestrate-course.

  2. Open a terminal: Terminal > New Terminal.

  3. If the prompt doesn't start with (.venv), turn it on:

    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate
  4. Check Python:

    python --version

    You should see Python 3.10 or newer. On macOS or Linux without .venv, use python3 in every command.

Run every command in this topic from the course folder, not from inside unit11.

Step 2: Set the token secret and keep lab files out of Git

The lab signs its tokens with a secret. Secrets go in .env, never in code.

  1. Open .env in your course folder (create it if it doesn't exist).

  2. Add this line, replacing the value with any long random text you make up, and save:

    LAB_TOKEN_SECRET=replace-with-a-long-random-text-you-invent

    If you skip this step, the lab uses a built-in placeholder secret. That still works for learning.

  3. Open .gitignore and add these two lines at the end, then save:

    unit11/auth_trace.jsonl
    unit11/perm_drafts.json

    The trace and drafts name users and orders. In a real system they would be audit data, not code.

Step 3: Create the lab

  1. In VS Code's file list, right-click unit11, choose New File, and name it agent_permissions.py.
  2. Paste the code below and save.
"""Unit 11: Agent permissions and SAP authorizations.

A local lab that models how an agent's permissions combine with the user's: SAP-style
authorization objects and roles, an agent with its own role, a delegated token that names
both the user and the agent, a segregation-of-duties check, and an SU53-style trace of
every failed check. Built-in Python modules only. Everything is made up.

    python unit11/agent_permissions.py roles                      roles per principal + SoD analysis
    python unit11/agent_permissions.py call sales_order_get order=4711 --user ana
    python unit11/agent_permissions.py call sales_order_get order=4801 --user ana --mode technical
    python unit11/agent_permissions.py approve D-0001 --user lee  a person approves a draft
    python unit11/agent_permissions.py tokens                     token exchange and rejected tokens
    python unit11/agent_permissions.py matrix                     the permission test suite
    python unit11/agent_permissions.py trace                      failed checks, like SU53
"""
import argparse
import base64
import hashlib
import hmac
import json
import os
import sys
import time
from pathlib import Path

try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:          # the lab still runs without python-dotenv
    pass

HERE = Path(__file__).resolve().parent
TRACE_FILE = HERE / "auth_trace.jsonl"
DRAFTS_FILE = HERE / "perm_drafts.json"

# --- 1. Authorization objects and roles (made up; real objects and values differ per system) ---
# An authorization object groups fields that are checked together. ACTVT is the activity:
# 01 create, 02 change, 03 display.
OBJECTS = {
    "ZLAB_SO": "Sales order (lab)",
    "ZLAB_RQ": "Release request (lab)",
    "ZLAB_AP": "Release approval (lab)",
}

ROLES = {
    "Z_ORDER_CLERK_1010": [("ZLAB_SO", {"SALES_ORG": ["1010"], "ACTVT": ["03"]}),
                           ("ZLAB_RQ", {"SALES_ORG": ["1010"], "ACTVT": ["01"]})],
    "Z_ORDER_CLERK_1710": [("ZLAB_SO", {"SALES_ORG": ["1710"], "ACTVT": ["03"]}),
                           ("ZLAB_RQ", {"SALES_ORG": ["1710"], "ACTVT": ["01"]})],
    "Z_AUDITOR_1010":     [("ZLAB_SO", {"SALES_ORG": ["1010"], "ACTVT": ["03"]})],
    "Z_CREDIT_APPROVER":  [("ZLAB_SO", {"SALES_ORG": ["*"], "ACTVT": ["03"]}),
                           ("ZLAB_AP", {"SALES_ORG": ["*"], "ACTVT": ["02"]})],
    # The agent's own envelope: it may read and draft in any sales org, never approve.
    "Z_AGENT_ORDER_EXC":  [("ZLAB_SO", {"SALES_ORG": ["*"], "ACTVT": ["03"]}),
                           ("ZLAB_RQ", {"SALES_ORG": ["*"], "ACTVT": ["01"]})],
    # An autonomous agent with its own identity: read only, two sales orgs.
    "Z_AGENT_NIGHT_READ": [("ZLAB_SO", {"SALES_ORG": ["1010", "1710"], "ACTVT": ["03"]})],
    # The anti-pattern: one technical user that can do everything.
    "Z_TECH_ALL":         [("ZLAB_SO", {"SALES_ORG": ["*"], "ACTVT": ["*"]}),
                           ("ZLAB_RQ", {"SALES_ORG": ["*"], "ACTVT": ["*"]}),
                           ("ZLAB_AP", {"SALES_ORG": ["*"], "ACTVT": ["*"]})],
}

PRINCIPALS = {
    # people
    "ana": {"kind": "user", "name": "Ana, order clerk 1010", "roles": ["Z_ORDER_CLERK_1010"]},
    "ben": {"kind": "user", "name": "Ben, order clerk 1710", "roles": ["Z_ORDER_CLERK_1710"]},
    "kim": {"kind": "user", "name": "Kim, auditor 1010", "roles": ["Z_AUDITOR_1010"]},
    "lee": {"kind": "user", "name": "Lee, credit approver", "roles": ["Z_CREDIT_APPROVER"]},
    # agents
    "order-exception-agent": {"kind": "agent", "name": "Order-exception agent",
                              "roles": ["Z_AGENT_ORDER_EXC"],
                              "tools": ["sales_order_get", "release_request_draft"]},
    "night-batch-agent": {"kind": "agent", "name": "Night batch agent (autonomous)",
                          "roles": ["Z_AGENT_NIGHT_READ"], "tools": ["sales_order_get"]},
    # technical user
    "tech-user": {"kind": "technical", "name": "Shared technical user", "roles": ["Z_TECH_ALL"]},
}

# Segregation of duties: no single principal may hold both sides of a rule.
SOD_RULES = [
    {"id": "SOD-01", "text": "Request a release and approve releases",
     "a": ("ZLAB_RQ", "01"), "b": ("ZLAB_AP", "02")},
]

ORDERS = {
    "4711": {"SalesOrganization": "1010", "SoldToParty": "10100001", "Block": "credit"},
    "4712": {"SalesOrganization": "1010", "SoldToParty": "10100002", "Block": ""},
    "4801": {"SalesOrganization": "1710", "SoldToParty": "17100001", "Block": "credit"},
}


def value_ok(allowed, value):
    return "*" in allowed or value in allowed


def authority_check(principal_id, obj, needed):
    """Like ABAP's AUTHORITY-CHECK: pass if ONE authorization for the object matches ALL fields."""
    for role in PRINCIPALS[principal_id]["roles"]:
        for auth_obj, fields in ROLES[role]:
            if auth_obj == obj and all(value_ok(fields.get(f, []), v) for f, v in needed.items()):
                return True, None
    held = [fields for role in PRINCIPALS[principal_id]["roles"]
            for auth_obj, fields in ROLES[role] if auth_obj == obj]
    return False, {"principal": principal_id, "object": obj, "needed": needed, "held": held}


def write_trace(entry):
    entry["time"] = time.strftime("%Y-%m-%d %H:%M:%S")
    with TRACE_FILE.open("a", encoding="utf-8") as f:
        f.write(json.dumps(entry) + "\n")


# --- 2. Tokens: a JWT-shaped, HMAC-signed stand-in for what an identity service issues ---
SECRET = os.getenv("LAB_TOKEN_SECRET", "lab-only-secret-change-me").encode()
APP_AUDIENCE = "https://assistant.lab"           # the token the user's sign-in gives the app
GATEWAY_AUDIENCE = "https://gateway.lab/tools"   # the only audience the tool gateway accepts


def b64(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def mint(claims, ttl=300):
    claims = dict(claims, iat=int(time.time()), exp=int(time.time()) + ttl)
    head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64(json.dumps(claims).encode())
    sig = b64(hmac.new(SECRET, f"{head}.{body}".encode(), hashlib.sha256).digest())
    return f"{head}.{body}.{sig}"


def verify(token, audience):
    """Return the claims, or raise PermissionError with the reason."""
    try:
        head, body, sig = token.split(".")
    except ValueError:
        raise PermissionError("malformed token")
    good = b64(hmac.new(SECRET, f"{head}.{body}".encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(sig, good):
        raise PermissionError("bad signature")
    claims = json.loads(unb64(body))
    if claims.get("aud") != audience:
        raise PermissionError(f"wrong audience {claims.get('aud')!r}, expected {audience!r}")
    if claims.get("exp", 0) < time.time():
        raise PermissionError("token expired")
    return claims


def sign_in(user_id):
    """What the user's sign-in produces: a token for the assistant app, not for the tools."""
    return mint({"sub": user_id, "aud": APP_AUDIENCE, "scope": "assistant"}, ttl=3600)


def exchange(user_token, agent_id, tools):
    """Token exchange (the RFC 8693 pattern): user token in, short-lived delegated token out.
    The new token names the user (sub) and the agent (act), targets the gateway only,
    and carries only tools that the agent is registered for."""
    user = verify(user_token, APP_AUDIENCE)
    agent = PRINCIPALS.get(agent_id)
    if not agent or agent["kind"] != "agent":
        raise PermissionError(f"{agent_id!r} is not a registered agent")
    refused = [t for t in tools if t not in agent["tools"]]
    if refused:
        raise PermissionError(f"agent {agent_id} may not request: {', '.join(refused)}")
    return mint({"sub": user["sub"], "act": {"sub": agent_id}, "aud": GATEWAY_AUDIENCE,
                 "scope": " ".join(tools)}, ttl=300)


# --- 3. The tool gateway: every call is checked for every principal in the chain ---
TOOLS = {
    "sales_order_get": {"object": "ZLAB_SO", "actvt": "03", "args": ["order"]},
    "release_request_draft": {"object": "ZLAB_RQ", "actvt": "01", "args": ["order", "reason"]},
}


def load_drafts():
    return json.loads(DRAFTS_FILE.read_text(encoding="utf-8")) if DRAFTS_FILE.exists() else {}


def gateway(token, tool, args, mode):
    """Check the token, the tool, then the authorization of each principal in the chain."""
    claims = verify(token, GATEWAY_AUDIENCE)
    if tool not in claims["scope"].split():
        raise PermissionError(f"tool {tool!r} not in this token's scope")
    spec = TOOLS[tool]
    unknown = set(args) - set(spec["args"])
    if unknown or not all(a in args for a in spec["args"]):
        raise ValueError(f"{tool} needs exactly: {', '.join(spec['args'])}")
    if tool == "release_request_draft" and not 20 <= len(args["reason"]) <= 500:
        raise ValueError("reason must be 20 to 500 characters")
    order = ORDERS.get(args["order"])
    if order is None:
        raise LookupError(f"order {args['order']} does not exist or you may not see it")
    needed = {"SALES_ORG": order["SalesOrganization"], "ACTVT": spec["actvt"]}

    user, agent = claims["sub"], claims.get("act", {}).get("sub")
    if mode == "technical":
        chain = ["tech-user"]                 # SAP only ever sees the technical user
    elif mode == "agent":
        chain = [agent]                       # autonomous: the agent's own identity
    else:
        chain = [user, agent]                 # delegated: user AND agent must both pass
    for principal in chain:
        ok, failure = authority_check(principal, spec["object"], needed)
        if not ok:
            failure.update({"tool": tool, "mode": mode, "on_behalf_of": user})
            write_trace(failure)
            # Same answer as a missing order, so the agent can't probe other sales orgs.
            raise LookupError(f"order {args['order']} does not exist or you may not see it")

    if tool == "sales_order_get":
        return {"SalesOrder": args["order"], **order}
    drafts = load_drafts()
    key = f"{user}:{args['order']}"
    for d in drafts.values():
        if d["key"] == key and d["status"] == "open":
            return {"draft": d["id"], "duplicate": True, "changed_in_sap": False}
    draft_id = f"D-{len(drafts) + 1:04d}"
    drafts[draft_id] = {"id": draft_id, "key": key, "order": args["order"],
                        "sales_org": order["SalesOrganization"], "requested_by": user,
                        "drafted_by": agent, "reason": args["reason"], "status": "open"}
    DRAFTS_FILE.write_text(json.dumps(drafts, indent=2), encoding="utf-8")
    return {"draft": draft_id, "duplicate": False, "changed_in_sap": False}


def run_tool(user_id, agent_id, tool, args, mode):
    """The agent runtime: sign in, exchange for a delegated token, call the gateway."""
    if mode == "agent":                       # no user: the agent signs in as itself
        token = mint({"sub": f"(none: {agent_id} runs alone)", "act": {"sub": agent_id},
                      "aud": GATEWAY_AUDIENCE, "scope": " ".join(PRINCIPALS[agent_id]["tools"])})
    else:
        token = exchange(sign_in(user_id), agent_id, [tool])
    return gateway(token, tool, args, mode)


def approve(draft_id, approver):
    """The human approval step. Not a tool: no agent token can reach it."""
    drafts = load_drafts()
    draft = drafts.get(draft_id)
    if draft is None or draft["status"] != "open":
        raise LookupError(f"no open draft {draft_id}")
    if PRINCIPALS.get(approver, {}).get("kind") != "user":
        raise PermissionError("only a person can approve")
    if approver == draft["requested_by"]:
        write_trace({"principal": approver, "object": "SOD-01", "needed": "approver != requester",
                     "held": draft["requested_by"], "tool": "approve", "mode": "human",
                     "on_behalf_of": approver})
        raise PermissionError("segregation of duties: you requested this release")
    ok, failure = authority_check(approver, "ZLAB_AP", {"SALES_ORG": draft["sales_org"], "ACTVT": "02"})
    if not ok:
        failure.update({"tool": "approve", "mode": "human", "on_behalf_of": approver})
        write_trace(failure)
        raise PermissionError("you may not approve releases for this sales organization")
    draft.update(status="approved", approved_by=approver)
    DRAFTS_FILE.write_text(json.dumps(drafts, indent=2), encoding="utf-8")
    return {"draft": draft_id, "status": "approved", "next": "the release step calls SAP (sketch only)"}


# --- 4. Commands ---
def holds(principal_id, obj, actvt):
    return [fields["SALES_ORG"] for role in PRINCIPALS[principal_id]["roles"]
            for o, fields in ROLES[role] if o == obj and value_ok(fields["ACTVT"], actvt)]


def cmd_roles(_):
    w = max(len(",".join(p["roles"])) for p in PRINCIPALS.values()) + 2
    print(f"{'Principal':<23}{'Kind':<11}{'Roles':<{w}}Can do")
    for pid, p in PRINCIPALS.items():
        can = []
        for role in p["roles"]:
            for obj, fields in ROLES[role]:
                can.append(f"{obj} {'/'.join(fields['ACTVT'])} in {','.join(fields['SALES_ORG'])}")
        print(f"{pid:<23}{p['kind']:<11}{','.join(p['roles']):<{w}}{'; '.join(can)}")
    print("\nSegregation of duties")
    conflicts = 0
    for rule in SOD_RULES:
        for pid in PRINCIPALS:
            a, b = holds(pid, *rule["a"]), holds(pid, *rule["b"])
            if a and b:
                conflicts += 1
                print(f"  CONFLICT {rule['id']} ({rule['text']}): {pid}")
    print(f"  {conflicts} conflict(s) found")


def parse_args(pairs):
    args = {}
    for pair in pairs:
        if "=" not in pair:
            sys.exit(f"Write arguments as key=value, for example order=4711 (got {pair!r})")
        k, v = pair.split("=", 1)
        args[k] = v
    return args


def cmd_call(a):
    if a.tool not in TOOLS:
        sys.exit(f"Unknown tool {a.tool!r}. Tools: {', '.join(TOOLS)}")
    if a.user not in PRINCIPALS or PRINCIPALS[a.user]["kind"] != "user":
        sys.exit(f"Unknown user {a.user!r}. Users: ana, ben, kim, lee")
    agent = a.agent or ("night-batch-agent" if a.mode == "agent" else "order-exception-agent")
    who = f"{agent} alone" if a.mode == "agent" else f"{a.user} via {agent}"
    print(f"Mode: {a.mode} | {who}")
    try:
        print("Allowed:", json.dumps(run_tool(a.user, agent, a.tool, parse_args(a.args), a.mode)))
    except (PermissionError, LookupError, ValueError) as e:
        print("Denied:", e)


def cmd_approve(a):
    try:
        print("Allowed:", json.dumps(approve(a.draft, a.user)))
    except (PermissionError, LookupError) as e:
        print("Denied:", e)


def cmd_tokens(_):
    ana_token = sign_in("ana")
    print("1. Ana signs in. Her token is for the assistant app:")
    print("  ", json.loads(unb64(ana_token.split(".")[1])))
    delegated = exchange(ana_token, "order-exception-agent", ["sales_order_get"])
    print("2. The agent exchanges it for a delegated token (user in sub, agent in act):")
    print("  ", json.loads(unb64(delegated.split(".")[1])))
    tests = [
        ("3. Agent forwards Ana's sign-in token to the gateway (passthrough)",
         lambda: gateway(ana_token, "sales_order_get", {"order": "4711"}, "delegated")),
        ("4. Agent asks for a tool it isn't registered for",
         lambda: exchange(ana_token, "order-exception-agent", ["release_request_approve"])),
        ("5. Agent uses a read token to draft",
         lambda: gateway(delegated, "release_request_draft", {"order": "4711", "reason": "x" * 20}, "delegated")),
        ("6. Someone edits the token to say sub=lee",
         lambda: gateway(tamper(delegated, sub="lee"), "sales_order_get", {"order": "4711"}, "delegated")),
        ("7. A token that expired a minute ago",
         lambda: gateway(mint({"sub": "ana", "act": {"sub": "order-exception-agent"},
                               "aud": GATEWAY_AUDIENCE, "scope": "sales_order_get"}, ttl=-60),
                         "sales_order_get", {"order": "4711"}, "delegated")),
    ]
    for label, fn in tests:
        try:
            fn()
            print(f"{label}: ALLOWED (this is a bug)")
        except (PermissionError, LookupError, ValueError) as e:
            print(f"{label}: rejected, {e}")


def tamper(token, **changes):
    head, body, sig = token.split(".")
    claims = dict(json.loads(unb64(body)), **changes)
    return f"{head}.{b64(json.dumps(claims).encode())}.{sig}"


# Expected results in delegated mode: "allow" or "deny". The matrix also asks what the
# shared technical user would do with the same request.
REASON = {"reason": "Customer paid the overdue invoice today."}
AGENT, NIGHT = "order-exception-agent", "night-batch-agent"
MATRIX = [
    ("ana", AGENT, "sales_order_get", {"order": "4711"}, "allow"),
    ("ana", AGENT, "sales_order_get", {"order": "4801"}, "deny"),
    ("ben", AGENT, "sales_order_get", {"order": "4801"}, "allow"),
    ("ben", AGENT, "sales_order_get", {"order": "4711"}, "deny"),
    ("kim", AGENT, "sales_order_get", {"order": "4712"}, "allow"),
    ("kim", AGENT, "release_request_draft", {"order": "4711", **REASON}, "deny"),
    ("ana", AGENT, "release_request_draft", {"order": "4711", **REASON}, "allow"),
    ("ana", AGENT, "release_request_draft", {"order": "4801", **REASON}, "deny"),
    ("ana", NIGHT, "release_request_draft", {"order": "4711", **REASON}, "deny"),
    ("lee", AGENT, "release_request_draft", {"order": "4801", **REASON}, "deny"),
]


def outcome(user, agent, tool, args, mode):
    try:
        run_tool(user, agent, tool, args, mode)
        return "allow"
    except (PermissionError, LookupError, ValueError):
        return "deny"


def cmd_matrix(_):
    saved = DRAFTS_FILE.read_text(encoding="utf-8") if DRAFTS_FILE.exists() else None
    failures = leaks = 0
    print(f"{'User':<5}{'Agent':<23}{'Tool':<23}{'Order':<7}{'Expect':<8}{'Got':<7}{'Result':<8}Tech user")
    for user, agent, tool, args, expected in MATRIX:
        got = outcome(user, agent, tool, args, "delegated")
        tech = outcome(user, agent, tool, args, "technical")
        result = "PASS" if got == expected else "FAIL"
        failures += result == "FAIL"
        leak = tech == "allow" and expected == "deny"
        leaks += leak
        print(f"{user:<5}{agent:<23}{tool:<23}{args['order']:<7}{expected:<8}{got:<7}{result:<8}"
              f"{tech}{'  <- LEAK' if leak else ''}")
    # The matrix must not leave drafts behind.
    if saved is None:
        DRAFTS_FILE.unlink(missing_ok=True)
    else:
        DRAFTS_FILE.write_text(saved, encoding="utf-8")
    print(f"\n{len(MATRIX) - failures}/{len(MATRIX)} as expected in delegated mode. "
          f"The shared technical user would allow {leaks} of the denied requests.")
    sys.exit(1 if failures else 0)


def cmd_trace(a):
    if not TRACE_FILE.exists():
        print("No failed checks recorded yet. Run a call that is denied first.")
        return
    lines = TRACE_FILE.read_text(encoding="utf-8").splitlines()[-a.last:]
    for line in lines:
        t = json.loads(line)
        print(f"{t['time']}  {t['tool']} ({t['mode']}, for {t['on_behalf_of']})")
        print(f"   failed: {t['principal']} on {t['object']}  needed {t['needed']}")
        print(f"   held:   {t['held'] or 'no authorization for this object'}")


def main():
    p = argparse.ArgumentParser(description="Agent permissions lab (all data made up).")
    sub = p.add_subparsers(dest="command", required=True)
    sub.add_parser("roles").set_defaults(fn=cmd_roles)
    c = sub.add_parser("call")
    c.add_argument("tool")
    c.add_argument("args", nargs="*", help="key=value pairs, e.g. order=4711")
    c.add_argument("--user", default="ana")
    c.add_argument("--agent")
    c.add_argument("--mode", choices=["delegated", "agent", "technical"], default="delegated")
    c.set_defaults(fn=cmd_call)
    ap = sub.add_parser("approve")
    ap.add_argument("draft")
    ap.add_argument("--user", required=True)
    ap.set_defaults(fn=cmd_approve)
    sub.add_parser("tokens").set_defaults(fn=cmd_tokens)
    sub.add_parser("matrix").set_defaults(fn=cmd_matrix)
    t = sub.add_parser("trace")
    t.add_argument("--last", type=int, default=5)
    t.set_defaults(fn=cmd_trace)
    a = p.parse_args()
    a.fn(a)


if __name__ == "__main__":
    main()

Step 4: Look at the roles and the SoD analysis

  1. Run:

    python unit11/agent_permissions.py roles
  2. You should see:

    Principal              Kind       Roles               Can do
    ana                    user       Z_ORDER_CLERK_1010  ZLAB_SO 03 in 1010; ZLAB_RQ 01 in 1010
    ben                    user       Z_ORDER_CLERK_1710  ZLAB_SO 03 in 1710; ZLAB_RQ 01 in 1710
    kim                    user       Z_AUDITOR_1010      ZLAB_SO 03 in 1010
    lee                    user       Z_CREDIT_APPROVER   ZLAB_SO 03 in *; ZLAB_AP 02 in *
    order-exception-agent  agent      Z_AGENT_ORDER_EXC   ZLAB_SO 03 in *; ZLAB_RQ 01 in *
    night-batch-agent      agent      Z_AGENT_NIGHT_READ  ZLAB_SO 03 in 1010,1710
    tech-user              technical  Z_TECH_ALL          ZLAB_SO * in *; ZLAB_RQ * in *; ZLAB_AP * in *
    
    Segregation of duties
      CONFLICT SOD-01 (Request a release and approve releases): tech-user
      1 conflict(s) found

Read it like a security reviewer. The order-exception agent has a wide organizational scope (*), because it serves clerks in every sales organization, but a narrow functional scope: read orders, create requests, never approve. The user's role supplies the organizational limit at run time. The shared technical user holds everything, so the SoD analysis flags it: one login can both request and approve.

Step 5: Compare delegated access with a shared technical user

  1. Ask, as Ana, for an order in her own sales organization:

    python unit11/agent_permissions.py call sales_order_get order=4711 --user ana
    Mode: delegated | ana via order-exception-agent
    Allowed: {"SalesOrder": "4711", "SalesOrganization": "1010", "SoldToParty": "10100001", "Block": "credit"}
  2. Ask for an order in sales organization 1710:

    python unit11/agent_permissions.py call sales_order_get order=4801 --user ana
    Mode: delegated | ana via order-exception-agent
    Denied: order 4801 does not exist or you may not see it

    The agent may read 1710, but Ana may not. Both keys must turn. The message is the same one a missing order gets, so the agent can't be used to probe which order numbers exist.

  3. Now run the same request as the shared technical user:

    python unit11/agent_permissions.py call sales_order_get order=4801 --user ana --mode technical
    Mode: technical | ana via order-exception-agent
    Allowed: {"SalesOrder": "4801", "SalesOrganization": "1710", "SoldToParty": "17100001", "Block": "credit"}

    This is the leak from the business example. SAP only saw tech-user, so Ana's role never came into play.

Step 6: Run an agent on its own identity

  1. Run the night batch agent alone, with no user:

    python unit11/agent_permissions.py call sales_order_get order=4801 --mode agent
    python unit11/agent_permissions.py call release_request_draft order=4711 "reason=Customer paid the overdue invoice today." --mode agent
    Mode: agent | night-batch-agent alone
    Allowed: {"SalesOrder": "4801", "SalesOrganization": "1710", "SoldToParty": "17100001", "Block": "credit"}
    Mode: agent | night-batch-agent alone
    Denied: tool 'release_request_draft' not in this token's scope

    With no user, the agent's own role is the only key. That is why it must be narrow: read-only, two sales organizations.

  2. Now let Ana use the night agent to draft:

    python unit11/agent_permissions.py call release_request_draft order=4711 "reason=Customer paid the overdue invoice today." --user ana --agent night-batch-agent
    Mode: delegated | ana via night-batch-agent
    Denied: agent night-batch-agent may not request: release_request_draft

    Ana may create release requests, but this agent may not. The intersection works in both directions.

Step 7: Draft, then approve with segregation of duties

  1. Let the agent draft a release request for Ana, twice:

    python unit11/agent_permissions.py call release_request_draft order=4711 "reason=Customer paid the overdue invoice today." --user ana
    python unit11/agent_permissions.py call release_request_draft order=4711 "reason=Customer paid the overdue invoice today." --user ana
    Mode: delegated | ana via order-exception-agent
    Allowed: {"draft": "D-0001", "duplicate": false, "changed_in_sap": false}
    Mode: delegated | ana via order-exception-agent
    Allowed: {"draft": "D-0001", "duplicate": true, "changed_in_sap": false}

    The second call returns the same draft, as in SAP tools for agents. Nothing changed in SAP.

  2. Try to approve it as Ana, then as Kim, then as Lee:

    python unit11/agent_permissions.py approve D-0001 --user ana
    python unit11/agent_permissions.py approve D-0001 --user kim
    python unit11/agent_permissions.py approve D-0001 --user lee
    Denied: segregation of duties: you requested this release
    Denied: you may not approve releases for this sales organization
    Allowed: {"draft": "D-0001", "status": "approved", "next": "the release step calls SAP (sketch only)"}

    Ana requested it, so she can't approve it. Kim has no approval right. Lee, a different person with the right, can. Notice there is no --agent option on approve: it is not a tool, and no agent token can reach it.

To start again, delete unit11/perm_drafts.json.

Step 8: Attack the tokens

  1. Run:

    python unit11/agent_permissions.py tokens
  2. You should see (your times differ):

    1. Ana signs in. Her token is for the assistant app:
       {'sub': 'ana', 'aud': 'https://assistant.lab', 'scope': 'assistant', 'iat': 1791451578, 'exp': 1791455178}
    2. The agent exchanges it for a delegated token (user in sub, agent in act):
       {'sub': 'ana', 'act': {'sub': 'order-exception-agent'}, 'aud': 'https://gateway.lab/tools', 'scope': 'sales_order_get', 'iat': 1791451578, 'exp': 1791451878}
    3. Agent forwards Ana's sign-in token to the gateway (passthrough): rejected, wrong audience 'https://assistant.lab', expected 'https://gateway.lab/tools'
    4. Agent asks for a tool it isn't registered for: rejected, agent order-exception-agent may not request: release_request_approve
    5. Agent uses a read token to draft: rejected, tool 'release_request_draft' not in this token's scope
    6. Someone edits the token to say sub=lee: rejected, bad signature
    7. A token that expired a minute ago: rejected, token expired

Each line is a control from the "How it works" section. Line 2 is delegation in the RFC 8693 sense: Ana stays the subject, the agent is the actor, the token lives five minutes and carries one tool. Line 3 is the passthrough the MCP specification forbids. If any line ever says ALLOWED (this is a bug), a control is broken.

Step 9: Run the permission test matrix

  1. Run:

    python unit11/agent_permissions.py matrix
  2. You should see:

    User Agent                  Tool                   Order  Expect  Got    Result  Tech user
    ana  order-exception-agent  sales_order_get        4711   allow   allow  PASS    allow
    ana  order-exception-agent  sales_order_get        4801   deny    deny   PASS    allow  <- LEAK
    ben  order-exception-agent  sales_order_get        4801   allow   allow  PASS    allow
    ben  order-exception-agent  sales_order_get        4711   deny    deny   PASS    allow  <- LEAK
    kim  order-exception-agent  sales_order_get        4712   allow   allow  PASS    allow
    kim  order-exception-agent  release_request_draft  4711   deny    deny   PASS    allow  <- LEAK
    ana  order-exception-agent  release_request_draft  4711   allow   allow  PASS    allow
    ana  order-exception-agent  release_request_draft  4801   deny    deny   PASS    allow  <- LEAK
    ana  night-batch-agent      release_request_draft  4711   deny    deny   PASS    deny
    lee  order-exception-agent  release_request_draft  4801   deny    deny   PASS    allow  <- LEAK
    
    10/10 as expected in delegated mode. The shared technical user would allow 5 of the denied requests.

Half of the rows expect deny. That is the point: each one is a promise to an auditor. The last column shows what the shared technical user would do with the same request. The night agent row says deny there too, because the token exchange refuses the tool before any SAP identity is involved. The matrix restores your drafts file when it finishes, so it is safe to run any time.

Step 10: Read the trace

  1. Run:

    python unit11/agent_permissions.py trace --last 3
  2. You see the last three failed checks, newest last. After the matrix they look like this (your times differ):

    2026-10-08 09:27:09  release_request_draft (delegated, for kim)
       failed: kim on ZLAB_RQ  needed {'SALES_ORG': '1010', 'ACTVT': '01'}
       held:   no authorization for this object
    2026-10-08 09:27:09  release_request_draft (delegated, for ana)
       failed: ana on ZLAB_RQ  needed {'SALES_ORG': '1710', 'ACTVT': '01'}
       held:   [{'SALES_ORG': ['1010'], 'ACTVT': ['01']}]
    2026-10-08 09:27:09  release_request_draft (delegated, for lee)
       failed: lee on ZLAB_RQ  needed {'SALES_ORG': '1710', 'ACTVT': '01'}
       held:   no authorization for this object

    Like SU53, each entry says which principal failed, on which object, what was needed and what was held. It says nothing about the order's contents. If you see No failed checks recorded yet, run a denied call first; that is a valid result.

  3. Save your work:

    git add .gitignore unit11/agent_permissions.py
    git commit -m "Unit 11: agent permissions lab"

What each part of the script does

Part What it does
OBJECTS, ROLES Made-up authorization objects with fields SALES_ORG and ACTVT, bundled into roles like PFCG roles or business roles
PRINCIPALS People, agents and a technical user, each with roles. Agents also list the tools they are registered for
SOD_RULES, holds A conflict rule (request and approve) and the design-time check over every principal's roles
authority_check Like AUTHORITY-CHECK: passes if one authorization for the object allows all needed values; otherwise returns what was needed and held
mint, verify JWT-shaped tokens signed with HMAC. verify checks signature, audience and expiry
sign_in, exchange The user's token is for the app. exchange turns it into a five-minute token for the gateway, with the agent in act and only registered tools in scope
gateway Checks token, tool scope and arguments, then the authorization of each principal in the chain for the chosen mode
run_tool The agent runtime: signs in, exchanges and calls the gateway. In agent mode there is no user
approve The human approval step: people only, never the requester, and only with the approval right for that sales organization
MATRIX, cmd_matrix Expected allow and deny results, compared with delegated mode and the technical user. Exit code 1 on any failure
write_trace, cmd_trace An SU53-style record of failed checks, without business data

If something goes wrong

What you see What it means What to do
python: command not found or 'python' is not recognized Python isn't on your path, or .venv isn't active Turn on .venv (Step 1). On macOS/Linux without .venv, use python3.
ModuleNotFoundError for any module Unusual: the lab needs only built-in modules Check that you saved the whole file. python-dotenv is optional here.
Write arguments as key=value An argument is missing its name Write order=4711, not 4711.
Denied: reason must be 20 to 500 characters The reason is too short or the quotes are missing, so only the first word arrived Put the whole "reason=..." in quotes.
Unknown user The lab knows ana, ben, kim and lee Use one of those names.
Denied: no open draft D-0001 The draft was already approved, or no draft exists yet Run Step 7.1 first, or delete unit11/perm_drafts.json and start again.
bad signature on every call Rare: LAB_TOKEN_SECRET changed between signing and checking in one run Save .env and run the command again.
A matrix row says FAIL Your roles or code allow or deny something the test doesn't expect Read the row, run trace, and fix the role or the expectation on purpose.
IndentationError or SyntaxError Part of the code was lost when pasting Delete the file contents, copy the whole block again and save.
Network or proxy errors Not expected: the lab makes no network calls If you see one, you are running a different script.

The SAP way

As of October 2026, here is how the two keys map onto SAP's offerings. SAP's agent identity pieces were announced and dated during 2026, so confirm the status in your landscape before you design around them.

The user's key inside SAP

The user's key is SAP's own authorization concept: roles with authorization objects in S/4HANA, business roles with restrictions in S/4HANA Cloud. For SAP to apply it, SAP must see the user. From your own BTP application, that means principal propagation through BTP destinations, set out in Grounding on SAP data with authorizations. Joule follows the same model for its own functions.

The agent's key in SAP Cloud Identity Services

SAP's Architecture Center puts agent identities in SAP Cloud Identity Services, in the same Identity Directory as people, with a global user ID. Agents run either in a user's context or autonomously with their own technical identity, tokens and audit trail. That gives your agent's identity an owner, a lifecycle and a place in access reviews, like any other identity.

The Agent Gateway: intersection as a platform service

In SAP's design, the Agent Gateway handles authentication, principal propagation and policy enforcement, and enforces the intersection of user and agent permissions at every hop. Outside agents sign in through SAP Cloud Identity Services and never receive backend credentials; the gateway connects to the SAP system. As of SAP's pages opened for this topic:

Piece Status stated by SAP
Agents as identities in SAP Cloud Identity Services Described in the Architecture Center, May and August 2026
Policy enforcement point between agents and APIs, tools or MCP "will be released in H2/2026" (Agent Identity page, May 2026)
Two-way communication with third-party and self-hosted agents through the Agent Gateway Not yet supported (Agentic AI page, August 2026)
MCP Gateway in SAP Integration Suite Described as governed MCP exposure for SAP and non-SAP APIs; its July 2026 status is covered in SAP tools for agents

Until the gateway covers your case, your own tool gateway carries the agent's key, as in the lab, and SAP carries the user's.

Technical access in S/4HANA Cloud

If an autonomous agent needs its own access to S/4HANA Cloud, the established mechanism is a communication arrangement with a communication user, scoped by a communication scenario. Pick the narrowest scenario that contains the APIs the agent needs, and give that communication user to that agent only. A scenario grants its whole set of APIs, so check what else it contains. SAP's API policy also restricts agentic use of its APIs to endorsed routes; SAP tools for agents covers what that means for your design.

Sketch: requesting a delegated token

# SKETCH: the parameter names are RFC 8693's; the endpoint, client and audience are yours.
import requests

def delegated_token(token_url, client_id, client_secret, user_token, agent_token, gateway_uri, tools):
    resp = requests.post(token_url, auth=(client_id, client_secret), timeout=10, data={
        "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
        "subject_token": user_token,                       # the user: becomes "sub"
        "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
        "actor_token": agent_token,                        # the agent: becomes "act"
        "actor_token_type": "urn:ietf:params:oauth:token-type:access_token",
        "resource": gateway_uri,                           # audience: the tool gateway only
        "scope": " ".join(tools),                          # only this step's tools
    })
    resp.raise_for_status()
    return resp.json()["access_token"]

Segregation of duties and access reviews

Run agent roles through the same SoD analysis as people's. SAP Cloud Identity Access Governance offers continuous access analysis, predefined access rules and SoD risk monitoring. Add your agent identities and their roles to it, and add the rule "an agent never holds an approving activity" to your own rule set.

Diagnosing refusals in ABAP systems

When SAP refuses a delegated call, use SU53 for the user, or STAUTHTRACE while you reproduce the call, to see the object and values involved. Then decide whether the user or the agent should hold that right. Often the answer is neither.

Build vs. SAP

Need Build it yourself SAP
User's key Never rebuild it; pass the user to SAP Roles and business roles, reached through principal propagation
Agent's key Your gateway: tool registry, scope per token, checks per call Agent identities in SAP Cloud Identity Services; Agent Gateway (parts dated H2 2026)
Intersection Check user and agent in your gateway, as in the lab Agent Gateway, as described in SAP's architecture
Delegated tokens Token exchange at your identity provider Ask SAP for the current support in SAP Cloud Identity Services
Autonomous access to S/4HANA Cloud Not yours to build Communication arrangement with a dedicated communication user
SoD analysis A rule check like the lab's roles for prototypes SAP Cloud Identity Access Governance
Refusal diagnosis Your gateway's trace SU53 and STAUTHTRACE in ABAP systems
Permission tests A matrix in your test suite, run on every change No SAP service runs your agent's matrix for you

Rule of thumb: SAP holds the user's key; you hold the agent's key until SAP's gateway covers your case; and you always own the tests.

Production concerns

  • Security and SAP authorizations. Prefer delegated access with principal propagation. If an agent needs its own SAP access, give it a dedicated, read-mostly identity and the narrowest scenario or role. Never share one login between users or between agents.
  • Change control. No agent token reaches an approving or changing activity. Changes run in a human approval step with the SoD check and the controls from SAP tools for agents: idempotency, ETag, audit.
  • Token hygiene. Short lifetimes, one audience, minimal scopes, no passthrough. Keep signing keys and client secrets in a secret store, as in Secrets, API authentication and OAuth.
  • Evaluation. The permission matrix runs in CI on every change to roles, tools or prompts. Add a row for every incident and every new tool, with both allow and deny cases.
  • Prompt injection. Assume the agent will sometimes ask for the wrong thing. The agent's own key limits the damage; the user's key stops it from reaching other users' data. See Prompt injection and tool poisoning.
  • Audit. Log the user, the agent, the tool, the decision and the failed check, never the business data. Keep a human owner for every agent identity, with periodic access reviews.
  • Operations. In delegated mode, the agent's reach follows the user's roles, because SAP checks the user. Autonomous identities don't follow anyone's roles, so review them on a schedule and remove them when their job ends.
  • Cost. Identity work is mostly design and configuration time. Budget for the security team's role design and SoD review, not just the developers.
  • Clean core. Use released APIs and standard roles or business roles. Don't add custom authorization bypasses, privileged CDS access or custom function modules just for the agent.

Pitfalls

  • The demo login goes live. The shared technical user from the prototype becomes the production design.
  • The agent inherits a power user's reach. Delegation without the agent's own limits gives the agent everything a broad user has.
  • Forwarding the user's token. It works until a tool server misuses it. Exchange it for the right audience instead.
  • Building roles from traces. A trace of the agent's attempts includes what it should never do. Design roles from the tool list.
  • Approval as a tool. If the agent can call it, it isn't an approval.
  • Different errors for "missing" and "forbidden". The agent becomes a way to discover what exists.
  • Only testing "allow". A matrix without deny rows proves nothing about security.
  • Treating lab objects as real. The lab's object names and values are made up. Real ones depend on the application and the system.

Exercise

A new colleague, Mia, is an order clerk in sales organization 1010 who also approves releases there. You will add her, find the two problems this creates, fix one and record both. The findings feed the red-teaming topic at the end of Unit 11.

  1. Open unit11/agent_permissions.py. In PRINCIPALS, directly below the line for lee, add:

        "mia": {"kind": "user", "name": "Mia, clerk and approver 1010", "roles": ["Z_ORDER_CLERK_1010", "Z_CREDIT_APPROVER"]},
  2. In MATRIX, directly below the lee row and above the closing ], add two rows and save:

        ("mia", AGENT, "release_request_draft", {"order": "4712", **REASON}, "allow"),
        ("mia", AGENT, "sales_order_get", {"order": "4801"}, "deny"),
  3. Run python unit11/agent_permissions.py roles. You should see a new line, CONFLICT SOD-01 (Request a release and approve releases): mia. This is a design-time SoD conflict.

  4. Run python unit11/agent_permissions.py matrix. The last row fails: Mia can read order 4801 in 1710. Her approver role reads every sales organization (*), and roles add up.

  5. Fix the role. In ROLES, directly above the comment # The agent's own envelope, add a scoped approver role:

        "Z_CREDIT_APPROVER_1010": [("ZLAB_SO", {"SALES_ORG": ["1010"], "ACTVT": ["03"]}),
                                   ("ZLAB_AP", {"SALES_ORG": ["1010"], "ACTVT": ["02"]})],

    Then change Mia's roles to ["Z_ORDER_CLERK_1010", "Z_CREDIT_APPROVER_1010"] and save.

  6. Run matrix again. It should end with 12/12 as expected in delegated mode.

  7. Show the run-time SoD check. Delete unit11/perm_drafts.json if it exists, then run:

    python unit11/agent_permissions.py call release_request_draft order=4712 "reason=Customer paid the overdue invoice today." --user mia
    python unit11/agent_permissions.py approve D-0001 --user mia

    The approval is denied with segregation of duties: you requested this release.

  8. Open unit11/findings.md and add two rows: F-09, area authorizations, what happened user holds request and approve for 1010; SoD conflict at design time, severity medium, status mitigated: run-time check blocks self-approval; and F-10, area authorizations, what happened wildcard read in approver role let a 1010 clerk read 1710 orders through the agent, severity high, status fixed: approver role scoped to 1010.

  9. Commit: git add unit11/agent_permissions.py unit11/findings.md, then git commit -m "Unit 11: scoped approver role and SoD findings".

Done when matrix shows 12/12 as expected in delegated mode, approve D-0001 --user mia is denied for segregation of duties, and findings.md has rows F-09 and F-10.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 1In the lab, Ana asks the order-exception agent for order 4801 in sales organization 1710. The agent's role allows all sales organizations. Why is the request denied?

    Answer: B. In delegated mode the gateway checks the user's role and then the agent's role. The agent's wide organizational scope doesn't help, because Ana's key doesn't turn for 1710.
  2. 2How does SAP's AUTHORITY-CHECK decide whether a user may perform an action?

    Answer: C. An authorization object groups fields checked together. The check needs one authorization for that object whose values cover all needed fields, such as the sales organization and the activity.
  3. 3Your MCP tool server receives the user's sign-in token for the assistant app. What should it do?

    Answer: D. The MCP specification of 2026-07-28 says servers must validate that tokens were issued for them and must not accept or pass on other tokens. Forwarding creates a confused deputy.
  4. 4Which token claims express delegation in the RFC 8693 sense?

    Answer: A. In delegation the actor keeps its own identity, recorded in the act claim, while the user stays the subject. Impersonation would hide the agent, which loses the agent's key and the audit trail.
  5. 5A nightly agent runs with no user. What makes its own role the critical control?

    Answer: C. In autonomous mode only the agent's identity is checked. SAP describes such agents as having their own technical identity, tokens and audit trail; their role must be narrow and owned.
  6. 6A business owner asks you to let the agent approve release requests under a small amount. What do you do?

    Answer: B. An agent with the approving activity creates an SoD conflict, and a model's judgment is not a control. Keep the approval step outside the agent's reach; speed it up instead, for example with a simpler queue for small amounts.
  7. 7The agent is refused by SAP in a delegated call, and STAUTHTRACE shows the missing object. What is the right next step?

    Answer: D. SAP Learning warns against turning trace results straight into roles. The trace shows what the agent tried, which may be something it should never do.
  8. 8Why does the lab's permission matrix include rows that expect "deny"?

    Answer: C. Each deny row is a promise, such as "a 1010 clerk can't see 1710 orders". The matrix runs on every change and fails if a promise breaks, which the technical-user column shows happening.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in