Skip to main content

Permission System Architecture

Understanding AuditSwarm's hierarchical permission model (PermissionServiceV2)


Overview

AuditSwarm implements a two-tier hierarchical permission system designed for enterprise audit management with strict compliance requirements (SOC2, ISO 27001).

Design Goals

  1. Principle of Least Privilege - Users get minimum necessary access
  2. Separation of Concerns - Page access ≠ Entity access
  3. Audit Trail - All permission changes logged
  4. Performance - Sub-100ms permission checks
  5. Compliance-Ready - SOC2/ISO 27001 compatible

Architecture Diagram

┌─────────────────────────────────────────────────────────────┐
│ Permission Check Request │
│ "Can user edit Audit #123?" │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ PermissionServiceV2 (Singleton) │
│ • Caches user permissions (5 min TTL) │
│ • Handles Admin bypass │
│ • Manages permission hierarchy │
└─────────────────────────────────────────────────────────────┘

┌────────────────────┴────────────────────┐
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ Tier 1: Page │ │ Tier 2: Entity │
│ Access Check │ │ Permission Check │
│ │ │ │
│ PageAccess table │ │ EntityPermission │
│ (binary on/off) │ │ (view/edit/none) │
└──────────────────┘ └──────────────────┘
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ Combined Result + Visibility Logic │
│ • Admin = always allow │
│ • No page access = deny │
│ • Permission "none" = deny (explicit block) │
│ • Permission "edit" = allow edit │
│ • Permission "view" = allow view only │
│ • No explicit permission + public entity = allow view │
│ • No explicit permission + private entity = deny │
└─────────────────────────────────────────────────────────────┘

✅ Allow or ❌ Deny

Database Schema

Core Tables

model User {
id String @id @default(cuid())
email String @unique
isAdmin Boolean @default(false)

pageAccess PageAccess[]
entityPermissions EntityPermission[]
}

model PageAccess {
id String @id @default(cuid())
userId String
pageName String // 'audits', 'issues', 'risks', etc.
hasAccess Boolean @default(true)

user User @relation(...)

@@unique([userId, pageName])
@@index([userId])
}

model EntityPermission {
id String @id @default(cuid())
userId String
entityType EntityType // AUDIT, ISSUE, RISK, CONTROL, WORKFLOW
entityId String
permission PermissionLevel // view, edit
isExplicit Boolean @default(true)

user User @relation(...)

@@unique([userId, entityType, entityId])
@@index([userId, entityType])
@@index([entityType, entityId])
}

enum EntityType {
AUDIT
ISSUE
RISK
CONTROL
WORKFLOW
ARTIFACT
DASHBOARD
}

enum PermissionLevel {
view
edit
}

Key Design Decisions

Why Two Separate Tables?

