Data Protection & Privacy
The last of the seven defensive layers — layer 7: what the agent may see, what it may keep, and what it can be made to forget. The earlier layers stop an attacker from acting; this one governs the agent's relationship to data over time — per-tenant and per-user isolation, provenance, retention, and right-to-erasure. It is the difference between a system that can answer and a system you can put a customer's private knowledge into.
The two privacy boundaries
Memory makes an agent useful and dangerous in the same breath. The moment it remembers across sessions, two questions become security-critical: can one customer's knowledge leak into another's, and can one user read another user's private memory? The harness answers both with a single mechanism, but it is worth seeing them as two distinct lines.
Both walls are enforced in the same place and by the same rule: a per-record check on every read and every write to the knowledge graph.
Per-record isolation: the visibility rule
The enforcement point is a decorator that wraps the knowledge-graph store
(IKnowledgeGraphStore):
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Scoping/TenantIsolatedGraphStore.cs.
It sits between callers and the real backend, and on every operation it asks two questions about
each record before letting the caller see it or touch it. The questions run through
IKnowledgeScopeValidator
(src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Scoping/KnowledgeScopeValidator.cs),
which checks tenant match plus dataset ownership.
The rule, stated plainly:
-
A node is visible only if its
TenantIdis null (a global record) or equals the caller's tenant; and -
its
OwnerIdis null (the shared corpus) or the caller owns it.
Written as a truth table — both columns must land on a green cell for the record to be visible:
Record's TenantId |
Tenant check | Record's OwnerId |
Owner check | Visible to caller? |
|---|---|---|---|---|
null (global) |
passes | null (shared corpus) |
passes | Yes |
| = caller's tenant | passes | null (shared corpus) |
passes | Yes — tenant shared corpus |
| = caller's tenant | passes | = caller | passes | Yes — caller's own memory |
| = caller's tenant | passes | = another user | fails | No — another user's private memory |
| = another tenant | fails | (any) | — | No — cross-tenant, blocked outright |
The net effect: a user sees their tenant's shared corpus plus their own memory — and nothing from other tenants or other users. The same check runs on writes, so a record can only be written into a scope the caller actually occupies.
When the MultiTenantIsolation config is disabled, all access passes — the
decorator becomes a pass-through and the store behaves as a single shared graph. This is
an intentional mode for single-tenant deployments, not a bug. If you run more than one
customer on one harness, this switch must be on; treat it as a
load-bearing setting and verify it in every environment.
Ambient identity propagation
The visibility rule is only as good as the identity it checks against. The hard part is making the caller's identity available everywhere it's needed — including in background work that runs after the HTTP response has already been sent — without manually passing a user/tenant parameter through every method in the call chain. The harness solves this with an ambient mechanism.
A .NET mechanism that carries a value along an asynchronous call chain — including into background continuations spawned from it — without threading it through every method signature. Set it once at the top of a request and any code that runs as part of that request, however deep or however much later, can read it back. It is the right tool for "the current user" precisely because identity needs to follow the work, not the stack frame.
The accessor
(src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Scoping/KnowledgeScopeAccessor.cs)
stores the user/tenant/dataset identity in a static AsyncLocal, set once at request
entry. From there it flows automatically into child dependency-injection scopes and into
post-turn background writes — so a memory written after the response still lands in the
right tenant and owner, with no extra plumbing.
Identity is captured at the entry point, after authentication, by one thin adapter per transport — mounted on every HTTP host, not just the agent hub. A host that forgets to mount it does not fall back to something safe: unscoped records are stored as global, so every caller can read them.
| Transport | Capture point | What it does |
|---|---|---|
| HTTP (Agent Hub) |
src/Content/Presentation/Presentation.Common/Scoping/KnowledgeScopeMiddleware.cs
|
Runs immediately after authentication and before authorization; calls
KnowledgeScopeInitializer to pull
userId/tenantId from the authenticated
ClaimsPrincipal and seed the accessor. An authenticated
caller whose identity cannot be resolved is rejected with 401 rather than
allowed to proceed unscoped.
|
| HTTP (Bundle API) |
The same class, mounted in
src/Content/Presentation/Presentation.ExecutionApi/Program.cs
|
Identical behaviour and identical position in the pipeline, so plans and records created by an externally-triggered bundle run are scoped exactly as agent-hub turns are. This host previously mounted no scope adapter at all, which meant bundle-created records were written unscoped — that is, global. |
| SignalR |
src/Content/Presentation/Presentation.AgentHub/Hubs/KnowledgeScopeHubFilter.cs
|
The hub-filter equivalent for real-time connections; same
KnowledgeScopeInitializer, same claims source, so streaming turns are
scoped identically to HTTP turns.
|
Both adapters read the user and tenant from the authenticated
ClaimsPrincipal — the identity the platform proved during login — not from
anything the caller can set in the payload. A request cannot ask to be treated as a
different tenant; it is whatever its token says it is.
Memory namespacing
Isolation by filtering is the primary control. Namespacing is the belt-and-braces backup: identity is baked into the memory node's id itself. Memory node ids follow the pattern:
memory:{tenant}:{user}:{key}
Because the tenant and user are part of the key, one user's memory key cannot collide with or address another's — even if the per-record isolation filter were somehow bypassed, there is no id a user could construct that names another user's memory without already knowing (and being) that user's scope. The id space itself is partitioned.
Enforced in all three backends
Isolation that only holds for the in-memory development store would be a trap: it would pass
every local test and then leak in production. The harness persists and filters
OwnerId/TenantId in every backend, so the same record-level boundary
holds whichever store you deploy.
| Backend | File | How isolation is persisted |
|---|---|---|
| In-memory (dev) |
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/InMemory/InMemoryGraphStore.cs
|
Preserves OwnerId/TenantId on merge. |
| Neo4j |
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Neo4j/Neo4jGraphStore.cs
|
Persists owner_id/tenant_id; the Cypher uses
coalesce so a write never null-clobbers an existing tenant.
|
| PostgreSQL |
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/PostgreSql/PostgreSqlGraphStore.cs
|
owner_id/tenant_id columns on kg_nodes and
kg_edges, with indexes; self-initializes its schema.
|
Provenance: making every fact attributable
Isolation answers "who may see this." Provenance answers "where did this come from, and can I
trust it." Every node and edge written to the graph is stamped — when
GraphRagConfig.ProvenanceEnabled — by
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Provenance/DefaultProvenanceStamper.cs,
which produces an immutable
src/Content/Domain/Domain.AI/KnowledgeGraph/Models/ProvenanceStamp.cs record
carrying:
| Field | Meaning |
|---|---|
SourcePipeline |
Which ingestion/processing pipeline created the record. |
SourceTask |
The specific task within that pipeline. |
Timestamp |
When it was written (UTC). |
SourceDocumentId |
The originating document — or null if the fact was synthesized. |
ExtractionConfidence |
How sure the extractor was (0–1) — or null. |
LastModifiedBy |
The actor responsible for the most recent change. |
An attack where a hostile party plants a false "fact" into the agent's memory during one session so that a later session retrieves it and is misled — the slow-burn cousin of prompt injection. Because the agent treats its own memory as trusted, a single poisoned record can quietly steer future answers. This is OWASP Agentic threat ASI06.
Provenance is the structural defence against it: every fact is attributable, so a downstream
filter can act on memory whose SourcePipeline or SourceDocumentId is
untrusted instead of blindly trusting whatever is in the graph. You cannot decide whether to
trust a fact you cannot trace; the stamp is what makes the decision possible.
Trust-aware memory: the write-gate
The quarantine is not hypothetical — it is enforced on every write.
KnowledgeMemoryService.RememberAsync runs an IMemoryWriteGate before
anything is persisted. Content the gate judges untrusted is stored as
MemoryTrust.Untrusted — quarantined rather than written as recallable — and the
IsRecallable check is re-evaluated at read time, so a quarantined record
never re-enters a prompt even if it slipped through. This directly closes the memory-poisoning
(ASI06) path: a hostile fact planted in one session cannot silently steer a later one.
Compliance & retention
Knowing where data came from is half of governance; the other half is not keeping it longer than
you're allowed to, and being able to prove what happened to it. A second decorator —
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Compliance/ComplianceAwareGraphStore.cs
— handles the temporal and audit side:
-
On writes it stamps temporal metadata — creation time via
TimeProvider.GetUtcNow()— and tenant metadata. - On reads it filters out expired nodes, so stale data simply isn't returned.
-
It emits a
MemoryAuditEvent(Remember/Forget/Erasure, withActorId,Timestamp,ScopeId, andAffectedNodeIds) toIMemoryAuditSink(JSONL) — an append-only trail of every memory-changing event.
Retention rules are not hardcoded: they come from IRetentionPolicyProvider, resolved
per tenant and per dataset, so different customers and datasets can carry different lifetimes.
Creation and expiry are computed from TimeProvider.GetUtcNow(), an injected
clock, rather than DateTime.UtcNow. That keeps retention behaviour
deterministic and testable — you can fast-forward a fake clock in a test to prove expired
nodes really do drop out of reads.
Right-to-erasure (GDPR Article 17)
"Delete the user's data" is easy to say and hard to do correctly, because an agent's knowledge of
a user is scattered across several stores. Forgetting must be total, or it isn't forgetting. The
orchestrator at
src/Content/Infrastructure/Infrastructure.AI.KnowledgeGraph/Compliance/DefaultErasureOrchestrator.cs
makes it total: EraseByOwnerAsync(ownerId) deletes across all
stores — graph nodes (and their edges), feedback weights, and vector embeddings — then emits an
Erasure audit event and returns an
src/Content/Domain/Domain.AI/KnowledgeGraph/Models/ErasureReceipt.cs.
An ErasureReceipt is not a log line you have to trust — it is a structured,
returnable record of exactly what was deleted, suitable for an auditor or a
data-subject-access response. It carries:
RequestIdandScopeId— which request, which scope.-
RequestedAt/CompletedAt— the erasure window, provable against the Article 17 timeframe. -
counts:
NodesDeleted,EdgesDeleted,FeedbackWeightsDeleted,VectorEmbeddingsDeleted— the exact blast radius of the deletion across every store.
Data-classification DLP
Isolation and retention govern where data lives; data-loss prevention governs
whether a tool call may move it at all based on its sensitivity. The harness ships a
Microsoft Purview classification gate, IToolClassificationGate, that runs on the
live tool-execution path — one of the three ambient gates inside GovernedAIFunction
(see Tools & Permissions). It classifies the
data a tool touches against Purview sensitivity labels and acts on the verdict.
It is opt-in through a single mode knob,
AppConfig:AI:Governance:DataClassification:Mode:
Off(default) — the gate is inert; no classification runs.Audit— classifications are computed and logged, but nothing is blocked or altered.-
Enforce— verdicts act. A Block verdict returns a model-facing message instead of running the tool; a RedactOutput verdict lets the tool run, then scrubs the sensitive spans from the result viaICompositeResponseSanitizerbefore it re-enters the model's context.
The gate fails open on ambiguity by design: an Unknown classification maps
to Allow, and the whole subsystem stays dormant unless both a mode other than
Off and a classification provider are configured. This keeps the closed-by-default
posture on capabilities while avoiding false positives that would block legitimate work in
deployments that have not adopted Purview labelling.
Secrets management
The data the agent holds is one thing; the credentials that let it reach databases, model
endpoints, and APIs are another. None of them belong in source. Configuration is assembled by
LoadAppConfig in
src/Content/Presentation/Presentation.Common/Helpers/AppConfigHelper.cs, which layers
sources in ascending priority — later layers override earlier ones:
appsettings.json
-> appsettings.{Environment}.json
-> User Secrets (development only)
-> environment variables
-> Azure Key Vault (non-DEBUG builds only)
-> Azure App Configuration
Key Vault is wired in for non-DEBUG builds via:
builder.Configuration.AddAzureKeyVault(
new Uri(akvUri),
new DefaultAzureCredential());
The credential is DefaultAzureCredential — authentication is Entra managed identity,
with no stored credential anywhere in the app to leak. No secret is hardcoded,
and a missing required secret fails host startup loudly rather than letting the app run in a
half-configured state. The Key Vault settings are bound through
src/Content/Domain/Domain.Common/Config/Azure/KeyVaultConfig.cs.
Here we cover how the app consumes secrets. The deployment side — provisioning the vault, assigning the managed identity, and granting it least-privilege access — lives in the Architecture guide: Networking & Security.
Attack scenario: reaching across the tenant wall
Suppose a user in tenant A crafts a request designed to pull back a fact they
know exists in tenant B — perhaps they learned a competitor's record id, or they
try to address a memory key by guessing its name. Their request is authenticated as a
tenant-A user, and that identity is set in the AsyncLocal accessor
at the middleware before any retrieval runs.
When retrieval reaches the graph, every candidate record passes through
TenantIsolatedGraphStore's per-record check. Tenant B's nodes have
TenantId = B, which does not match the caller's tenant — the tenant
check fails and those nodes are filtered out before they ever reach the model's context.
The query returns nothing across the wall. Even the namespaced memory id
(memory:B:…) can't be reached, because the caller's scope is
A and the id space is partitioned by scope. No leak, no error message that
confirms the record exists — just an empty, scoped result.
That closes the seventh and final layer. With identity, autonomy, tool permissions, execution, egress, content safety, and now data protection all in place, the last question is how you prove they stay in place release after release. That is the job of the OWASP Agentic evaluation pack — ten deterministic tests, one per threat category, that attack each layer and fail the build if a defence has quietly regressed.