Memory Permissions
A flat memory store with no access control is a security liability in any multi-user system. This pattern implements server-enforced namespace isolation with role-based access using scoped API keys — ensuring Customer A's data never appears in Customer B's context, private user memories stay private, and shared organizational knowledge is accessible to the right roles without being writable by everyone.
Start Building Free →- Dakera instance running with admin API key (quickstart)
- SDK installed:
pip install dakera/npm i @dakera-ai/dakera - Defined access control requirements: which roles need read or write access to which namespaces
- A mechanism to provision scoped API keys per user/tenant during onboarding (your auth service or API gateway)
The Problem
Multi-tenant AI applications face a critical security challenge that most developers don't anticipate until they're in production. Without proper memory isolation:
- Data leakage across tenants: Customer A's proprietary pricing strategy, stored as agent memory during a planning session, appears in Customer B's recalled context. This is not a hypothetical — it's a predictable failure mode of flat memory stores.
- Privilege escalation: A read-only analytics agent gains write access to memory through a misconfigured key, allowing it to corrupt or inject false memories that influence future agent behavior.
- GDPR deletion gaps: A user requests data deletion under GDPR Article 17. Because their memories are mixed into a shared namespace, it's impossible to surgically delete all records for that user without manual intervention.
- Insider threat vectors: A user elevated from "viewer" to "editor" role gains access to organizational memories accumulated before their role change, creating an unintended retroactive access grant.
Architecture
This pattern uses Dakera's namespace system as the primary security boundary, enforced server-side. Each tenant, user, or team is an agent, and an agent's memories live in their own namespace, _dakera_agent_{agent_id}. An API key has one scope (read, write, admin or super_admin, each including the one before) and a grant list of the namespaces it reaches (exact names or prefix patterns such as _dakera_agent_user-acme-*). A key cannot be read+write on one namespace and read-only on another, so a user who writes their own memories and reads shared knowledge gets two keys: a write key and a read key. Admin keys manage keys and namespaces and are never distributed to client-facing applications. Cross-namespace access is configured in the key grants, not in application code.
Implementation Steps
-
Design your namespace hierarchy before writing any codeMap out the three levels as agents: user agents (
user-{userId}) for private memories; team agents (team-{teamId}) for collaborative knowledge; shared agents (shared-org,shared-docs) for read-mostly organizational knowledge. Each one's memories live in_dakera_agent_{agent_id}, the namespace your keys grant. Sketch the permission matrix for each role before provisioning. Changing the hierarchy later is painful, so design it correctly upfront. -
Create the agents during provisioningAn agent's namespace is created by its first stored memory, or ahead of time with
POST /v1/agents(v0.12.2,create_agent()), which needs write on_dakera_agent_{agent_id}. Create the agents from your provisioning (Terraform, Pulumi, or deployment scripts), not at runtime in application code: the shared ones once, user and team ones during onboarding. Never expose admin keys to application servers. -
Issue scoped API keys per user/tenant during onboardingWhen a user signs up, your onboarding service creates two keys for them with
POST /admin/keys(super_admin): a write key granted_dakera_agent_user-{userId}and, if they belong to a team,_dakera_agent_team-{teamId}; and a read key granted_dakera_agent_shared-org. Setexpires_in_dayson both. Store this scoped key in your secret management system (Vault, AWS Secrets Manager), never in environment variables or code. -
Edit grants on team changes, issue a new key on scope changesWhen a user changes team, replace the write key's grants in place with
PATCH /admin/keys/{key_id}(update_key()); the key keeps its ID and secret. A key's scope cannot change, so a move from read to write access means a new key: issue it, update the stored reference, then delete or deactivate the old key. Rotate secrets on a schedule withPOST /admin/keys/{key_id}/rotate;grace_secskeeps the old secret valid while clients move. -
Implement GDPR deletion by deleting the user's namespaceWhen a user requests data deletion, your admin service deletes their namespace with
DELETE /v1/namespaces/_dakera_agent_user-{userId}(delete_namespace()), which removes it and all its data, then deletes their keys. Because data is namespace-isolated, you can surgically delete exactly their data without touching shared namespaces. Document this deletion flow in your privacy policy and test it quarterly.
A common mistake is checking permissions in application code before calling Dakera: "if user.role == 'admin' then query all namespaces." This is defense-in-depth theater — a compromised application server bypasses all your checks. Real security requires server-side enforcement via scoped API keys. Use application-layer checks only for UX (showing/hiding UI elements), never as the actual security boundary.
Implementation
# Each memory owner is an agent; its memories live in the namespace _dakera_agent_{agent_id}.
# ADMIN: create the agents (and their namespaces) during provisioning (v0.12.2)
curl -X POST http://localhost:3000/v1/agents \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{"agent_id": "shared-org"}'
curl -X POST http://localhost:3000/v1/agents \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{"agent_id": "team-engineering"}'
curl -X POST http://localhost:3000/v1/agents \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{"agent_id": "user-alice-chen"}'
# ADMIN: a key has ONE scope plus a grant list, so Alice gets two keys.
# Key 1: write (includes read) on her own namespace and her team's
curl -X POST http://localhost:3000/admin/keys \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{
"name": "alice-chen-write",
"scope": "write",
"namespaces": ["_dakera_agent_user-alice-chen", "_dakera_agent_team-engineering"],
"expires_in_days": 90
}'
# Key 2: read-only on the shared organizational knowledge
curl -X POST http://localhost:3000/admin/keys \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{
"name": "alice-chen-read-shared",
"scope": "read",
"namespaces": ["_dakera_agent_shared-org"],
"expires_in_days": 90
}'
# USER: Alice stores a private memory with her write key
curl -X POST http://localhost:3000/v1/memory/store \
-H "Authorization: Bearer dk-alice-write-key" \
-H "Content-Type: application/json" \
-d '{"agent_id": "user-alice-chen", "content": "My private notes: negotiating salary increase", "importance": 0.85}'
# USER: Alice attempts to write another user's namespace -- BLOCKED
curl -X POST http://localhost:3000/v1/memory/store \
-H "Authorization: Bearer dk-alice-write-key" \
-H "Content-Type: application/json" \
-d '{"agent_id": "user-bob-smith", "content": "Cross-tenant write attempt"}'
# Returns: 403 Forbidden
# USER: check what a key reaches (scope, namespaces), never the key itself
curl http://localhost:3000/v1/auth/whoami \
-H "Authorization: Bearer dk-alice-write-key"
# ADMIN: Alice changes team -- replace the key's grants in place (scope cannot change)
curl -X PATCH http://localhost:3000/admin/keys/dk_key_alice_write \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{"namespaces": ["_dakera_agent_user-alice-chen", "_dakera_agent_team-platform"]}'
# ADMIN: GDPR deletion -- delete Alice's namespace with all its data, then her keys
curl -X DELETE http://localhost:3000/v1/namespaces/_dakera_agent_user-alice-chen \
-H "Authorization: Bearer dk-super-admin-key"
curl -X DELETE http://localhost:3000/admin/keys/dk_key_alice_write \
-H "Authorization: Bearer dk-super-admin-key"from dakera import DakeraClient
from typing import Optional
# Admin client (super_admin key) -- used ONLY for provisioning, never in application code
admin = DakeraClient(base_url="http://localhost:3000", api_key="dk-super-admin-key")
def ns(agent_id: str) -> str:
"""An agent's memories live in the namespace _dakera_agent_{agent_id}."""
return f"_dakera_agent_{agent_id}"
SHARED_AGENTS = ["shared-org", "shared-docs"]
# --- PROVISIONING: Run once during infrastructure setup ---
def setup_shared_agents() -> None:
"""Create the organization-level agents (and their namespaces) ahead of time."""
for agent_id in SHARED_AGENTS:
admin.create_agent(agent_id) # existing agents are left untouched
def onboard_user(user_id: str, team_id: Optional[str] = None) -> dict:
"""Provision a new user: create their agent and issue two scoped keys.
A key has one scope plus a namespace grant list, so read+write on the
user's own (and team) namespace and read-only on shared knowledge
need two keys.
"""
admin.create_agent(f"user-{user_id}")
write_grants = [ns(f"user-{user_id}")]
if team_id:
write_grants.append(ns(f"team-{team_id}"))
write_key = admin.create_key(
name=f"{user_id}-write", scope="write", namespaces=write_grants, expires_in_days=90
)
read_key = admin.create_key(
name=f"{user_id}-read-shared", scope="read",
namespaces=[ns(a) for a in SHARED_AGENTS], expires_in_days=90
)
# Store both keys in your secrets manager; the key value is shown only once
# secrets_manager.store(f"dakera-write-{user_id}", write_key["key"])
# secrets_manager.store(f"dakera-read-{user_id}", read_key["key"])
return {"write_key_id": write_key["key_id"], "read_key_id": read_key["key_id"]}
def change_team(write_key_id: str, user_id: str, new_team_id: str) -> None:
"""Replace the write key's grants in place (scope cannot change)."""
admin.update_key(write_key_id, namespaces=[ns(f"user-{user_id}"), ns(f"team-{new_team_id}")])
# --- APPLICATION: Runtime operations using the user's scoped keys ---
class UserMemoryClient:
"""Application-level wrapper; Dakera enforces what each key may do."""
def __init__(self, user_id: str, write_key: str, read_key: str):
self.agent_id = f"user-{user_id}"
self.client = DakeraClient(base_url="http://localhost:3000", api_key=write_key)
self.shared = DakeraClient(base_url="http://localhost:3000", api_key=read_key)
def store_private(self, content: str, importance: float = 0.8) -> None:
"""Store private memory -- only keys granted this namespace can reach it."""
self.client.store_memory(
agent_id=self.agent_id,
content=content,
importance=importance,
memory_type="semantic"
)
def recall_private(self, query: str, top_k: int = 5) -> list:
return self.client.recall(agent_id=self.agent_id, query=query, top_k=top_k).memories
def recall_team(self, team_id: str, query: str, top_k: int = 5) -> list:
return self.client.recall(agent_id=f"team-{team_id}", query=query, top_k=top_k).memories
def recall_shared(self, query: str, top_k: int = 5) -> list:
"""Read-only key: a store_memory() into shared-org would get 403."""
return self.shared.recall(agent_id="shared-org", query=query, top_k=top_k).memories
def build_agent_context(self, query: str) -> str:
"""Two separately authorized recalls, merged in your application."""
private = self.recall_private(query, top_k=5)
shared = self.recall_shared(query, top_k=3)
parts = []
if private:
parts.append("[Your private context]\n" + "\n".join(m.content for m in private))
if shared:
parts.append("[Organizational knowledge]\n" + "\n".join(m.content for m in shared))
return "\n\n".join(parts)
# --- GDPR DELETION: Admin-only ---
def delete_user_data(user_id: str, key_ids: list[str]) -> None:
"""Permanently delete a user's memories and keys. GDPR Article 17 compliance."""
admin.delete_namespace(ns(f"user-{user_id}")) # the namespace and all its data
for key_id in key_ids:
admin.delete_key(key_id)
print(f"Deleted namespace and {len(key_ids)} keys for user {user_id}")import { DakeraClient } from '@dakera-ai/dakera';
// Admin client (super_admin key) -- provisioning only, never shipped to client apps
const admin = new DakeraClient({ baseUrl: 'http://localhost:3000', apiKey: process.env.DAKERA_ADMIN_KEY });
// An agent's memories live in the namespace _dakera_agent_{agentId}
const ns = (agentId: string) => `_dakera_agent_${agentId}`;
const SHARED_AGENTS = ['shared-org', 'shared-docs'];
// --- Provisioning: one write key and one read key per user (a key has one scope) ---
async function onboardUser(userId: string, teamId?: string) {
await admin.createAgent(`user-${userId}`);
const writeKey = await admin.createKey({
name: `${userId}-write`,
scope: 'write',
namespaces: [ns(`user-${userId}`), ...(teamId ? [ns(`team-${teamId}`)] : [])],
expires_in_days: 90,
});
const readKey = await admin.createKey({
name: `${userId}-read-shared`,
scope: 'read',
namespaces: SHARED_AGENTS.map(ns),
expires_in_days: 90,
});
// Store writeKey.key and readKey.key in your secrets manager -- shown only once
return { writeKeyId: writeKey.key_id, readKeyId: readKey.key_id };
}
// Team change: replace the write key's grants in place (scope cannot change)
async function changeTeam(writeKeyId: string, userId: string, newTeamId: string) {
await admin.updateKey(writeKeyId, { namespaces: [ns(`user-${userId}`), ns(`team-${newTeamId}`)] });
}
// --- Runtime: user-scoped clients ---
class UserMemoryClient {
private client: DakeraClient;
private shared: DakeraClient;
private agentId: string;
constructor(userId: string, writeKey: string, readKey: string) {
this.agentId = `user-${userId}`;
this.client = new DakeraClient({ baseUrl: 'http://localhost:3000', apiKey: writeKey });
this.shared = new DakeraClient({ baseUrl: 'http://localhost:3000', apiKey: readKey });
}
async storePrivate(content: string, importance = 0.8) {
await this.client.storeMemory(this.agentId, { content, importance, memory_type: 'semantic' });
}
async recallPrivate(query: string, topK = 5) {
const { memories } = await this.client.recall(this.agentId, query, { top_k: topK });
return memories;
}
async recallShared(query: string, topK = 3) {
// Read-only key: a store into shared-org would get 403
const { memories } = await this.shared.recall('shared-org', query, { top_k: topK });
return memories;
}
// Two separately authorized recalls, merged in your application
async buildContext(query: string): Promise<string> {
const [priv, shared] = await Promise.all([this.recallPrivate(query), this.recallShared(query)]);
const parts: string[] = [];
if (priv.length) parts.push('[Your private context]\n' + priv.map(m => m.content).join('\n'));
if (shared.length) parts.push('[Organizational knowledge]\n' + shared.map(m => m.content).join('\n'));
return parts.join('\n\n');
}
}
// GDPR deletion: the namespace and all its data, then the user's keys
async function deleteUserData(userId: string, keyIds: string[]) {
await admin.deleteNamespace(ns(`user-${userId}`));
for (const id of keyIds) await admin.deleteKey(id);
}use dakera_client::{CreateKeyRequest, DakeraClient, UpdateKeyRequest};
// Admin client (super_admin key): provisioning only
let admin = DakeraClient::builder("http://localhost:3000")
.api_key(std::env::var("DAKERA_ADMIN_KEY")?)
.build()?;
let ns = |agent: &str| format!("_dakera_agent_{}", agent);
// Create the user's agent (and namespace) ahead of time (v0.12.2)
admin.create_agent("user-alice-chen").await?;
// A key has one scope, so Alice gets a write key and a read key
let write_key = admin.create_key(CreateKeyRequest {
name: "alice-chen-write".into(),
scope: "write".into(),
namespaces: Some(vec![ns("user-alice-chen"), ns("team-engineering")]),
expires_in_days: Some(90),
}).await?;
let read_key = admin.create_key(CreateKeyRequest {
name: "alice-chen-read-shared".into(),
scope: "read".into(),
namespaces: Some(vec![ns("shared-org")]),
expires_in_days: Some(90),
}).await?;
println!("store {} and {} in your secrets manager", write_key.key_id, read_key.key_id);
// Team change: replace the write key's grants in place
admin.update_key(
&write_key.key_id,
&UpdateKeyRequest::new().with_namespaces(vec![ns("user-alice-chen"), ns("team-platform")]),
).await?;
// GDPR deletion: the namespace and all its data, then the keys
admin.delete_namespace(&ns("user-alice-chen")).await?;
admin.delete_key(&write_key.key_id).await?;
admin.delete_key(&read_key.key_id).await?;admin := dakera.NewClientWithOptions(dakera.ClientOptions{
BaseURL: "http://localhost:3000",
APIKey: os.Getenv("DAKERA_ADMIN_KEY"), // super_admin: provisioning only
})
ctx := context.Background()
ns := func(agent string) string { return "_dakera_agent_" + agent }
days := 90
// Create the user's agent (and namespace) ahead of time (v0.12.2)
admin.CreateAgent(ctx, "user-alice-chen")
// A key has one scope, so Alice gets a write key and a read key
writeKey, _ := admin.CreateKey(ctx, dakera.CreateKeyRequest{
Name: "alice-chen-write",
Scope: "write",
Namespaces: dakera.GrantNamespaces(ns("user-alice-chen"), ns("team-engineering")),
ExpiresInDays: &days,
})
readKey, _ := admin.CreateKey(ctx, dakera.CreateKeyRequest{
Name: "alice-chen-read-shared",
Scope: "read",
Namespaces: dakera.GrantNamespaces(ns("shared-org")),
ExpiresInDays: &days,
})
// writeKey.Key and readKey.Key are shown only once: store them in your secrets manager
// Team change: replace the write key's grants in place (scope cannot change)
admin.UpdateKey(ctx, writeKey.ID, dakera.UpdateKeyRequest{
Namespaces: dakera.GrantNamespaces(ns("user-alice-chen"), ns("team-platform")),
})
// GDPR deletion: the namespace and all its data, then the keys
admin.DeleteNamespace(ctx, ns("user-alice-chen"))
admin.DeleteKey(ctx, writeKey.ID)
admin.DeleteKey(ctx, readKey.ID)Production-ready memory isolation from day one
Dakera's namespace system gives you GDPR-compliant tenant isolation with server-enforced access control — no custom security infrastructure needed.
Real-World Scenario: Multi-Tenant SaaS Platform
A B2B SaaS platform deploys an AI assistant to 340 enterprise customers. Each customer has 10–500 users across multiple teams. Memory permissions are critical for their SOC 2 Type II compliance.
- Agent naming:
user-{customerId}-{userId}(private),team-{customerId}-{teamId}(collaborative),shared-{customerId}(tenant-wide knowledge). The shared customer ID in every name lets one prefix pattern cover a tenant. - Key provisioning: During customer onboarding, the platform creates a tenant admin key (scope
admin, granted the tenant's prefixes such as_dakera_agent_user-{customerId}-*). When the customer adds users, your API calls Dakera as that tenant admin to issue each user's keys, which can only grant namespaces inside the tenant admin's own grants. - Audit trail: Every API key has a
nametied to the user ID, and Dakera's audit log records one entry per REST request and gRPC call. SOC 2 auditors can verify that Customer A's keys have never accessed Customer B's namespaces. - GDPR compliance: When a user submits a deletion request, the platform's compliance service uses the admin key to delete their user namespace and keys, completing the deletion within the required 30-day window. Deletion is logged and verifiable.
- Compromised application server: the server only holds user and tenant keys, and none of them grants another tenant's namespaces, so Dakera refuses cross-tenant requests even from a compromised server.
Before / After Memory State
// Single namespace: "default"
// All users share one pool
// Alice's private salary note:
"salary negotiation target: $180k"
// Bob queries "salary" and
// Alice's note appears in his
// context.
// Data leak.
// SOC 2 fail.
// GDPR violation.
// Customer loses trust.
// user-alice-chen:
"salary negotiation target: $180k"
// alice's write key: this ns
// user-bob-smith: (empty)
// bob's write key: own ns only
// Bob queries "salary":
// Dakera checks bob's key:
// 403 for user-alice-chen
// Result: zero cross-user leakage
// SOC 2 compliant. GDPR ready.
SDK Method Reference
| Method | SDK | Purpose in this pattern |
|---|---|---|
create_agent(agent_id) | Python | Create an agent and its namespace ahead of its first memory |
create_key(name, scope=, namespaces=, expires_in_days=) | Python | Super admin: issue a key with one scope and a grant list |
update_key(key_id, namespaces=) | Python | Super admin: replace a key's grants in place |
rotate_key(key_id, grace_secs=) | Python | Super admin: new secret, old one valid for the grace period |
delete_key(key_id) / delete_namespace(ns) | Python | GDPR deletion of a user's keys and namespace |
whoami() | Python | The scope and namespaces of the calling key |
createKey({name, scope, namespaces}) | TypeScript | Issue a scoped key |
updateKey(keyId, {namespaces}) | TypeScript | Replace a key's grants |
admin.create_key(CreateKeyRequest{...}).await? | Rust | Issue a scoped key |
admin.update_key(&key_id, &UpdateKeyRequest::new().with_namespaces(...)).await? | Rust | Replace a key's grants |
admin.CreateKey(ctx, dakera.CreateKeyRequest{...}) | Go | Issue a scoped key |
admin.UpdateKey(ctx, keyID, dakera.UpdateKeyRequest{...}) | Go | Replace a key's grants |
Edge Cases and Gotchas
- Namespace collisions in multi-tenant scenarios:
user-aliceis ambiguous in a multi-tenant system — which tenant's Alice? Always include the tenant/customer ID:user-{customerId}-{userId}. Design namespace naming conventions upfront and enforce them with validation in your provisioning service. - Read-only keys and stale permissions: If you issue a read-only key to an analytics system and later need to give it write access, you must create a new key: a key's scope cannot change, only its name and namespaces (
PATCH). This is by design. This is a security feature, not a limitation. Plan for this in your key management workflow. - Cross-namespace context building: An agent context that draws from both private and shared namespaces requires two separate recall calls. This is intentional — each call is independently authorized. Never try to merge these calls or work around the boundary. The separation is the security guarantee.
- Namespace discovery: A user with a scoped key cannot discover namespaces they don't have access to: namespace listings show a key only the namespaces it can reach. This is by design for privacy.
GET /v1/auth/whoamishows a key its own scope and grants. - Backups and GDPR timing: deleting a namespace removes it from the live server, but backups taken earlier still carry its data. For GDPR right-to-erasure compliance, include backup retention in your deletion timeline and document it in your data processing agreement.
- Prefix patterns are strict:
team-*reachesteam-abut notteamorteam-, a pattern never reaches a server-internal namespace, and patterns that could cover internal names (_dakera_*,_*) are refused with 400. Name tenants so one prefix covers exactly one tenant.
A SaaS platform with 1,000 customers and 50 users each needs 50,000 user agents, each with its own namespace, plus thousands of team and shared ones, and two keys per user. Your provisioning and key management infrastructure needs to handle this volume. Use Infrastructure-as-Code (Terraform) to manage namespace lifecycle at scale.
Performance Considerations
Advanced Configuration: Hierarchical namespace inheritance
For complex enterprise deployments, implement a hierarchical permission model where tenant admin keys can issue sub-scoped keys for their own tenant's namespaces:
# Enterprise hierarchy:
# super_admin -> tenant admin key -> user keys
# 1. Provisioning (super_admin): a tenant admin key for acme,
# scope admin, granted only acme's prefixes (v0.12.2 patterns)
curl -X POST http://localhost:3000/admin/keys \
-H "Authorization: Bearer dk-super-admin-key" \
-H "Content-Type: application/json" \
-d '{"name": "acme-tenant-admin", "scope": "admin",
"namespaces": ["_dakera_agent_user-acme-*", "_dakera_agent_team-acme-*", "_dakera_agent_shared-acme"]}'
# 2. Tenant portal (acme's admin key): issue a user's write key.
# extra_namespaces must lie inside the caller's own grants (403 otherwise),
# and the scope is at most admin.
curl -X POST http://localhost:3000/v1/namespaces/_dakera_agent_user-acme-alice/keys \
-H "Authorization: Bearer dk-acme-tenant-admin" \
-H "Content-Type: application/json" \
-d '{"name": "acme-alice-write", "scope": "write",
"extra_namespaces": ["_dakera_agent_team-acme-sales"], "expires_in_days": 90}'
# 3. Edit that key's grants later, within the tenant's prefixes
curl -X PATCH http://localhost:3000/v1/namespaces/_dakera_agent_user-acme-alice/keys/dk_key_alice \
-H "Authorization: Bearer dk-acme-tenant-admin" \
-H "Content-Type: application/json" \
-d '{"namespaces": ["_dakera_agent_user-acme-alice", "_dakera_agent_team-acme-support"]}'
# The acme admin can never grant _dakera_agent_user-widgets-* (another tenant)
# or "*": no global admin key in the tenant portal at runtime
Run this pattern against a real Dakera.
The Playground executes live API calls in your browser. No account, no install. Then run it on your own server in five minutes.