WardenAuthAgent Security
PricingDocsCompareBlogLearnChangelog
Sign inGet started free
All posts
Architecture
June 1, 2026
12 min read

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:

typescript
// 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:

typescript
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.md before 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.


Back to blogTry WardenAuth free →
© 2026 ecarrizo. All rights reserved.
PricingDocsCompareBlogLearnChangelogStatusGlossaryContactTermsPrivacy