PageAccess and EntityPermission are separate because:

  1. Different access patterns:

    • Page access checked on navigation (8 pages max)
    • Entity permissions checked per-item (100s-1000s of entities)
  2. Different cardinality:

    • Page access: O(users × pages) → ~8 rows per user
    • Entity permissions: O(users × entities) → 100s of rows per user
  3. Performance optimization:

    • Page access loaded once per session
    • Entity permissions cached with short TTL
  4. Compliance requirements:

    • Page access = coarse-grained audit (who can see audits?)
    • Entity permissions = fine-grained audit (who edited Audit #123?)

Permission Service (PermissionServiceV2)

Core Methods

class PermissionServiceV2 {
// Check if user has page access
async hasPageAccess(userId: string, pageName: string): Promise<boolean>

// Check entity permission level
async getEntityPermission(
userId: string,
entityType: EntityType,
entityId: string
): Promise<PermissionLevel | null>

// Convenience methods
async canViewEntity(...): Promise<boolean>
async canEditEntity(...): Promise<boolean>

// Bulk checks (for list filtering)
async filterEntitiesByPermission(
userId: string,
entities: Entity[],
minimumPermission: PermissionLevel
): Promise<Entity[]>
}

Permission Check Algorithm

async function canEditEntity(
userId: string,
entityType: EntityType,
entityId: string
): Promise<boolean> {
// 1. Check if user is admin
const user = await db.user.findUnique({ where: { id: userId } })
if (user?.isAdmin) return true // Admins bypass all checks

// 2. Check page access (Tier 1)
const pageName = entityTypeToPageName(entityType) // 'audit' → 'audits'
const hasPage = await hasPageAccess(userId, pageName)
if (!hasPage) return false // Can't reach the page at all

// 3. Check explicit permission (Tier 2)
const perm = await db.entityPermission.findUnique({
where: { userId_entityType_entityId: { userId, entityType, entityId } }
})

if (perm?.permission === 'none') return false // Explicitly blocked
if (perm?.permission === 'edit') return true // Explicitly allowed
if (perm?.permission === 'view') return false // View-only

// 4. No explicit permission - check entity visibility
const entity = await getEntity(entityType, entityId)
if (entity.visibility === 'public') return false // Public = view only
if (entity.visibility === 'private') return false // Private = no access

return false // Default deny
}

Caching Strategy

// In-memory cache with 5-minute TTL
private cache = new Map<string, {
permissions: EntityPermission[]
pageAccess: PageAccess[]
expiresAt: Date
}>()

// Cache key: userId
// Invalidation: On permission change or every 5 minutes

async function getPermissions(userId: string) {
const cached = this.cache.get(userId)
if (cached && cached.expiresAt > new Date()) {
return cached.permissions
}

// Cache miss - load from database
const permissions = await db.entityPermission.findMany({
where: { userId }
})

this.cache.set(userId, {
permissions,
pageAccess: await loadPageAccess(userId),
expiresAt: new Date(Date.now() + 5 * 60 * 1000)
})

return permissions
}

Why 5 minutes?

  • Balance between performance and freshness
  • Permission changes are rare (admin-only action)
  • Stale data for 5 min is acceptable security trade-off
  • Auditors see permission changes within acceptable timeframe

Permission Inheritance (Workflows)

Design Decision: No Inheritance

Workflows do NOT inherit permissions from parent entities.

Example:

📋 Audit #123 (User has Edit permission)
├── 🔄 Workflow #456 (User has View permission)
└── 🔄 Workflow #789 (User has no permission)

Why No Inheritance?

  1. Separation of duties: Lead auditor may manage audit but not approve workflows
  2. Workflow-specific access: Junior auditors may work on specific phases only
  3. Compliance requirements: SOC2 requires documenting who worked on each phase
  4. Clearer audit trail: Explicit permissions = clearer evidence

Implementation

// Each workflow has its own permission check
await canEditEntity(userId, 'workflow', workflowId) // Independent check
// Does NOT check parent audit permission

Visibility vs Permissions

Entity Visibility Field

model Audit {
id String
visibility VisibilityLevel @default(private)
}

enum VisibilityLevel {
public // Anyone with page access can view
private // Only users with explicit permission
}

Interaction Matrix

VisibilityUser PermissionAccess Level
public(none)👁️ View only
publicview👁️ View only
publicedit✏️ Edit
publicnone❌ Blocked (explicit)
private(none)❌ No access
privateview👁️ View only
privateedit✏️ Edit
privatenone❌ No access

Design Rationale

Why have both?

  1. Visibility = default policy

    • "Should most users see this?"
    • Scales to 100s of users without creating 100s of permissions
  2. Permissions = exceptions

    • "This specific user needs access/restriction"
    • Overrides visibility for specific users

Example Use Cases:

Public Audit:

Visibility: public
Permissions:
- Jane (edit) → Can manage audit
- Bob (none) → Explicitly blocked (conflict of interest)
- Everyone else → Can view (no explicit permission needed)

Private Audit:

Visibility: private
Permissions:
- Jane (edit) → Lead auditor
- Tom (view) → Reviewer
- Everyone else → Cannot see (even in list)

Performance Optimization

Database Indexes

@@index([userId])                           // User's permissions lookup
@@index([entityType, entityId]) // Entity access check
@@index([userId, entityType]) // Filtered lists
@@unique([userId, entityType, entityId]) // Prevent duplicates

Query Patterns

Bad (N+1 query):

for (const audit of audits) {
const canView = await canViewEntity(userId, 'audit', audit.id)
if (canView) result.push(audit)
}

Good (Bulk check):

const userPermissions = await db.entityPermission.findMany({
where: { userId, entityType: 'AUDIT' }
})
const permMap = new Map(userPermissions.map(p => [p.entityId, p.permission]))

const result = audits.filter(audit => {
const perm = permMap.get(audit.id)
return perm === 'view' || perm === 'edit' || audit.visibility === 'public'
})

Benchmark Results

OperationQueriesTime
Check page access1~5ms
Check entity permission (cached)0~0.5ms
Check entity permission (uncached)1~15ms
Filter 100 audits (bulk)2~50ms
Filter 100 audits (N+1)201~3000ms

GraphQL Integration

Resolver Protection

export const Mutation = builder.mutationFields({
updateAudit: (t) => t.field({
type: Audit,
args: { id: t.arg.string(), input: t.arg({ type: UpdateAuditInput }) },
resolve: async (_, { id, input }, { userId }) => {
// Permission check
const canEdit = await permissionService.canEditEntity(
userId,
EntityType.AUDIT,
id
)

if (!canEdit) {
throw new Error('Insufficient permissions to edit this audit')
}

return prisma.audit.update({ where: { id }, data: input })
}
})
})

Automatic Filtering

export const Query = builder.queryFields({
audits: (t) => t.field({
type: [Audit],
resolve: async (_, __, { userId }) => {
// Get all audits
const allAudits = await prisma.audit.findMany()

// Filter by permissions
return permissionService.filterEntitiesByPermission(
userId,
allAudits,
PermissionLevel.view
)
}
})
})

Audit Logging

Every permission check and permission change is logged:

await auditLog.create({
userId,
actionType: 'PERMISSION_CHECK',
entityType: 'AUDIT',
entityId: auditId,
metadata: {
permissionLevel: 'edit',
result: 'denied',
reason: 'no_explicit_permission'
}
})

Logged Events:

  • ✅ Permission granted
  • ✅ Permission revoked
  • ✅ Permission check (passed)
  • ❌ Permission check (denied)
  • 🔍 Permission query (bulk)

Audit Trail Use Cases:

  • SOC2 evidence: "Who accessed what when?"
  • Incident response: "Who could have seen the leaked data?"
  • Compliance review: "Prove least privilege is enforced"

Security Considerations

Threat Model

Protected Against:

  • ✅ Unauthorized data access
  • ✅ Privilege escalation
  • ✅ Permission bypass via API
  • ✅ Session hijacking (permissions checked server-side)
  • ✅ IDOR attacks (permission checked on every entity access)

Not Protected Against:

  • ⚠️ Compromised admin account (admins bypass everything)
  • ⚠️ Database compromise (attacker has direct DB access)
  • ⚠️ Social engineering (tricking admin to grant permissions)

Defense in Depth

┌─────────────────────────────────────┐
│ 1. Authentication (OAuth2) │
│ "Are you who you say you are?" │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│ 2. Page Access (PageAccess table) │
│ "Can you reach this section?" │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│ 3. Entity Permission Check │
│ "Can you act on this item?" │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│ 4. Audit Logging │
│ "Recording who did what" │
└─────────────────────────────────────┘

Admin Account Protection

Best Practices:

  1. Limit admin accounts (≤3 per organization)
  2. Require MFA for admins
  3. Audit admin actions weekly
  4. Use service accounts for automation (not admin users)
  5. Disable admin access for inactive users

Migration from Legacy System

Legacy Permission Model (v1)

Problems:

  • ❌ Single role field (admin/auditor/viewer)
  • ❌ No entity-level permissions
  • ❌ All-or-nothing access
  • ❌ No audit trail

Migration Path

-- 1. Create new tables (already done via Prisma migrations)
-- 2. Migrate existing users
INSERT INTO "PageAccess" (userId, pageName, hasAccess)
SELECT id, 'audits', true FROM "User" WHERE role IN ('admin', 'auditor');

-- 3. Grant entity permissions based on ownership
INSERT INTO "EntityPermission" (userId, entityType, entityId, permission)
SELECT ownerId, 'AUDIT', id, 'edit' FROM "Audit" WHERE ownerId IS NOT NULL;

-- 4. Deprecate role field (keep for compatibility)
-- 5. Update application code to use PermissionServiceV2

Testing Strategy

Unit Tests

describe('PermissionServiceV2', () => {
it('should allow admin to edit any entity', async () => {
const result = await service.canEditEntity(adminUserId, 'AUDIT', auditId)
expect(result).toBe(true)
})

it('should deny access if no page access', async () => {
const result = await service.canEditEntity(userId, 'AUDIT', auditId)
expect(result).toBe(false)
})

it('should allow view for public entities', async () => {
const result = await service.canViewEntity(userId, 'AUDIT', publicAuditId)
expect(result).toBe(true)
})
})

Integration Tests

describe('Audit GraphQL API', () => {
it('should filter audits by user permissions', async () => {
const { audits } = await graphql(QUERY_AUDITS, { userId })

// User should only see audits they have access to
expect(audits).toHaveLength(3)
expect(audits).not.toContain(privateAuditTheyDontHaveAccessTo)
})
})