Single-Table DynamoDB Design for Multi-Tenant RBAC at Scale
How we designed a single DynamoDB table to serve all of WardenAuth's data: scopes, permissions, roles, policies, API keys, and audit logs — with sub-millisecond access times and no cross-table joins.
When we started building EC-RBAC, we had to decide how to store the data for a multi-tenant RBAC system: scopes, permissions, roles, access policies, API keys, team members, audit logs, SSO configs. That's seven distinct entity types with complex relationships between them.
The conventional approach would be seven DynamoDB tables (or a relational database with seven tables). We went in a different direction: one DynamoDB table for everything.
Why Single-Table Design?
Single-table design is a DynamoDB access pattern made famous by Alex DeBrie in The DynamoDB Book. The core idea: model all your access patterns around a single table, using composite keys (PK + SK) to represent different entity types and relationships.
The advantages for a system like EC-RBAC are significant:
- No cross-table joins. In RBAC, checking access requires fetching the subject's policies, each policy's roles, and each role's permissions. With a single table, all of this lives in one DynamoDB partition.
- Transactional writes. DynamoDB transactions only work within a single table. Creating a scope + an admin role + a default permission set atomically requires single-table design.
- Predictable latency. Access evaluation is on the critical path of every API call. We needed sub-10ms p99. Single-table with correct key design gives us this.
- Cost efficiency. One table means one set of provisioned or on-demand capacity. No capacity stranded across unused tables.
The Key Schema
Every item in our table uses a composite primary key: PK (partition key) and SK (sort key). The values encode the entity type and its identity:
// Scope (a.k.a. tenant)
{ PK: "SCOPE#acme-corp", SK: "SCOPE#acme-corp" }
// Permissions within a scope
{ PK: "SCOPE#acme-corp", SK: "PERMISSION#invoice:read" }
{ PK: "SCOPE#acme-corp", SK: "PERMISSION#invoice:delete" }
// Roles within a scope
{ PK: "SCOPE#acme-corp", SK: "ROLE#admin" }
{ PK: "SCOPE#acme-corp", SK: "ROLE#viewer" }
// Access policy: what roles a subject (user) has
{ PK: "SCOPE#acme-corp", SK: "POLICY#user-123" }
// API keys (scoped to org, not workspace)
{ PK: "ORG#barksoft", SK: "API_KEY#key-abc123" }
// Audit events (with timestamp in SK for range queries)
{ PK: "SCOPE#acme-corp", SK: "AUDIT#2025-06-01T12:00:00Z#evt-xyz" }The key insight: everything within a scope shares the same partition key SCOPE#{id}. This means a single DynamoDB query with PK = "SCOPE#acme-corp" AND SK begins_with "PERMISSION#" returns all permissions for that tenant. No secondary indexes needed for the core RBAC evaluation path.
Access Evaluation: Zero Joins
When a subject requests access to a resource, the evaluation path looks like this:
async function evaluateAccess(subjectId: string, scopeId: string, resource: string, action: string) {
// 1. Fetch subject's policy (their assigned roles)
const policy = await db.getItem({ PK: `SCOPE#${scopeId}`, SK: `POLICY#${subjectId}` })
if (!policy) return { allowed: false, reason: 'no-policy' }
// 2. Batch-fetch all roles in the policy (single DynamoDB BatchGet)
const roleKeys = policy.roles.map(roleId => ({
PK: `SCOPE#${scopeId}`,
SK: `ROLE#${roleId}`,
}))
const roles = await db.batchGetItems(roleKeys)
// 3. Collect all permission IDs from all roles
const permissionIds = roles.flatMap(role => role.permissions)
// 4. Batch-fetch all permissions (single DynamoDB BatchGet)
const permKeys = permissionIds.map(permId => ({
PK: `SCOPE#${scopeId}`,
SK: `PERMISSION#${permId}`,
}))
const permissions = await db.batchGetItems(permKeys)
// 5. Evaluate: deny-wins semantics
const matches = permissions.filter(p => p.resource === resource && p.action === action)
if (matches.some(p => p.effect === 'deny')) return { allowed: false, reason: 'explicit-deny' }
if (matches.some(p => p.effect === 'allow')) return { allowed: true }
return { allowed: false, reason: 'no-matching-permission' }
}That's three DynamoDB operations: GetItem, BatchGetItems, BatchGetItems. All operations target the same partition (same scope), so they hit the same DynamoDB partition and are served from cache or a single storage node. Typical latency: 3–8ms end-to-end including Lambda overhead.
Wildcard Permissions and Deny-Wins
EC-RBAC supports wildcard permissions: invoice:* matches any action on invoices. *:read matches read on any resource. *:* is a superadmin grant.
Deny-wins semantics mean an explicit effect: "deny" on any matching permission overrides any number of allow permissions. This is critical for compliance use cases: you can grant broad access via one role, then restrict specific actions via another.
Global Secondary Indexes
Single-table doesn't mean single access pattern. We use three GSIs for queries that don't fit the primary key pattern:
- GSI1: Org name uniqueness checks (PK: ORG_NAME, SK: ORG#{id}) and SSO IdP name lookups.
- GSI2: SCIM user listing by org (PK: ORG#{id}, SK: USER_IDENTITY#{sub}). Also powers audit queries by org.
- GSI3: Audit log queries by scope + timestamp (PK: SCOPE#{id}#AUDIT, SK: timestamp). Supports pagination with DynamoDB's native range key ordering.
What We'd Do Differently
Single-table design is powerful but has sharp edges. A few lessons learned:
- Document your access patterns first. Single-table requires you to know your queries upfront. We wrote ours in a
DOMAIN_MODELS.mdbefore writing any code. - GSI fan-out is expensive. Every write that needs to appear in multiple GSIs costs extra WCUs. We keep GSI usage minimal and targeted.
- Item size limits matter for audit logs. DynamoDB items max out at 400KB. For high-frequency audit events, we store minimal data per item and rely on the SK timestamp for ordering.
- Transactions come with a cost. DynamoDB TransactWrite costs 2× the normal WCU. We use them for org provisioning (5–7 items) but not for hot paths like access evaluation.
Conclusion
Single-table DynamoDB design was the right call for EC-RBAC. The core RBAC evaluation path requires three DynamoDB operations with sub-10ms latency, and we get transactional guarantees for tenant provisioning without a relational database. The tradeoff — upfront access pattern design, operational complexity — is worth it at our scale.
If you're building a multi-tenant authorization system and considering DynamoDB, the single-table approach is worth the investment. The access pattern documentation pays for itself in reduced debugging time and operational simplicity.