NewRelease 0.12.3 is out: Engine v0.12.3, Dashboard 0.5.2, Helm 0.12.3, the four SDKs, CLI 0.8.2 and MCP 0.12.3Release 0.12.3 is outSee what's new →
Advanced Security

Memory Permissions

⏱ ~45 min to implement 📦 Requires: Dakera v0.12.2+

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 →
Prerequisites
  • 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:

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.

Architecture of memory permissions. A client app sends each request with one of the user's two keys; an onboarding service that holds the admin key, never shipped in apps, creates the agents and the keys. Inside the boundary Dakera enforces, the user's write key reaches user-{userId} and, if the user is in a team, team-{teamId}; the read key reaches shared-org only. Anything else gets 403.Architecture of memory permissions. A client app sends each request with one of the user's two keys; an onboarding service that holds the admin key, never shipped in apps, creates the agents and the keys. Inside the boundary Dakera enforces, the user's write key reaches user-{userId} and, if the user is in a team, team-{teamId}; the read key reaches shared-org only. Anything else gets 403.
One scope per key: a write key for the user's own namespaces, a read key for shared knowledge.Full size ↗Full size ↗
Sequence of requests from Bob's app with his two scoped keys. A recall for salary with his write key is checked against the key, its namespaces and the operation, then reads user-bob-smith and returns results. A second, separate recall with his read key reads shared-org. A store_memory into shared-org gets 403, because no key of Bob's grants write there, and a recall in user-alice-chen gets 403 Forbidden.Sequence of requests from Bob's app with his two scoped keys. A recall for salary with his write key is checked against the key, its namespaces and the operation, then reads user-bob-smith and returns results. A second, separate recall with his read key reads shared-org. A store_memory into shared-org gets 403, because no key of Bob's grants write there, and a recall in user-alice-chen gets 403 Forbidden.
Each call is authorized on its own, so private and shared reads stay separate.Full size ↗Full size ↗

Implementation Steps

Warning: Never implement permission logic in your application layer

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.

Try Free →

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.

Before / After Memory State

Before: No Access Control
// 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.
After: Namespace Isolation Active
// 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

MethodSDKPurpose in this pattern
create_agent(agent_id)PythonCreate an agent and its namespace ahead of its first memory
create_key(name, scope=, namespaces=, expires_in_days=)PythonSuper admin: issue a key with one scope and a grant list
update_key(key_id, namespaces=)PythonSuper admin: replace a key's grants in place
rotate_key(key_id, grace_secs=)PythonSuper admin: new secret, old one valid for the grace period
delete_key(key_id) / delete_namespace(ns)PythonGDPR deletion of a user's keys and namespace
whoami()PythonThe scope and namespaces of the calling key
createKey({name, scope, namespaces})TypeScriptIssue a scoped key
updateKey(keyId, {namespaces})TypeScriptReplace a key's grants
admin.create_key(CreateKeyRequest{...}).await?RustIssue a scoped key
admin.update_key(&key_id, &UpdateKeyRequest::new().with_namespaces(...)).await?RustReplace a key's grants
admin.CreateKey(ctx, dakera.CreateKeyRequest{...})GoIssue a scoped key
admin.UpdateKey(ctx, keyID, dakera.UpdateKeyRequest{...})GoReplace a key's grants

Edge Cases and Gotchas

Note: Plan your namespace hierarchy for scale

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

1
Scope per key (read, write, admin or super_admin)
15 s
Open event streams re-check their key's scope and grants
SOC 2
Audit log: one entry per REST request and gRPC call
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
Try it live

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.