Overview
Azen uses organization-based multi-tenancy to separate data between different teams and companies. Every memory, API key, and resource belongs to an organization.
Multi-Tenancy Architecture
Database Schema
Organization Table
Core organization data (packages/db/src/db/schema.ts:130-142):
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
description: text("description"),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
Key Fields:
id: Unique identifier (UUID)
slug: URL-friendly identifier (unique, for routing)
name: Display name
metadata: JSON for extensibility
Member Table
User-organization relationships (packages/db/src/db/schema.ts:144-161):
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
Roles:
member: Default role, basic access
- Custom roles can be added (owner, admin, viewer, etc.)
Invitation Table
Pending organization invitations (packages/db/src/db/schema.ts:163-183):
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
Status Values:
pending: Invitation sent, not yet accepted
accepted: User joined the organization
expired: Invitation past expiresAt date
revoked: Manually cancelled
Organization Relationships
Every major resource includes an organizationId foreign key:
Memory → Organization
export const memory = pgTable("Memory", {
id: text("id").primaryKey(),
userId: text("user_id").notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.references(() => organization.id, { onDelete: "cascade" }),
// ...
});
From packages/db/src/db/schema.ts:230-245.
API Key → Organization
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.references(() => organization.id, { onDelete: "cascade" }),
// ...
},
// ...
);
From packages/db/src/db/schema.ts:92-128.
Dual Attribution:
userId: Creator/owner of the API key
organizationId: Billing and data isolation boundary
EmbeddingJob → Organization
export const embeddingJob = pgTable("EmbeddingJob", {
id: text("id").primaryKey(),
memoryId: text("memory_id").notNull()
.references(() => memory.id, { onDelete: "cascade" }),
userId: text("user_id").notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.references(() => organization.id, { onDelete: "cascade" }),
// ...
});
From packages/db/src/db/schema.ts:252-271.
Usage Tracking → Organization
export const apikeyUsage = pgTable(
"api_usage",
{
id: text("id").primaryKey().notNull(),
userId: text("user_id").notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.references(() => organization.id, { onDelete: "cascade" }),
apiKeyId: text("api_key_id").notNull(),
date: text("date").notNull(),
routeGroup: text("route_group").notNull(),
totalRequests: integer("total_requests").notNull().default(0),
// ...
},
// ...
);
From packages/db/src/db/schema.ts:191-223.
Unique Index (packages/db/src/db/schema.ts:214-220):
usageUnique: uniqueIndex("ux_api_usage_unique").on(
table.organizationId,
table.userId,
table.apiKeyId,
table.date,
table.routeGroup
)
This ensures accurate aggregation of usage data per organization.
Session-Based Organization Context
The session table tracks which organization a user is currently working in:
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
userId: text("user_id").notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
// ...
},
// ...
);
From packages/db/src/db/schema.ts:27-45.
Users can be members of multiple organizations. The activeOrganizationId determines which organization’s data they’re currently accessing.
Authentication and Authorization
The authMiddleware enforces organization context on every API request (apps/api/src/middlewares/authMiddleware.ts:5-54).
Session-Based Auth
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (session && session.user) {
const userId = session.user.id;
const organizationId = session.session.activeOrganizationId;
if (!organizationId) {
throw new HTTPException(403, { message: "Unauthorized" });
}
c.set("userId", userId);
c.set("organizationId", organizationId);
return await next();
}
Flow:
- Extract session from request headers
- Get
activeOrganizationId from session
- If missing, reject request (user must select an organization)
- Set context variables for downstream handlers
API Key Auth
const key = c.req.header("azen-api-key") ?? "";
if (!key) throw new HTTPException(401, { message: "no api key" });
const response = await auth.api.verifyApiKey({ body: { key } });
if (!response || !response.valid) {
throw new HTTPException(403, {
message: response.error?.message ?? "Invalid API key",
});
}
const userId = response.key?.userId;
const apiKeyId = response.key?.id;
const organizationId = response.key?.metadata?.organizationId;
if (!organizationId || !userId || !apiKeyId) {
throw new HTTPException(403, {
message: "Invalid API key",
});
}
c.set("userId", userId);
c.set("apiKeyId", apiKeyId);
c.set("organizationId", organizationId);
Flow:
- Extract API key from
azen-api-key header
- Verify key validity and rate limits
- Extract
organizationId from key metadata
- Set context variables
Every API key must have organizationId in metadata. Keys without an organization are rejected.
Data Isolation
Organization ID is enforced at three layers:
1. Database Queries
Every query includes organizationId filter. Example from apps/api/src/routes/memory.ts:94-111:
const items = await db
.select({
id: memory.id,
encryptedContent: memory.encryptedContent,
iv: memory.iv,
tag: memory.tag,
metadata: memory.metadata,
createdAt: memory.createdAt,
embedded: memory.embedded,
})
.from(memory)
.where(eq(memory.organizationId, organizationId))
.orderBy(desc(memory.createdAt))
.offset(offset)
.limit(per);
2. Vector Namespaces
Pinecone namespaces isolate embeddings. From apps/api/src/jobs/embed-job.ts:25:
const namespace = `org-${organizationId}`;
await upsertVectors(ids, vectors, namespace, memoryID);
Each organization’s vectors are stored in a separate namespace:
- Organization
org-123 → namespace org-org-123
- Organization
org-456 → namespace org-org-456
Vector Search (apps/api/src/routes/search.ts:42-43):
const namespace = `org-${organizationId}`;
const matches = await queryVectors(qEmb, topK, namespace);
Pinecone namespaces provide logical separation. Queries against one namespace cannot access vectors in another.
3. Cascading Deletes
When an organization is deleted, all related data is automatically removed:
organization (deleted)
↓ CASCADE
├─ members (deleted)
├─ memories (deleted)
│ ↓ CASCADE
│ └─ embeddingJobs (deleted)
├─ apikeys (deleted)
│ ↓ CASCADE
│ └─ apikeyUsage (deleted)
└─ invitations (deleted)
This is enforced by foreign key constraints with onDelete: "cascade" in the schema.
Organization Lifecycle
1. Creation
Organizations are typically created during onboarding:
const orgId = randomUUID();
await db.insert(organization).values({
id: orgId,
name: "Acme Corp",
slug: "acme-corp",
createdAt: new Date(),
});
// Add creator as first member
await db.insert(member).values({
id: randomUUID(),
organizationId: orgId,
userId: creatorUserId,
role: "owner",
createdAt: new Date(),
});
2. Inviting Members
const inviteId = randomUUID();
await db.insert(invitation).values({
id: inviteId,
organizationId: orgId,
email: "[email protected]",
role: "member",
status: "pending",
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
inviterId: currentUserId,
});
// Send invitation email
await sendInvitationEmail(inviteId);
3. Accepting Invitations
const [invite] = await db
.select()
.from(invitation)
.where(
and(
eq(invitation.id, inviteId),
eq(invitation.status, "pending"),
gt(invitation.expiresAt, new Date())
)
);
if (!invite) {
throw new Error("Invalid or expired invitation");
}
// Create member record
await db.insert(member).values({
id: randomUUID(),
organizationId: invite.organizationId,
userId: currentUserId,
role: invite.role ?? "member",
createdAt: new Date(),
});
// Mark invitation as accepted
await db
.update(invitation)
.set({ status: "accepted" })
.where(eq(invitation.id, inviteId));
4. Switching Organizations
Users can switch between organizations by updating their session:
await db
.update(session)
.set({ activeOrganizationId: newOrgId })
.where(eq(session.id, sessionId));
Subsequent requests will use the new organization context.
5. Deletion
Deleting an organization triggers cascading deletes:
// This will cascade to all related records
await db
.delete(organization)
.where(eq(organization.id, orgId));
// Separately clean up Pinecone namespace
const namespace = `org-${orgId}`;
await index.namespace(namespace).deleteAll();
Organization deletion is permanent and irreversible. All memories, embeddings, and usage data are lost.
Multi-Tenancy Patterns
Azen uses shared database, shared schema multi-tenancy:
Advantages
- Cost Efficiency: Single database instance for all tenants
- Operational Simplicity: One schema to maintain
- Query Performance: Database-level optimizations apply to all tenants
- Cross-Tenant Analytics: Possible (with proper access controls)
Disadvantages
- Noisy Neighbor: One tenant can impact others (mitigated by connection pooling)
- Data Leakage Risk: Requires careful query filtering (every query must include
organizationId)
- Scaling Limits: Single database has throughput ceiling (can shard by organization later)
Alternative: Database-per-Tenant
For very large organizations, you could:
- Create separate database instances
- Route queries based on
organizationId
- Provide dedicated compute resources
Azen’s current architecture supports this via connection string routing.
Security Considerations
Query Injection Prevention
Drizzle ORM prevents SQL injection:
// SAFE: Parameterized query
await db
.select()
.from(memory)
.where(eq(memory.organizationId, organizationId));
// UNSAFE: String interpolation (Drizzle doesn't allow this)
// await db.execute(`SELECT * FROM memory WHERE organization_id = '${organizationId}'`);
Privilege Escalation Prevention
Users cannot access organizations they’re not members of:
// Check membership before granting access
const [membership] = await db
.select()
.from(member)
.where(
and(
eq(member.userId, userId),
eq(member.organizationId, requestedOrgId)
)
);
if (!membership) {
throw new HTTPException(403, { message: "Not a member of this organization" });
}
API Key Scoping
API keys are tied to a single organization:
const organizationId = response.key?.metadata?.organizationId;
if (!organizationId) {
throw new HTTPException(403, { message: "API key not associated with an organization" });
}
Best Practice: Generate separate API keys per organization, even if the same user is a member of multiple organizations.
Billing and Usage Attribution
Organization ID is the billing boundary:
Usage Aggregation
const usage = await db
.select({
totalRequests: sum(apikeyUsage.totalRequests),
memoryCount: sum(apikeyUsage.memoryCount),
searchCount: sum(apikeyUsage.searchCount),
})
.from(apikeyUsage)
.where(
and(
eq(apikeyUsage.organizationId, orgId),
gte(apikeyUsage.date, billingPeriodStart),
lte(apikeyUsage.date, billingPeriodEnd)
)
);
Cost Allocation
- Memory Storage: Count of
memory records per organization
- Vector Storage: Count of vectors in
org-{id} namespace
- Embedding Generation: Count of
embeddingJob records with status “done”
- Search Queries: Count of search requests via
apikeyUsage.searchCount