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
- Principle of Least Privilege - Users get minimum necessary access
- Separation of Concerns - Page access ≠ Entity access
- Audit Trail - All permission changes logged
- Performance - Sub-100ms permission checks
- 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:
-
Different access patterns:
- Page access checked on navigation (8 pages max)
- Entity permissions checked per-item (100s-1000s of entities)
-
Different cardinality:
- Page access:
O(users × pages)→ ~8 rows per user - Entity permissions:
O(users × entities)→ 100s of rows per user
- Page access:
-
Performance optimization:
- Page access loaded once per session
- Entity permissions cached with short TTL
-
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?
- Separation of duties: Lead auditor may manage audit but not approve workflows
- Workflow-specific access: Junior auditors may work on specific phases only
- Compliance requirements: SOC2 requires documenting who worked on each phase
- 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
| Visibility | User Permission | Access Level |
|---|---|---|
| public | (none) | 👁️ View only |
| public | view | 👁️ View only |
| public | edit | ✏️ Edit |
| public | none | ❌ Blocked (explicit) |
| private | (none) | ❌ No access |
| private | view | 👁️ View only |
| private | edit | ✏️ Edit |
| private | none | ❌ No access |
Design Rationale
Why have both?
-
Visibility = default policy
- "Should most users see this?"
- Scales to 100s of users without creating 100s of permissions
-
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
| Operation | Queries | Time |
|---|---|---|
| Check page access | 1 | ~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:
- Limit admin accounts (≤3 per organization)
- Require MFA for admins
- Audit admin actions weekly
- Use service accounts for automation (not admin users)
- Disable admin access for inactive users
Migration from Legacy System
Legacy Permission Model (v1)
Problems:
- ❌ Single
rolefield (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)
})
})
Related Documentation
- How to Manage Permissions
- Understanding Permissions
- RBAC Security Model (SOC2/ISO perspective)
- GraphQL Schema Reference