Chapter 08 · Data & Privacy

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.

Tenant ↔ tenant
The hard outer wall. One customer organisation's knowledge graph must never be visible to another's — not in retrieval, not in memory, not by accident. This is the boundary a multi-tenant SaaS lives or dies on.
User ↔ shared corpus
The softer inner wall, inside a single tenant. A user can see their tenant's shared corpus (documents and facts everyone in the tenant may use) plus their own private memory — but not another user's private memory. Net visibility: shared-in-tenant + mine, nothing else.

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 TenantId is null (a global record) or equals the caller's tenant; and
  • its OwnerId is 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.

!
The single-tenant switch is deliberate

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.

AsyncLocal

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.
i
Identity comes from claims, never from the request body

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.
Memory poisoning

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, with ActorId, Timestamp, ScopeId, and AffectedNodeIds) to IMemoryAuditSink (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.

i
Time is injected, not read from the wall clock

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.

The receipt is machine-checkable proof of compliance

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:

  • RequestId and ScopeId — 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 via ICompositeResponseSanitizer before 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.

i
This page is the application side; deployment is elsewhere

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

A user tries to recall another tenant's data

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.