Grounding on SAP data without breaking authorizations: live queries, access labels and the late check
How to ground AI answers on SAP data so each user sees only what SAP would show them, with live queries as the user, access labels on copies and leak tests.
An AI assistant that answers from SAP data is only safe if it shows each person what SAP would show them, and nothing more.
SAP already knows who may see what. A sales rep in Germany sees German sales orders. A credit analyst sees credit reviews. A new hire with no roles sees almost nothing. These rules live in SAP's authorizations, and SAP checks them on every screen and every API call.
The language model knows none of this. It reads whatever text the assistant puts in front of it and uses it in the answer. So the question is simple: who decides what goes into that text?
There are two ways to ground an answer on SAP data:
Ask SAP live, as the person. The assistant calls SAP with the user's own identity. SAP applies its usual checks and returns only what that person may see.
Search a copy. Notes and documents are copied into a search index for speed and meaning-based search. The copy carries no SAP authorizations. Your team must label every item and filter on those labels for every question.
Most real assistants use both. The rule for leaders: no AI answer should reveal anything the user couldn't open in SAP directly.
The risk is a data leak, not a wrong answer. A sales rep asks, "Why is order 5001 blocked?" The order belongs to a US sales organization the rep isn't authorized for. If the assistant still finds the case note, the rep learns the customer's credit trouble. In SAP the rep would have seen nothing.
The common ways this happens are ordinary design choices:
One technical user for everything. The assistant calls SAP as a single system account that can read all sales organizations. SAP checks the account, not the person, so every user effectively gets the account's reach.
A copy without labels. Case notes, credit memos and emails are loaded into a search index. Nobody tags them with company code or sales organization, so anyone's question can surface them.
Stale permissions. A rep moved from the US team to Germany last month. SAP removed her US access. The index still lists her as allowed on US notes, because its permission list was built before the move.
The cost of getting it right is mostly design time. Passing the user's identity through to SAP needs trust set up between systems. Labelling copies needs a clear rule for every document type. Both are cheaper before go-live than after an audit finding.
The value is wider adoption. When the security team can show that the assistant respects existing roles, it can be rolled out to more users and more data. Running examples from this course, such as blocked sales orders in order-to-cash, become safe to automate.
As of October 2026, SAP's documentation describes these building blocks:
Joule. SAP's architecture guidance states that a user can't see or change through Joule anything they couldn't in the SAP application directly. Joule passes the user's identity to the business system, so that system's roles apply.
Principal propagation on SAP BTP. For your own AI applications on SAP BTP, the Destination and Connectivity services can forward the signed-in user's identity to S/4HANA, in the cloud or on premise. S/4HANA then checks that person's authorizations.
Access controls on SAP data models. In S/4HANA, CDS access controls filter rows by the user's authorizations when ABAP code reads the data. That protection belongs to the SAP system. A copy exported to a search index does not carry it.
Document grounding in SAP AI Core. SAP's managed grounding service loads documents from sources such as SharePoint into a vector store. It reads them with one credential that you configure. Separating collections and filtering by labels is your design work.
So the SAP offering covers the identity plumbing and the checks inside SAP systems. For any copy of SAP data, your team still owns the labels and the filters.
Free text: case notes, emails, policies, past resolutions
Who enforces access
SAP, with the user's own roles
Your application, using labels you maintain
Freshness
Always current
As fresh as the last load
Main risk
Calling as a technical user instead of the person
Missing, wrong or stale labels
Setup effort
Trust between identity provider, BTP and SAP
A labelling rule per document type, plus leak tests
Typical SAP pieces
Joule, BTP destinations with principal propagation
SAP HANA Cloud or SAP AI Core grounding, with your filters
Three decisions to make early:
Default to live queries for structured data. If SAP has an API for it, let SAP decide what the user sees.
Copy only what needs search. Every copied document needs an owner, a label and a deletion rule.
Ask for a leak test before go-live. The team should show a test where users with different roles ask the same questions, and nobody gets a source they couldn't open in SAP.
"The model knows not to share confidential data." It doesn't. A model has no authorizations. Anything put in front of it can appear in the answer.
"Joule respects roles, so our custom assistant does too." Joule's behaviour comes from how it is built and connected. A custom assistant respects roles only if your team builds that in.
"Our vector store is inside SAP HANA Cloud, so SAP's roles apply." A copied note carries no S/4HANA roles. The filter must be designed and maintained.
"A technical user is fine because the assistant only reads." Reading is exactly how data leaks. A broad read account gives every user its reach.
"We set the permissions when we loaded the data." Roles change every week. Permissions captured at load time go stale.
Pick one answer for each question. The explanation appears after you choose.
1An AI assistant gives a sales rep a case note from a sales organization she can't open in SAP. What kind of problem is this?
Answer: B. The rep saw data SAP would have hidden from her. That is a leak, whatever the answer's quality. Prompting or fine-tuning can't fix it, because the model has no authorizations.
2For current order figures, which design is the safest default?
Answer: C. When the user's identity reaches SAP, SAP applies its own checks and returns only what that person may see. A copy needs labels you maintain, and a broad system account gives every user its reach.
3Why is one broad technical user a risk, even for a read-only assistant?
Answer: D. SAP can only check the identity it sees. If that is a system account with access to all sales organizations, every user of the assistant inherits that access. Reading is exactly how data leaks.
4Your team keeps case notes in SAP HANA Cloud for search. What protects them from the wrong users?
Answer: B. A copy carries no S/4HANA roles, even inside an SAP database. Each note needs labels such as sales organization, and the application must filter on them for every question.
5A rep moved teams last month and SAP removed her old access. What should you ask the team?
Answer: C. Permission lists captured when data was loaded go stale when roles change. The team should say how quickly the assistant reflects SAP's current roles, and how it checks.
6What is the best evidence that an assistant respects authorizations before go-live?
Answer: B. A leak test shows, for real roles and real questions, that nobody gets a source they couldn't open in SAP. An administrator demo hides the problem, and prompt instructions don't enforce anything.
7Which statement about Joule and a custom assistant is right?
Answer: D. SAP's guidance says Joule doesn't let users see what they couldn't see in the application directly. A custom assistant respects roles only if your team builds principal propagation and filtering into it.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Everything in the context window is shown to the user. Treat the prompt you send the model as if it were printed on the user's screen. Models can be asked to repeat, summarize or hint at any of it. So the access decision has to happen before text reaches the model, and it has to be as strict as SAP's own.
That gives one test for every design choice: could this user have opened this exact source in SAP right now? If yes, it may go in the context. If no, or if you can't tell, it stays out.
Two paths lead from a question to the context:
Live path. You call SAP as the user. SAP applies its authorizations and returns only allowed rows. You inherit SAP's correctness and freshness.
Index path. You search a copy. The copy has no authorizations, so your code must reproduce the decision: labels on every chunk, the user's current values, a filter before ranking, and ideally a final check back against SAP.
The rest of this topic is about making both paths pass that test.
flowchart LR
U[Signed-in user] --> A[Your AI app]
A -->|live: as the user| S[(S/4HANA<br/>checks roles)]
A -->|index: user's values| F[Pre-filter<br/>on labels]
F --> R[Rank top k]
R --> L{Late check<br/>in SAP}
L -->|allowed| C[Context]
S --> C
L -->|denied| D[Drop and log]
C --> M[Model] --> O[Answer + sources]
C --> G[(Audit log)]
Every SAP API call carries an identity. Two patterns are common:
Technical user. The application authenticates as itself, for example with OAuth client credentials (covered in Secrets, API authentication and OAuth). SAP checks that account. If it may read every sales organization, every user of your assistant can, too. Security people call this a confused deputy: a trusted program does something on behalf of someone who wasn't allowed to.
Principal propagation. The application forwards the signed-in user's identity. SAP's BTP Connectivity documentation describes two routes. Cloud to on-premise uses a destination with authentication type PrincipalPropagation and the SAP Cloud Connector. Cloud to cloud uses OAuth2SAMLBearerAssertion or OAuth2JWTBearer. The documentation says the user information travels as a JSON Web Token (JWT). For S/4HANA Cloud, SAP documents a SAML bearer assertion flow with your Identity Authentication tenant as the trusted identity provider.
Technical users still have a place: nightly loads, monitoring and jobs no person triggers. They should not answer a person's question about business data.
In an ABAP-based system such as S/4HANA, access rests on authorization objects. SAP Learning describes an object as a group of up to ten related fields that are checked together. Most include ACTVT, the activity: 01 create, 02 change, 03 display, 06 delete. Users get authorizations through roles maintained in transaction PFCG.
For reads, SAP recommends CDS access controls as the first choice. A CDS role (DEFINE ROLE ... GRANT SELECT ON ... WHERE ...) adds conditions to every read of a CDS entity. A condition such as aspect pfcg_auth(object, field, ACTVT = '03') compares a column, for example the sales organization, with the values in the user's authorizations. Rows that fail are simply not returned.
Two details matter for AI builders:
The check runs inside the SAP system. SAP Learning notes that CDS access controls take effect when ABAP code reads the CDS entity directly. Data extracted to a file, a data lake or a vector store has left that check behind.
Privileged reads exist. ABAP SQL offers WITH PRIVILEGED ACCESS, which skips the access control. An extraction job that uses it, or that runs as a broad technical user, copies everything.
When you copy documents into a vector store, you have two ways to record who may see each chunk.
Access labels (attributes)
Permission snapshot (user list)
Stored on the chunk
sales_org: 1710, confidential: credit
allowed: [anna, ben]
Compared with
The user's authorized values, looked up when they ask
The user's ID
When roles change
Correct at once, if the lookup is live
Wrong until the next rebuild
When the document changes
Needs re-labelling
Needs re-computing
Typical source
Fields of the business object the note belongs to
ACLs copied from a document system
Labels taken from the business object age well. A note about order 5001 carries order 5001's sales organization. The user's authorized sales organizations come from SAP at question time. Change the user's role, and the next question already reflects it.
User lists age badly. Microsoft's documentation for Azure AI Search states the general rule plainly: permission changes in the source reach search results only after the permission metadata is synchronized to the index. Until then, a removed user still matches.
Whatever you store, fail closed: a chunk without a label is excluded for everyone and reported as a data error. CAP applies the same principle to user attributes: its documentation says an empty or undefined attribute list means the user is fully restricted for that attribute.
Pre-filter. Apply the access filter inside the search, before ranking. If you rank first and filter afterwards, the top 3 may be all forbidden. You then return nothing useful, or you widen the search and leak timing and counts. Hybrid search and metadata filtering shows filters in Chroma, SAP HANA Cloud and SAP AI Core.
Late check. Labels can be wrong. An ingestion job might copy the customer's default sales organization instead of the order's. A late check asks SAP, as the user, whether the note's business object is still visible. For example, read sales order 5003 with the user's identity. If SAP returns nothing, drop the note. This costs one call per candidate, so do it only for the few chunks that made the top k.
Audit. Log who asked, which sources were used and which were dropped and why. OWASP's guidance on vector and embedding weaknesses recommends immutable logs of retrieval activity. Keep the question text out of broadly readable logs; it can itself contain sensitive data.
You will build grounded_answers.py. It contains a tiny stand-in for S/4HANA: six sales orders and three users with authorizations, checked on every read. Next to it sits an index of ten case notes copied out of "SAP", with access labels. You will query SAP as a user and as a technical user, retrieve notes with four levels of access enforcement, and run a leak test that counts every source a user shouldn't have seen.
flowchart LR
Q[Question + user] --> O[orders: live query]
Q --> K[ask: search the copy]
O --> S[(Mock S/4HANA<br/>checks roles)]
K --> M{--mode}
M --> X[Context + audit log]
S --> X
T[leaktest: 6 questions<br/>x 3 users x 4 modes] --> R[Leak count per mode]
The cast:
User
Today in SAP
Last month (when the index was built)
anna
Sales rep, displays sales org 1010
Also had 1710, before she moved teams
ben
Credit analyst, sales orgs 1010 and 1710, plus credit notes
Same
carl
New hire, no roles yet
Same
Two notes contain planted problems. N07 belongs to order 5003 (sales org 1710) but was labelled 1010 by a faulty ingestion job. N08 has no label at all.
Your course folder with the Unit 7 setup done. Cost: free.
No new library. The script uses only Python's built-in modules. python-dotenv (from the course setup) and gen_ai_hub (from Unit 5) are needed only for --llm MODEL.
About 40 minutes. No account and no network, unless you use --llm MODEL (small per-request charge on your SAP AI Core account).
In VS Code, right-click the unit07 folder, choose New File, name it grounded_answers.py, paste the code below and save.
"""Unit 7: grounding answers on SAP-style data without breaking authorizations.
A small "SAP system" in this file holds sales orders and each user's authorizations, and
checks them on every read, the way S/4HANA does. Next to it sits an index of case notes, copied
out of SAP for retrieval. The script shows two ways to ground an answer:
live ask the SAP system directly, as the user (principal propagation) or as a technical user
index retrieve case notes from the copy, with four ways of enforcing access (--mode)
How to run (from your course folder, with .venv turned on):
python unit07/grounded_answers.py orders --user anna # live query as Anna
python unit07/grounded_answers.py orders --user anna --as-technical # the same, as a technical user
python unit07/grounded_answers.py ask "Why is order 5001 blocked?" --user anna
python unit07/grounded_answers.py ask "Why is order 5001 blocked?" --user anna --mode none
python unit07/grounded_answers.py leaktest # every mode against every user
Add --llm sample (no account) or --llm MODEL (SAP orchestration, Unit 5 setup) to "ask".
"""
import argparse
import json
import math
import os
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
AUDIT_LOG = Path(__file__).with_name("grounding_audit.jsonl")
# ---------------------------------------------------------------------------
# 1. The "SAP system": sales orders and authorizations (all made up).
# Z_ORCH_SO is an invented authorization object with a sales organization field and ACTVT.
# Z_ORCH_CR is an invented object for confidential credit notes. ACTVT 03 means display (read).
# ---------------------------------------------------------------------------
SALES_ORDERS = [
{"SalesOrder": "4001", "SalesOrganization": "1010", "SoldToParty": "10100001",
"TotalNetAmount": "18400.00", "TransactionCurrency": "EUR"},
{"SalesOrder": "4002", "SalesOrganization": "1010", "SoldToParty": "10100003",
"TotalNetAmount": "72950.00", "TransactionCurrency": "EUR"},
{"SalesOrder": "4003", "SalesOrganization": "1010", "SoldToParty": "10100002",
"TotalNetAmount": "5120.00", "TransactionCurrency": "EUR"},
{"SalesOrder": "5001", "SalesOrganization": "1710", "SoldToParty": "17100001",
"TotalNetAmount": "96300.00", "TransactionCurrency": "USD"},
{"SalesOrder": "5002", "SalesOrganization": "1710", "SoldToParty": "17100004",
"TotalNetAmount": "12780.00", "TransactionCurrency": "USD"},
{"SalesOrder": "5003", "SalesOrganization": "1710", "SoldToParty": "17100002",
"TotalNetAmount": "54000.00", "TransactionCurrency": "USD"},
]
# Each user's authorizations TODAY, as the SAP system holds them.
AUTHORIZATIONS = {
"anna": [{"object": "Z_ORCH_SO", "SALES_ORG": ["1010"], "ACTVT": ["03"]}],
"ben": [{"object": "Z_ORCH_SO", "SALES_ORG": ["1010", "1710"], "ACTVT": ["03"]},
{"object": "Z_ORCH_CR", "CONFID": ["credit"], "ACTVT": ["03"]}],
"carl": [], # a new hire: signed in, but no roles in SAP yet
"COMM_USER_AI": [{"object": "Z_ORCH_SO", "SALES_ORG": ["*"], "ACTVT": ["03"]},
{"object": "Z_ORCH_CR", "CONFID": ["*"], "ACTVT": ["03"]}], # technical user
}
# The same, as they were LAST MONTH when the index was built. Anna has since moved from the
# US team (1710) to the German team (1010), and her 1710 authorization was removed.
AUTHORIZATIONS_AT_INDEX_TIME = dict(AUTHORIZATIONS)
AUTHORIZATIONS_AT_INDEX_TIME["anna"] = [{"object": "Z_ORCH_SO", "SALES_ORG": ["1010", "1710"],
"ACTVT": ["03"]}]
def authorized_values(auths: dict, user: str, obj: str, field: str, activity: str = "03") -> set:
"""Values of one field the user holds for an object and activity. '*' means all values."""
values = set()
for auth in auths.get(user, []):
if auth["object"] == obj and activity in auth["ACTVT"]:
values.update(auth.get(field, []))
return values
def allowed(values: set, value: str) -> bool:
return "*" in values or value in values
class MockS4:
"""Stands in for S/4HANA: every read is checked against the caller's authorizations."""
def __init__(self, auths: dict):
self.auths = auths
def read_sales_order(self, caller: str, order_id: str):
"""Return the order if the caller may display it; otherwise None (as if it didn't exist)."""
orgs = authorized_values(self.auths, caller, "Z_ORCH_SO", "SALES_ORG")
for order in SALES_ORDERS:
if order["SalesOrder"] == order_id and allowed(orgs, order["SalesOrganization"]):
return order
return None
def query_sales_orders(self, caller: str, min_amount: float = 0.0) -> list:
"""Like a filtered OData query: only rows the caller may display come back."""
orgs = authorized_values(self.auths, caller, "Z_ORCH_SO", "SALES_ORG")
return [o for o in SALES_ORDERS
if allowed(orgs, o["SalesOrganization"]) and float(o["TotalNetAmount"]) >= min_amount]
SAP = MockS4(AUTHORIZATIONS)
# ---------------------------------------------------------------------------
# 2. The index: case notes copied out of SAP, with access labels set at ingestion.
# sales_org "ALL" = general guidance for every signed-in user. confidential "credit" needs Z_ORCH_CR.
# order = the sales order the note belongs to (its "anchor" in SAP), or None.
# ---------------------------------------------------------------------------
NOTES = [
{"id": "N01", "order": None, "sales_org": "ALL", "confidential": None,
"text": "Policy: an order blocked by the credit check is released by a credit analyst after "
"reviewing the customer's open items. Sales reps cannot release credit blocks."},
{"id": "N02", "order": "4001", "sales_org": "1010", "confidential": None,
"text": "Order 4001 for customer 10100001 is blocked for delivery: the requested delivery "
"date is missing. Rep asked the customer for a date."},
{"id": "N03", "order": "4002", "sales_org": "1010", "confidential": None,
"text": "Order 4002 for customer 10100003 waits for a price check on two items. Pricing team "
"confirmed the list price; block removed."},
{"id": "N04", "order": "5001", "sales_org": "1710", "confidential": None,
"text": "Order 5001 for customer 17100001 is blocked by the credit check. The customer asked "
"the rep when the order will ship."},
{"id": "N05", "order": "5001", "sales_org": "1710", "confidential": "credit",
"text": "Credit review for customer 17100001: order 5001 exceeds the credit limit by 12,000 USD "
"because two invoices are 45 days overdue. Do not raise the limit."},
{"id": "N06", "order": "5002", "sales_org": "1710", "confidential": None,
"text": "Order 5002 for customer 17100004 shipped late because of a missing export document."},
# Ingestion bug: the label was copied from the customer's default sales org, not the order's.
{"id": "N07", "order": "5003", "sales_org": "1010", "confidential": None,
"text": "Order 5003 for customer 17100002 is blocked: the customer disputes the price. The "
"sales director approved a special discount of 18 percent."},
# Missing label: the note must be left out for everyone (fail closed).
{"id": "N08", "order": None, "sales_org": None, "confidential": None,
"text": "Draft: discount bands for key accounts next year, up to 25 percent for orders "
"blocked by price disputes."},
{"id": "N09", "order": "4003", "sales_org": "1010", "confidential": "credit",
"text": "Credit review for customer 10100002: limit raised to 20,000 EUR after a bank "
"guarantee arrived. Order 4003 released from the credit block."},
{"id": "N10", "order": None, "sales_org": "ALL", "confidential": None,
"text": "How to read a blocked order: open the sales order, check the header status and the "
"block reason, then the notes attached to the order."},
]
def label_ok(user: str, note: dict, auths: dict) -> bool:
"""Access-label check against the user's authorizations. A missing label fails closed."""
if note["sales_org"] is None:
return False
if note["sales_org"] != "ALL":
orgs = authorized_values(auths, user, "Z_ORCH_SO", "SALES_ORG")
if not allowed(orgs, note["sales_org"]):
return False
if note["confidential"]:
classes = authorized_values(auths, user, "Z_ORCH_CR", "CONFID")
if not allowed(classes, note["confidential"]):
return False
return True
# A snapshot ACL: the list of users allowed to see each note, computed ONCE at index time.
for _note in NOTES:
_note["acl_snapshot"] = sorted(u for u in ("anna", "ben", "carl")
if label_ok(u, _note, AUTHORIZATIONS_AT_INDEX_TIME))
def may_read(user: str, note: dict) -> bool:
"""Ground truth for the leak test: what SAP itself would let this user see today."""
if note["order"]:
if SAP.read_sales_order(user, note["order"]) is None:
return False
if note["confidential"]:
classes = authorized_values(AUTHORIZATIONS, user, "Z_ORCH_CR", "CONFID")
return allowed(classes, note["confidential"])
return True
return note["sales_org"] == "ALL"
# ---------------------------------------------------------------------------
# 3. Retrieval with access enforcement
# ---------------------------------------------------------------------------
STOP = set("a an and are as at be by for from how i in is it my of on or the this to was what "
"when which who why will with".split())
def tokens(text: str) -> Counter:
return Counter(w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP)
def cosine(a: Counter, b: Counter) -> float:
dot = sum(a[w] * b[w] for w in a)
norm = math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values()))
return dot / norm if norm else 0.0
MODES = ["none", "snapshot", "labels", "labels+recheck"]
def retrieve(question: str, user: str, mode: str, top: int = 3):
"""Return (context notes, dropped notes with reasons) for this user and enforcement mode."""
q = tokens(question)
dropped = []
# Pre-filter BEFORE ranking, so the top results are filled with notes the user may see.
if mode == "none":
pool = list(NOTES)
elif mode == "snapshot":
pool = [n for n in NOTES if user in n["acl_snapshot"]]
else:
pool = [n for n in NOTES if label_ok(user, n, AUTHORIZATIONS)]
ranked = sorted(((cosine(q, tokens(n["text"])), n) for n in pool), key=lambda p: -p[0])
context = []
for score, note in ranked:
if score <= 0 or len(context) == top:
break
# Late check: ask SAP, as the user, whether the note's business object is still visible.
if mode == "labels+recheck" and note["order"] and SAP.read_sales_order(user, note["order"]) is None:
dropped.append({"id": note["id"], "reason": f"SAP denies order {note['order']} to {user}"})
continue
context.append({"id": note["id"], "score": round(score, 3), "text": note["text"]})
return context, dropped
def audit(user: str, question: str, mode: str, context: list, dropped: list) -> None:
"""Append one line per retrieval: who asked, what was used, what was dropped and why."""
entry = {"time": datetime.now(timezone.utc).isoformat(timespec="seconds"), "user": user,
"mode": mode, "question": question, "used": [c["id"] for c in context], "dropped": dropped}
with AUDIT_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
# ---------------------------------------------------------------------------
# 4. Generation (optional): the same call as in "RAG fundamentals"
# ---------------------------------------------------------------------------
SYSTEM = ("Answer only from the numbered sources. Cite them as [1], [2]. If the sources don't "
"contain the answer, say you can't find it. Never guess about orders you have no source for.")
def generate(model: str, question: str, context: str) -> str:
"""Call a model through SAP's orchestration service, or return a made-up answer with --llm sample."""
if model == "sample":
if not context:
return "[sample answer, no model called] I can't find that in the sources you may see."
first = context.split("\n")[1]
return "[sample answer, no model called] " + first.split(". ")[0].rstrip(".") + ". [1]"
from dotenv import load_dotenv
load_dotenv()
missing = [n for n in ["AICORE_CLIENT_ID", "AICORE_CLIENT_SECRET", "AICORE_AUTH_URL",
"AICORE_BASE_URL", "AICORE_RESOURCE_GROUP"] if not os.environ.get(n)]
if missing:
sys.exit("Missing in .env: " + ", ".join(missing) + ". See 'Set up for Unit 5'. Or use --llm sample.")
from gen_ai_hub.orchestration_v2 import (LLMModelDetails, ModuleConfig, OrchestrationConfig,
OrchestrationService, PromptTemplatingModuleConfig,
SystemMessage, Template, UserMessage)
template = Template(template=[SystemMessage(content=SYSTEM),
UserMessage(content="Sources:\n{{?context}}\n\nQuestion: {{?question}}")])
config = OrchestrationConfig(modules=ModuleConfig(prompt_templating=PromptTemplatingModuleConfig(
prompt=template, model=LLMModelDetails(name=model, timeout=60, max_retries=1))))
service = None
try:
service = OrchestrationService(config=config)
result = service.run(placeholder_values={"context": context or "(no sources)", "question": question})
except Exception as error:
sys.exit(f"The model call failed: {type(error).__name__}: {str(error)[:300]}")
finally:
if service is not None:
service.close_http_connection()
return result.final_result.choices[0].message.content or ""
# ---------------------------------------------------------------------------
# 5. Commands
# ---------------------------------------------------------------------------
LEAK_QUESTIONS = [
"Why is order 5001 blocked?",
"What is the credit limit situation for customer 17100001?",
"Why is order 5003 blocked and what discount was approved?",
"What discount bands apply to key accounts?",
"Was the credit limit for customer 10100002 raised?",
"Why is order 4001 blocked?",
]
def cmd_orders(args) -> None:
caller = "COMM_USER_AI" if args.as_technical else args.user
rows = SAP.query_sales_orders(caller, args.min_amount)
print(f"Signed-in user: {args.user}. SAP sees the call as: {caller}")
print(f"{'Order':<7}{'SalesOrg':<10}{'SoldTo':<10}{'Net amount':>14}")
for o in rows:
print(f"{o['SalesOrder']:<7}{o['SalesOrganization']:<10}{o['SoldToParty']:<10}"
f"{o['TotalNetAmount']:>10} {o['TransactionCurrency']}")
print(f"{len(rows)} order(s) returned.")
if args.as_technical:
print(f"Warning: {args.user} may display only "
f"{len(SAP.query_sales_orders(args.user, args.min_amount))} of these in SAP.")
def cmd_ask(args) -> None:
context, dropped = retrieve(args.question, args.user, args.mode, args.top)
audit(args.user, args.question, args.mode, context, dropped)
print(f"User: {args.user} Mode: {args.mode} Question: {args.question}\n")
if not context:
print("No sources this user may see match the question.")
for n, c in enumerate(context, 1):
flag = "" if may_read(args.user, next(x for x in NOTES if x["id"] == c["id"])) else " <-- LEAK"
print(f"[{n}] {c['id']} (score {c['score']}){flag}\n {c['text']}")
for d in dropped:
print(f"Dropped {d['id']}: {d['reason']}")
print(f"\nLogged to {AUDIT_LOG.name}")
if args.llm:
text = "\n\n".join(f"[{n}] ({c['id']})\n{c['text']}" for n, c in enumerate(context, 1))
print("\nAnswer:\n" + generate(args.llm, args.question, text))
def cmd_leaktest(args) -> None:
users = ["anna", "ben", "carl"]
print(f"{len(LEAK_QUESTIONS)} questions x {len(users)} users, top {args.top} sources each\n")
print(f"{'Mode':<16}{'Leaks':>6} Leaked notes (user:note)")
worst = 0
for mode in MODES:
leaks = []
for user in users:
for question in LEAK_QUESTIONS:
context, _ = retrieve(question, user, mode, args.top)
leaks += [f"{user}:{c['id']}" for c in context
if not may_read(user, next(x for x in NOTES if x["id"] == c["id"]))]
unique = sorted(set(leaks))
print(f"{mode:<16}{len(unique):>6} {', '.join(unique) if unique else '-'}")
if mode == "labels+recheck":
worst = len(unique)
print("\nPASS: the default mode leaked nothing." if worst == 0 else "\nFAIL: the default mode leaked.")
sys.exit(0 if worst == 0 else 1)
def main() -> None:
parser = argparse.ArgumentParser(description="Grounding on SAP-style data with authorizations.")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("orders", help="live query: sales orders as the user or as a technical user")
p.add_argument("--user", choices=["anna", "ben", "carl"], default="anna")
p.add_argument("--as-technical", action="store_true", help="call SAP as COMM_USER_AI instead")
p.add_argument("--min-amount", type=float, default=0.0)
p = sub.add_parser("ask", help="retrieve case notes from the index for one user")
p.add_argument("question")
p.add_argument("--user", choices=["anna", "ben", "carl"], default="anna")
p.add_argument("--mode", choices=MODES, default="labels+recheck")
p.add_argument("--top", type=int, default=3)
p.add_argument("--llm", metavar="MODEL", help="'sample' (no account) or a model name")
p = sub.add_parser("leaktest", help="run leak questions for every user and mode")
p.add_argument("--top", type=int, default=3)
args = parser.parse_args()
{"orders": cmd_orders, "ask": cmd_ask, "leaktest": cmd_leaktest}[args.command](args)
if __name__ == "__main__":
main()
#Step 4: Compare a live query as the user with one as a technical user
Ask "SAP" for Anna's sales orders, as Anna:
python unit07/grounded_answers.py orders --user anna
Signed-in user: anna. SAP sees the call as: anna
Order SalesOrg SoldTo Net amount
4001 1010 10100001 18400.00 EUR
4002 1010 10100003 72950.00 EUR
4003 1010 10100002 5120.00 EUR
3 order(s) returned.
This is principal propagation in miniature. The mock checks Anna's authorization for display (03) on sales org 1010 and returns only those rows. Your code did no filtering.
Now run the same query as the technical user:
python unit07/grounded_answers.py orders --user anna --as-technical
Signed-in user: anna. SAP sees the call as: COMM_USER_AI
Order SalesOrg SoldTo Net amount
4001 1010 10100001 18400.00 EUR
...
5003 1710 17100002 54000.00 USD
6 order(s) returned.
Warning: anna may display only 3 of these in SAP.
The person is the same; only the identity SAP sees changed. Anna now receives three US orders. If these rows went into a prompt, the model would happily use them.
Try --user carl. A valid result is 0 order(s) returned.: Carl has no roles yet, so SAP shows him nothing.
#Step 5: Watch each enforcement mode on one question
With no access enforcement at all:
python unit07/grounded_answers.py ask "Why is order 5001 blocked?" --user anna --mode none
User: anna Mode: none Question: Why is order 5001 blocked?
[1] N04 (score 0.577) <-- LEAK
Order 5001 for customer 17100001 is blocked by the credit check. The customer asked the rep when the order will ship.
[2] N10 (score 0.504)
How to read a blocked order: open the sales order, check the header status and the block reason, then the notes attached to the order.
[3] N07 (score 0.28) <-- LEAK
Order 5003 for customer 17100002 is blocked: the customer disputes the price. The sales director approved a special discount of 18 percent.
<-- LEAK marks a note Anna couldn't open in SAP today. The script decides that by asking the mock, not by trusting labels.
With the snapshot taken last month:
python unit07/grounded_answers.py ask "Why is order 5001 blocked?" --user anna --mode snapshot
The same two leaks appear. The snapshot still lists Anna on 1710 notes, because it was computed before she changed teams. N07 leaks through its wrong label.
With labels compared against Anna's current authorizations:
python unit07/grounded_answers.py ask "Why is order 5003 blocked and what discount was approved?" --user anna --mode labels
[1] N07 (score 0.542) <-- LEAK
Order 5003 for customer 17100002 is blocked: the customer disputes the price. The sales director approved a special discount of 18 percent.
The role change is handled now, because Anna's values come from SAP at question time. But the label on N07 is wrong, so a 1010 user still gets a 1710 note.
With labels plus the late check (the default):
python unit07/grounded_answers.py ask "Why is order 5003 blocked and what discount was approved?" --user anna --llm sample
User: anna Mode: labels+recheck Question: Why is order 5003 blocked and what discount was approved?
[1] N10 (score 0.39)
How to read a blocked order: open the sales order, check the header status and the block reason, then the notes attached to the order.
[2] N02 (score 0.2)
Order 4001 for customer 10100001 is blocked for delivery: the requested delivery date is missing. Rep asked the customer for a date.
[3] N01 (score 0.175)
Policy: an order blocked by the credit check is released by a credit analyst after reviewing the customer's open items. Sales reps cannot release credit blocks.
Dropped N07: SAP denies order 5003 to anna
Logged to grounding_audit.jsonl
Answer:
[sample answer, no model called] How to read a blocked order: open the sales order, check the header status and the block reason, then the notes attached to the order. [1]
The late check read order 5003 as Anna, got nothing back, and dropped the note. The answer is built only from sources she may see. That is the correct outcome, even though it is less "helpful".
Optional, with a real model (needs the Unit 5 keys in .env; small per-request charge). Replace MODEL_NAME with a model available in your SAP AI Core account:
python unit07/grounded_answers.py ask "Why is order 5003 blocked and what discount was approved?" --user anna --llm MODEL_NAME
A good model answers that it can't find order 5003 in the sources. If it guesses, the system prompt needs work; the data is still safe because N07 never reached it.
Each line is one retrieval: user, mode, question, the notes used and the notes dropped with a reason. leaktest doesn't write to the log; only ask does.
Check what Git will save:
git status
You should see .gitignore and unit07/grounded_answers.py. You must not see grounding_audit.jsonl.
Save it:
git add .gitignore unit07/grounded_answers.py
git commit -m "Add grounding lab with authorization checks and leak test"
Six made-up orders with fields named as in the Sales Order API (SalesOrder, SalesOrganization, SoldToParty, ...)
AUTHORIZATIONS
Each user's authorizations today: object, field values and activities. * means all values
AUTHORIZATIONS_AT_INDEX_TIME
The same, last month, when Anna still had 1710
authorized_values
The values a user holds for one field of one object, for activity 03 (display)
MockS4
The stand-in for S/4HANA. read_sales_order and query_sales_orders check the caller on every read
NOTES
Ten case notes copied out of "SAP", each with an anchor order and access labels. N07 is mislabelled, N08 unlabelled
label_ok
Compares a note's labels with a user's authorizations. A missing label fails closed
acl_snapshot
A list of allowed users per note, computed once from last month's authorizations
may_read
The ground truth for testing: asks the mock whether the user may see the note's order today
retrieve
Pre-filters by mode, ranks by word overlap (cosine), then in labels+recheck asks SAP about each candidate's order
audit
Appends one JSON line per ask: who, what was used, what was dropped and why
generate
Optional model call through SAP's orchestration service, as in RAG fundamentals
cmd_leaktest
Runs six questions for three users in four modes and counts leaked notes; exit code 1 if the default mode leaks
The ranking is deliberately simple word overlap, so you can focus on access. In a real system the same pre-filter and late check sit around the vector or hybrid search from earlier Unit 7 topics.
SAP's Architecture Center states the principle for Joule: a user can't access information or change processes through Joule that they couldn't in the SAP application directly. Its identity guidance describes how. SAP Cloud Identity Services holds users with a global user ID. When Joule calls a function for a user, it uses principal propagation through the SAP BTP Destination service. Each Joule function is mapped to a role, and the Identity Provisioning service keeps users and role assignments in sync.
Use this as the bar for your own assistant. Joule extensions and agents are covered in Unit 9.
For an application you deploy on SAP BTP (see Deploying AI apps on BTP), the user's identity reaches S/4HANA through a destination:
Target
Destination authentication type
Extra pieces
S/4HANA on premise or in a private cloud
PrincipalPropagation
Connectivity service, SAP Cloud Connector, trust between Cloud Connector and the ABAP system
S/4HANA Cloud and other cloud systems
OAuth2SAMLBearerAssertion or OAuth2JWTBearer
Trust between your BTP subaccount, the Identity Authentication tenant and the target
Your code stays nearly the same as with a technical user: it calls the destination with the signed-in user's token, and the Destination service handles the exchange. BTP foundations for AI builders shows how an app reads a destination.
# SKETCH: needs a BTP application with XSUAA login and a destination named S4_USER
# (authentication OAuth2SAMLBearerAssertion or PrincipalPropagation). Not runnable locally.
def read_order_as_user(user_jwt: str, order_id: str):
dest = destination_service.find("S4_USER", user_token=user_jwt) # exchange happens here
resp = http_get(dest.url + f"/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder('{order_id}')",
headers=dest.auth_headers)
if resp.status_code in (403, 404):
return None # SAP said no, or the order isn't visible to this user
resp.raise_for_status()
return resp.json()["d"]
The live path is only as good as the checks inside SAP. For released APIs and CDS views, SAP's own access controls apply when the data is read. If your team builds custom CDS views for the assistant, give them CDS access controls with pfcg_auth conditions, and avoid WITH PRIVILEGED ACCESS in anything that serves a user's question. Clean core rules (covered in CAP and side-by-side extensions) favour released APIs over custom extraction anyway.
If your assistant exposes data through a CAP service, CAP's @restrict annotation does the label filtering for you. A where condition with a $user attribute filters every READ:
// SKETCH: a CAP model on BTP; the user attribute salesOrg comes from your identity setup.
annotate CaseNotes with @(restrict: [
{ grant: 'READ', to: 'SalesRep', where: 'salesOrg = $user.salesOrg' }
]);
CAP's documentation treats $user.salesOrg as a list of values, and an empty or undefined list as fully restricted. That is fail closed by default. It also warns against unrestricted attributes unless they are designed very carefully.
SAP AI Core's grounding service chunks documents, embeds them, stores them in a vector store and retrieves them at query time. As of September 2026 its documentation lists Microsoft SharePoint, AWS S3, SFTP, SAP Build Work Zone, SAP Document Management service, ServiceNow and Google Drive as repositories, with content refresh listed as daily.
Three points matter for authorizations:
The pipeline reads with one credential. For SharePoint, the documentation describes a generic secret with either OAuth2Password (delegated Sites.Read.All) or OAuth2ClientCredentials (application Sites.Selected). The pages I read describe the pipeline's access, not trimming results to each end user's SharePoint permissions. Ask SAP how your release handles source permissions before you index restricted content.
Partition by resource group or collection. Resource groups separate resources within a tenant. Put content for different audiences in different collections or resource groups, and choose the collection on the server from the user's identity. OWASP recommends this kind of logical partitioning.
Add labels and filter. Metadata filters on retrieval work as shown in Hybrid search and metadata filtering, including its warning about select modes that don't fail closed.
The daily refresh also means deletions and permission changes in the source can take up to a day to reach the index. For content where that matters, add the late check.
If you keep your own vector table in SAP HANA Cloud (SAP HANA Cloud vector engine), store access labels as columns next to the vector and put the filter in the SQL WHERE clause. Build that clause on the server from the user's identity, with bound parameters, never from text the client or the model sends. The application connects as a database user that can read only the tables it needs.
Principal propagation uses SAP BTP Connectivity and Destination services and, on premise, the Cloud Connector; SAP AI Core grounding is billed with SAP AI Core. Check current service plans and your contract with SAP before you design around a service. None of the hands-on lab needs a paid account.
Never reproduce SAP's checks; call SAP as the user
Destinations with principal propagation; Joule for standard scenarios
Free-text notes stored in SAP
Copy with labels from the business object, pre-filter, late check
CDS access controls if you can read the text live through a released API
Documents in SharePoint or S3
Your own index with security filters per user or group
SAP AI Core grounding pipelines, partitioned by collection, with your labels
Labels on your own entities
Filter code in your service
CAP @restrict with $user attributes
Proving no leaks
A leak test in CI, like this lab's
No SAP service does this for you
A practical rule: let SAP decide wherever SAP can be asked live. Copy only when you need search, and then treat the copy as a cache that must be re-checked against SAP.
Identity end to end. One identity provider for the assistant and SAP (SAP Cloud Identity Services in SAP's reference design), so the same person is recognised on both sides. Users who exist in one system but not the other should get nothing, not a default role.
Least privilege for jobs. Ingestion jobs need broad read access; give them a separate technical user that the question-answering path can't use. Log what each job copied.
Label quality is a data pipeline. Derive labels from the business object (the order's sales organization), not from defaults. Report chunks without labels as errors. Re-label when the source object changes.
Freshness budget. Agree how stale a permission may be, per data class. Use live queries or the late check where the answer is "not at all".
Evaluation. Add leak tests to the evaluation set: several user profiles, questions designed to pull forbidden sources, and a pass rule of zero leaks. Run them on every change to prompts, retrieval or data. Unit 8 covers evaluation harnesses.
Logs and privacy. Audit logs show who saw what; restrict who can read them and how long they are kept. Questions and sources may be personal data under data protection law.
Cost and latency. The late check adds one SAP read per candidate. Limit it to the top k, and batch reads where the API allows a filter on several keys.
Clean core. Read through released APIs and CDS views with their own access controls, instead of custom extracts that bypass them.
Testing as an administrator. A demo user with all roles never sees a leak. Test with the least privileged realistic user.
Filtering after ranking. The top k fills with forbidden chunks, and the user gets an empty or poor answer.
Trusting filter values from the browser or the model. A user, or a prompt injection, can change them. Derive them on the server.
"Not found" vs. "forbidden" messages. Telling a user "you may not see order 5003" confirms it exists. Answer as if no source were found.
Caches and shared threads. A cached grounded answer served to the next user, or a shared conversation that keeps sources in context.
Summaries that escape labels. A nightly job summarises all case notes into one "weekly digest" document. The digest inherits none of the labels and leaks everything to whoever can read it.
Missing labels treated as public. One unlabelled document is enough. Fail closed.
Extend the lab with a new user and a role change, and keep the leak test green.
Open unit07/grounded_answers.py in VS Code.
In AUTHORIZATIONS, add a user dora who may display sales org 1710 but has no credit authorization: "dora": [{"object": "Z_ORCH_SO", "SALES_ORG": ["1710"], "ACTVT": ["03"]}],.
Add "dora" to the three choices=[...] lists in main, to the user tuple in the snapshot loop, and to users in cmd_leaktest.
Add two questions to LEAK_QUESTIONS that should pull the confidential note N05 for Dora, for example "Why can't the credit limit for customer 17100001 be raised?".
Run python unit07/grounded_answers.py leaktest. Check that none shows dora:N05 among the leaks and that labels+recheck still shows 0.
Simulate a role removal: in Ben's Z_ORCH_CR entry, change "ACTVT": ["03"] to "ACTVT": ["02"] (change, but not display). Run the leak test again. Confirm that none now lists ben:N05 and that labels+recheck still shows 0. The snapshot row doesn't change for Ben, because the script copies last month's authorizations from the edited ones; in a real system, last month's snapshot would still grant him access.
Pick one answer for each question. The explanation appears after you choose.
1What is the safest working assumption about text you put in the model's context?
Answer: C. A model has no authorizations, and users can ask it to repeat or summarise its sources. So the access decision must happen before text reaches the model, as strictly as SAP would make it.
2Your BTP app calls S/4HANA Cloud with a destination of type OAuth2SAMLBearerAssertion. What does that achieve?
Answer: B. SAP documents OAuth2SAMLBearerAssertion and OAuth2JWTBearer as the cloud-to-cloud routes for principal propagation. The target system sees the person, not the application, and applies that person's authorizations.
3Why does an index built from a CDS view not inherit that view's access control?
Answer: D. SAP Learning notes that CDS access controls take effect when ABAP code reads the entity directly. Once rows are copied out, nothing in the copy checks the user, so the application must label and filter.
4Anna moved teams and lost sales org 1710 in SAP. Which design still shows her 1710 notes?
Answer: C. A permission snapshot reflects roles at index time and stays wrong until the next rebuild. Labels checked against live values, live queries and the late check all use her current roles.
5In the lab, mode labels still leaks N07 to Anna. What catches it, and how?
Answer: D. N07's label says 1010 but its order belongs to 1710, so any label-based filter trusts the wrong value. The late check asks SAP about the note's business object with Anna's identity, gets nothing back, and drops the note.
6Why apply the access filter before ranking rather than after?
Answer: A. If you rank first, the top k can be all forbidden chunks, leaving an empty or poor answer. Pre-filtering keeps the result useful; the late check still guards against wrong labels.
7A colleague proposes caching grounded answers by question text to save cost. What is the risk?
Answer: C. Two users with different roles can ask the same words. A cache keyed only by question text serves the first user's grounded answer to the second. Key it by user or authorization set, or don't cache grounded answers.
8A user asks about order 5003, which SAP hides from them. What should the assistant reply?
Answer: D. Saying "you may not see order 5003" confirms the order exists, which is itself information. Answering as if no source were found reveals nothing, matching what SAP would show the user.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Integrating and Extending Joule (SAP Architecture Center)— a user cannot access information or adjust processes through Joule that they couldn't in the application directly; Joule is aware of the user's roles; SAP Document Grounding for customer-owned knowledge
Identity and Access Management for SAP Joule (SAP Architecture Center)— SAP Cloud Identity Services as user store with a global user ID; principal propagation through the SAP BTP Destination service; Joule functions mapped to roles; Identity Provisioning service syncs users and role assignments
Grounding (SAP AI Core documentation, SAP-docs on GitHub)— grounding chunks, embeds and stores documents, then retrieves at query time; repositories Microsoft SharePoint, AWS S3, SFTP, SAP Build Work Zone, SAP Document Management service, ServiceNow, Google Drive; content refresh listed as daily; 8000 documents per pipeline for the listed repositories
CAP-level Authorization (capire)— @restrict with grant, to and where; instance-based rules with $user attributes treated as lists; an empty or undefined attribute list means fully restricted; where acts as a result filter on READ