diff --git a/__tests__/rbac/README.md b/__tests__/rbac/README.md new file mode 100644 index 0000000..3ad2774 --- /dev/null +++ b/__tests__/rbac/README.md @@ -0,0 +1,271 @@ +# RBAC Test Suite + +This directory contains comprehensive tests for the Role-Based Access Control (RBAC) system. + +## Test Files + +### 1. `permission-checker.test.ts` +Tests for core permission checking logic: +- ✅ Permission granting when user has role +- ✅ Permission denial when user lacks role +- ✅ Non-existent permission handling +- ✅ Permission caching functionality +- ✅ Project-scoped permissions +- ✅ Multiple permission checking +- ✅ Cache invalidation +- ✅ Edge cases (non-existent users, empty strings, null values) + +### 2. `impersonation.test.ts` +Tests for user impersonation functionality: +- ✅ Super admin identification +- ✅ Impersonation session creation +- ✅ Permission checks for non-super admins +- ✅ Self-impersonation prevention +- ✅ Non-existent user impersonation prevention +- ✅ Session expiration +- ✅ Ending previous sessions +- ✅ Session retrieval +- ✅ Auto-expiration of timed-out sessions +- ✅ Audit trail logging + +### 3. `integration.test.ts` +End-to-end integration tests: +- ✅ Complete permission lifecycle (create → assign → check → revoke) +- ✅ Project-scoped permission isolation +- ✅ Group-based permission grants +- ✅ Permission hierarchy (system + project + group) +- ✅ Super admin impersonation workflow +- ✅ Permission caching and invalidation + +## Running Tests + +### Prerequisites + +1. Install Jest and TypeScript testing dependencies: +```bash +npm install --save-dev jest @jest/globals @types/jest ts-jest +``` + +2. Create `jest.config.js`: +```javascript +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/__tests__'], + testMatch: ['**/*.test.ts'], + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + collectCoverageFrom: [ + 'lib/rbac/**/*.ts', + '!lib/rbac/examples.ts', + '!lib/rbac/types.ts', + ], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, +}; +``` + +3. Update `package.json` scripts: +```json +{ + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:rbac": "jest __tests__/rbac" + } +} +``` + +### Running Tests + +```bash +# Run all tests +npm test + +# Run only RBAC tests +npm run test:rbac + +# Run with coverage +npm run test:coverage + +# Watch mode (for development) +npm run test:watch +``` + +## Test Database Setup + +These tests require a test database. You have two options: + +### Option 1: Separate Test Database +Create a separate database for testing: + +```bash +# Create test database +createdb corestack_test + +# Set environment variable +export DATABASE_URL_TEST="postgresql://user:password@localhost:5432/corestack_test" +``` + +### Option 2: Use Docker for Tests +```bash +# Start test database +docker run -d \ + --name corestack-test-db \ + -e POSTGRES_PASSWORD=testpassword \ + -e POSTGRES_DB=corestack_test \ + -p 5433:5432 \ + postgres:16 + +export DATABASE_URL_TEST="postgresql://postgres:testpassword@localhost:5433/corestack_test" +``` + +## Test Coverage + +Run tests with coverage to ensure all code paths are tested: + +```bash +npm run test:coverage +``` + +Expected coverage: +- **Statements**: > 80% +- **Branches**: > 80% +- **Functions**: > 80% +- **Lines**: > 80% + +## Writing New Tests + +### Test Structure +```typescript +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; + +describe('Feature Name', () => { + beforeEach(async () => { + // Set up test data + }); + + afterEach(async () => { + // Clean up test data + }); + + it('should do something specific', async () => { + // Arrange + const input = setupTestData(); + + // Act + const result = await functionUnderTest(input); + + // Assert + expect(result).toBe(expected); + }); +}); +``` + +### Best Practices + +1. **Isolation**: Each test should be independent +2. **Clean Up**: Always clean up test data in `afterEach` +3. **Descriptive Names**: Use clear, descriptive test names +4. **AAA Pattern**: Arrange, Act, Assert +5. **One Assertion**: Test one thing per test when possible +6. **Edge Cases**: Test boundary conditions and error cases + +## Manual Testing Checklist + +If automated tests aren't set up yet, use this checklist: + +### Permission Checker +- [ ] User with role has permission +- [ ] User without role doesn't have permission +- [ ] Non-existent permission returns false +- [ ] Project-scoped permissions work correctly +- [ ] Multiple permissions checked in batch +- [ ] Cache stores and retrieves correctly +- [ ] Cache invalidation works + +### Impersonation +- [ ] Super admin can start impersonation +- [ ] Non-super admin cannot start impersonation +- [ ] Cannot impersonate self +- [ ] Cannot impersonate non-existent user +- [ ] Session expires after timeout +- [ ] Previous sessions end when starting new one +- [ ] Session can be ended manually +- [ ] Audit logs created for all operations + +### Integration +- [ ] Complete workflow: create perm → create role → assign → check +- [ ] Project permissions isolated correctly +- [ ] Group permissions work +- [ ] Multiple roles grant multiple permissions +- [ ] Revoking role removes permission + +## Troubleshooting + +### Tests fail with database connection errors +- Ensure test database is running +- Check `DATABASE_URL_TEST` environment variable +- Run migrations on test database + +### Tests fail with "table does not exist" +```bash +# Run migrations on test database +DATABASE_URL=$DATABASE_URL_TEST npm run db:push +``` + +### Tests are slow +- Use in-memory database for tests +- Mock database calls for unit tests +- Use transactions that rollback after each test + +## CI/CD Integration + +Add to `.github/workflows/test.yml`: +```yaml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: corestack_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '20' + - run: npm ci + - run: npm run test:coverage + env: + DATABASE_URL_TEST: postgresql://postgres:testpassword@localhost:5432/corestack_test +``` + +## Additional Resources + +- [Jest Documentation](https://jestjs.io/) +- [Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices) +- [Test-Driven Development](https://martinfowler.com/bliki/TestDrivenDevelopment.html) diff --git a/__tests__/rbac/impersonation.test.ts b/__tests__/rbac/impersonation.test.ts new file mode 100644 index 0000000..9e79be0 --- /dev/null +++ b/__tests__/rbac/impersonation.test.ts @@ -0,0 +1,364 @@ +/** + * Impersonation Service Tests + * + * Tests for user impersonation functionality. + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + isSuperAdmin, + startImpersonation, + endImpersonation, + getImpersonationSession, + canImpersonate, + SUPER_ADMIN_GROUP_NAME, +} from '@/lib/rbac/impersonation-service'; +import { createGroup, addUserToGroup } from '@/lib/rbac/group-service'; +import { db } from '@/lib/db'; +import { users, groups, groupMembers } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +describe('Impersonation Service', () => { + let adminUserId: number; + let targetUserId: number; + let superAdminGroupId: number; + + beforeEach(async () => { + // Create admin user + const [admin] = await db + .insert(users) + .values({ + username: 'admin-' + Date.now(), + name: 'Admin User', + email: `admin-${Date.now()}@example.com`, + authType: 'email', + }) + .returning(); + adminUserId = admin.id; + + // Create target user + const [target] = await db + .insert(users) + .values({ + username: 'target-' + Date.now(), + name: 'Target User', + email: `target-${Date.now()}@example.com`, + authType: 'email', + }) + .returning(); + targetUserId = target.id; + + // Create super admin group + const [existingGroup] = await db + .select() + .from(groups) + .where(eq(groups.name, SUPER_ADMIN_GROUP_NAME)) + .limit(1); + + if (existingGroup) { + superAdminGroupId = existingGroup.id; + } else { + const group = await createGroup({ + name: SUPER_ADMIN_GROUP_NAME, + description: 'Super Admin Group', + groupType: 'functional', + }); + superAdminGroupId = group.id; + } + }); + + afterEach(async () => { + // Clean up + if (adminUserId) { + await db.delete(users).where(eq(users.id, adminUserId)); + } + if (targetUserId) { + await db.delete(users).where(eq(users.id, targetUserId)); + } + }); + + describe('isSuperAdmin', () => { + it('should return true for users in super admin group', async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const result = await isSuperAdmin(adminUserId); + expect(result).toBe(true); + }); + + it('should return false for users not in super admin group', async () => { + const result = await isSuperAdmin(targetUserId); + expect(result).toBe(false); + }); + + it('should return false for non-existent users', async () => { + const result = await isSuperAdmin(999999); + expect(result).toBe(false); + }); + }); + + describe('startImpersonation', () => { + beforeEach(async () => { + // Add admin to super admin group + await addUserToGroup(superAdminGroupId, adminUserId); + }); + + it('should create impersonation session for super admin', async () => { + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Test impersonation', + }); + + expect(session).not.toBeNull(); + expect(session?.adminUserId).toBe(adminUserId); + expect(session?.impersonatedUserId).toBe(targetUserId); + expect(session?.sessionToken).toBeDefined(); + expect(session?.isActive).toBe(true); + }); + + it('should deny impersonation for non-super admin', async () => { + // Remove admin from super admin group + await db + .delete(groupMembers) + .where( + eq(groupMembers.userId, adminUserId) + ); + + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Test impersonation', + }); + + expect(session).toBeNull(); + }); + + it('should prevent self-impersonation', async () => { + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: adminUserId, + reason: 'Self impersonation attempt', + }); + + expect(session).toBeNull(); + }); + + it('should prevent impersonating non-existent user', async () => { + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: 999999, + reason: 'Non-existent user', + }); + + expect(session).toBeNull(); + }); + + it('should set expiration time', async () => { + const durationMs = 3600000; // 1 hour + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Test with expiration', + durationMs, + }); + + expect(session).not.toBeNull(); + + const expectedExpiry = new Date(Date.now() + durationMs); + const actualExpiry = session!.expiresAt; + + // Allow 1 second tolerance for timing + expect(Math.abs(actualExpiry.getTime() - expectedExpiry.getTime())).toBeLessThan(1000); + }); + + it('should end previous sessions when starting new one', async () => { + // Start first session + const session1 = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'First session', + }); + + expect(session1).not.toBeNull(); + + // Start second session + const [otherTarget] = await db + .insert(users) + .values({ + username: 'other-' + Date.now(), + name: 'Other User', + email: `other-${Date.now()}@example.com`, + authType: 'email', + }) + .returning(); + + const session2 = await startImpersonation({ + adminUserId, + impersonatedUserId: otherTarget.id, + reason: 'Second session', + }); + + expect(session2).not.toBeNull(); + + // First session should be ended + const retrievedSession1 = await getImpersonationSession(session1!.sessionToken); + expect(retrievedSession1).toBeNull(); + + // Clean up + await db.delete(users).where(eq(users.id, otherTarget.id)); + }); + }); + + describe('endImpersonation', () => { + let sessionToken: string; + + beforeEach(async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Test session', + }); + + sessionToken = session!.sessionToken; + }); + + it('should end active impersonation session', async () => { + const result = await endImpersonation(sessionToken); + expect(result).toBe(true); + + // Session should no longer be retrievable + const session = await getImpersonationSession(sessionToken); + expect(session).toBeNull(); + }); + + it('should return false for non-existent session', async () => { + const result = await endImpersonation('invalid-token'); + expect(result).toBe(false); + }); + + it('should return false for already ended session', async () => { + await endImpersonation(sessionToken); + + // Try to end again + const result = await endImpersonation(sessionToken); + expect(result).toBe(false); + }); + }); + + describe('getImpersonationSession', () => { + let sessionToken: string; + + beforeEach(async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Test session', + }); + + sessionToken = session!.sessionToken; + }); + + it('should retrieve active session by token', async () => { + const session = await getImpersonationSession(sessionToken); + + expect(session).not.toBeNull(); + expect(session?.sessionToken).toBe(sessionToken); + expect(session?.isActive).toBe(true); + }); + + it('should return null for invalid token', async () => { + const session = await getImpersonationSession('invalid-token'); + expect(session).toBeNull(); + }); + + it('should return null for ended session', async () => { + await endImpersonation(sessionToken); + + const session = await getImpersonationSession(sessionToken); + expect(session).toBeNull(); + }); + + it('should auto-expire sessions past expiration time', async () => { + // Create session with very short duration (1ms) + const shortSession = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Short session', + durationMs: 1, + }); + + // Wait for expiration + await new Promise(resolve => setTimeout(resolve, 10)); + + const session = await getImpersonationSession(shortSession!.sessionToken); + expect(session).toBeNull(); + }); + }); + + describe('canImpersonate', () => { + it('should allow super admin to impersonate other users', async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const result = await canImpersonate(adminUserId, targetUserId); + expect(result).toBe(true); + }); + + it('should deny non-super admin', async () => { + const result = await canImpersonate(adminUserId, targetUserId); + expect(result).toBe(false); + }); + + it('should deny self-impersonation', async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const result = await canImpersonate(adminUserId, adminUserId); + expect(result).toBe(false); + }); + + it('should deny impersonating non-existent user', async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const result = await canImpersonate(adminUserId, 999999); + expect(result).toBe(false); + }); + }); + + describe('Audit Trail', () => { + it('should log impersonation start', async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Audit test', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + }); + + expect(session).not.toBeNull(); + expect(session?.reason).toBe('Audit test'); + expect(session?.ipAddress).toBe('192.168.1.1'); + expect(session?.userAgent).toBe('Mozilla/5.0'); + }); + + it('should log impersonation end', async () => { + await addUserToGroup(superAdminGroupId, adminUserId); + + const session = await startImpersonation({ + adminUserId, + impersonatedUserId: targetUserId, + reason: 'Audit test', + }); + + const result = await endImpersonation(session!.sessionToken); + expect(result).toBe(true); + + // Session should have endedAt timestamp + // This would require querying the database directly + }); + }); +}); diff --git a/__tests__/rbac/integration.test.ts b/__tests__/rbac/integration.test.ts new file mode 100644 index 0000000..8f664ee --- /dev/null +++ b/__tests__/rbac/integration.test.ts @@ -0,0 +1,357 @@ +/** + * RBAC Integration Tests + * + * End-to-end tests for the complete RBAC system. + */ + +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + createRole, + createPermission, + assignPermissionsToRole, + assignRole, + revokeRole, + getUserPermissions, + checkPermission, + createGroup, + addUserToGroup, + isSuperAdmin, + startImpersonation, +} from '@/lib/rbac'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; + +describe('RBAC Integration Tests', () => { + let userId: number; + let projectId: number; + + beforeAll(async () => { + // Create test user + const [user] = await db + .insert(users) + .values({ + username: 'integration-test-' + Date.now(), + name: 'Integration Test User', + email: `integration-${Date.now()}@example.com`, + authType: 'email', + }) + .returning(); + userId = user.id; + + // Assume project with ID 1 exists or create one + projectId = 1; + }); + + afterAll(async () => { + // Clean up + if (userId) { + await db.delete(users).where({ id: userId }); + } + }); + + describe('Complete Permission Flow', () => { + it('should handle full permission lifecycle', async () => { + // 1. Create a permission + const permission = await createPermission({ + name: 'integration.test.' + Date.now(), + displayName: 'Integration Test', + resourceType: 'api', + resourceName: 'integration', + action: 'execute', + }); + + expect(permission.id).toBeDefined(); + + // 2. Create a role + const role = await createRole({ + name: 'integration-role-' + Date.now(), + displayName: 'Integration Role', + roleType: 'system', + }); + + expect(role.id).toBeDefined(); + + // 3. Assign permission to role + await assignPermissionsToRole(role.id, [permission.id]); + + // 4. Verify user doesn't have permission yet + const hasPermBefore = await checkPermission({ + userId, + permission: permission.name, + useCache: false, + }); + expect(hasPermBefore).toBe(false); + + // 5. Assign role to user + await assignRole({ + userId, + roleId: role.id, + }); + + // 6. Verify user now has permission + const hasPermAfter = await checkPermission({ + userId, + permission: permission.name, + useCache: false, + }); + expect(hasPermAfter).toBe(true); + + // 7. Get all user permissions + const userPerms = await getUserPermissions(userId); + expect(userPerms.permissions).toContain(permission.name); + expect(userPerms.systemRoles).toHaveLength(1); + + // 8. Revoke role from user + await revokeRole(userId, role.id); + + // 9. Verify user no longer has permission + const hasPermFinal = await checkPermission({ + userId, + permission: permission.name, + useCache: false, + }); + expect(hasPermFinal).toBe(false); + }); + }); + + describe('Project-Scoped Permissions', () => { + it('should handle project-specific permissions', async () => { + // Create project-scoped permission + const permission = await createPermission({ + name: 'project.manage.' + Date.now(), + displayName: 'Manage Project', + resourceType: 'api', + resourceName: 'project', + action: 'update', + }); + + // Create project role + const role = await createRole({ + name: 'project-manager-' + Date.now(), + displayName: 'Project Manager', + roleType: 'project', + }); + + await assignPermissionsToRole(role.id, [permission.id]); + + // Assign role to user for specific project + await assignRole({ + userId, + roleId: role.id, + projectId, + }); + + // User should have permission in this project + const hasPermInProject = await checkPermission({ + userId, + permission: permission.name, + projectId, + useCache: false, + }); + expect(hasPermInProject).toBe(true); + + // User should NOT have permission in different project + const hasPermOtherProject = await checkPermission({ + userId, + permission: permission.name, + projectId: 999, + useCache: false, + }); + expect(hasPermOtherProject).toBe(false); + }); + }); + + describe('Group-Based Permissions', () => { + it('should grant permissions via group membership', async () => { + // Create permission and role + const permission = await createPermission({ + name: 'group.feature.' + Date.now(), + displayName: 'Group Feature', + resourceType: 'api', + resourceName: 'feature', + action: 'execute', + }); + + const role = await createRole({ + name: 'group-role-' + Date.now(), + displayName: 'Group Role', + roleType: 'system', + }); + + await assignPermissionsToRole(role.id, [permission.id]); + + // Create group + const group = await createGroup({ + name: 'test-group-' + Date.now(), + description: 'Test Group', + groupType: 'functional', + }); + + // Add user to group + await addUserToGroup(group.id, userId); + + // Assign role to user via group + await assignRole({ + userId, + roleId: role.id, + groupId: group.id, + }); + + // User should have permission via group membership + const hasPerm = await checkPermission({ + userId, + permission: permission.name, + useCache: false, + }); + expect(hasPerm).toBe(true); + }); + }); + + describe('Permission Hierarchy', () => { + it('should properly handle multiple role sources', async () => { + // Create multiple permissions + const perm1 = await createPermission({ + name: 'hierarchy.system.' + Date.now(), + displayName: 'System Permission', + resourceType: 'api', + resourceName: 'system', + action: 'read', + }); + + const perm2 = await createPermission({ + name: 'hierarchy.project.' + Date.now(), + displayName: 'Project Permission', + resourceType: 'api', + resourceName: 'project', + action: 'read', + }); + + // Create system role with perm1 + const systemRole = await createRole({ + name: 'system-role-' + Date.now(), + displayName: 'System Role', + roleType: 'system', + }); + await assignPermissionsToRole(systemRole.id, [perm1.id]); + + // Create project role with perm2 + const projectRole = await createRole({ + name: 'project-role-' + Date.now(), + displayName: 'Project Role', + roleType: 'project', + }); + await assignPermissionsToRole(projectRole.id, [perm2.id]); + + // Assign both roles + await assignRole({ userId, roleId: systemRole.id }); + await assignRole({ userId, roleId: projectRole.id, projectId }); + + // User should have both permissions + const hasPerm1 = await checkPermission({ + userId, + permission: perm1.name, + useCache: false, + }); + const hasPerm2 = await checkPermission({ + userId, + permission: perm2.name, + projectId, + useCache: false, + }); + + expect(hasPerm1).toBe(true); + expect(hasPerm2).toBe(true); + }); + }); + + describe('Super Admin and Impersonation', () => { + it('should allow super admin to impersonate users', async () => { + // Create super admin group if it doesn't exist + const group = await createGroup({ + name: 'super_admins', + description: 'Super Admins', + groupType: 'functional', + }); + + // Add user to super admin group + await addUserToGroup(group.id, userId); + + // Verify user is super admin + const isAdmin = await isSuperAdmin(userId); + expect(isAdmin).toBe(true); + + // Create target user + const [targetUser] = await db + .insert(users) + .values({ + username: 'target-' + Date.now(), + name: 'Target User', + email: `target-${Date.now()}@example.com`, + authType: 'email', + }) + .returning(); + + // Start impersonation + const session = await startImpersonation({ + adminUserId: userId, + impersonatedUserId: targetUser.id, + reason: 'Integration test', + }); + + expect(session).not.toBeNull(); + expect(session?.sessionToken).toBeDefined(); + + // Clean up + await db.delete(users).where({ id: targetUser.id }); + }); + }); + + describe('Permission Caching', () => { + it('should cache and invalidate permissions correctly', async () => { + const permission = await createPermission({ + name: 'cache.test.' + Date.now(), + displayName: 'Cache Test', + resourceType: 'api', + resourceName: 'cache', + action: 'read', + }); + + const role = await createRole({ + name: 'cache-role-' + Date.now(), + displayName: 'Cache Role', + roleType: 'system', + }); + + await assignPermissionsToRole(role.id, [permission.id]); + await assignRole({ userId, roleId: role.id }); + + // First check (caches result) + const result1 = await checkPermission({ + userId, + permission: permission.name, + useCache: true, + }); + expect(result1).toBe(true); + + // Second check (uses cache) + const result2 = await checkPermission({ + userId, + permission: permission.name, + useCache: true, + }); + expect(result2).toBe(true); + + // Revoke role + await revokeRole(userId, role.id); + + // Without cache invalidation, might still show true + // With proper cache invalidation, should show false + const result3 = await checkPermission({ + userId, + permission: permission.name, + useCache: false, // Force fresh check + }); + expect(result3).toBe(false); + }); + }); +}); diff --git a/__tests__/rbac/permission-checker.test.ts b/__tests__/rbac/permission-checker.test.ts new file mode 100644 index 0000000..6a0b582 --- /dev/null +++ b/__tests__/rbac/permission-checker.test.ts @@ -0,0 +1,283 @@ +/** + * Permission Checker Tests + * + * Tests for the core permission checking logic. + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + checkPermission, + checkMultiplePermissions, + invalidateUserPermissionCache, + clearExpiredCache, +} from '@/lib/rbac/permission-checker'; +import { + createRole, + createPermission, + assignPermissionsToRole, + assignRole, +} from '@/lib/rbac'; +import { db } from '@/lib/db'; +import { users, roles, permissions } from '@/lib/db/schema'; + +describe('Permission Checker', () => { + let testUserId: number; + let testRoleId: number; + let testPermissionId: number; + let testProjectId: number | undefined; + + beforeEach(async () => { + // Create test user + const [user] = await db + .insert(users) + .values({ + username: 'test-user-' + Date.now(), + name: 'Test User', + email: `test-${Date.now()}@example.com`, + authType: 'email', + }) + .returning(); + testUserId = user.id; + + // Create test permission + const permission = await createPermission({ + name: 'test.read.' + Date.now(), + displayName: 'Test Read', + resourceType: 'api', + resourceName: 'test', + action: 'read', + }); + testPermissionId = permission.id; + + // Create test role + const role = await createRole({ + name: 'test-role-' + Date.now(), + displayName: 'Test Role', + roleType: 'system', + }); + testRoleId = role.id; + + // Assign permission to role + await assignPermissionsToRole(testRoleId, [testPermissionId]); + }); + + afterEach(async () => { + // Clean up: delete test data + if (testUserId) { + await db.delete(users).where({ id: testUserId }); + } + }); + + describe('checkPermission', () => { + it('should grant permission when user has role with permission', async () => { + // Assign role to user + await assignRole({ + userId: testUserId, + roleId: testRoleId, + }); + + // Check permission + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'test.read.' + Date.now(), + useCache: false, // Disable cache for test + }); + + expect(hasPermission).toBe(true); + }); + + it('should deny permission when user lacks role', async () => { + // Don't assign role to user + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'test.read.' + Date.now(), + useCache: false, + }); + + expect(hasPermission).toBe(false); + }); + + it('should deny permission when permission does not exist', async () => { + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'nonexistent.permission', + useCache: false, + }); + + expect(hasPermission).toBe(false); + }); + + it('should cache permission results', async () => { + await assignRole({ + userId: testUserId, + roleId: testRoleId, + }); + + const permName = 'test.read.' + Date.now(); + + // First call - not cached + const start1 = Date.now(); + const result1 = await checkPermission({ + userId: testUserId, + permission: permName, + useCache: true, + }); + const duration1 = Date.now() - start1; + + // Second call - should be cached + const start2 = Date.now(); + const result2 = await checkPermission({ + userId: testUserId, + permission: permName, + useCache: true, + }); + const duration2 = Date.now() - start2; + + expect(result1).toBe(true); + expect(result2).toBe(true); + // Cached call should be faster (though this is timing-dependent) + expect(duration2).toBeLessThanOrEqual(duration1); + }); + + it('should check project-scoped permissions', async () => { + // Create project role and assign + const projectRole = await createRole({ + name: 'project-role-' + Date.now(), + displayName: 'Project Role', + roleType: 'project', + }); + + await assignPermissionsToRole(projectRole.id, [testPermissionId]); + + const testProjectId = 1; // Assuming project exists + + await assignRole({ + userId: testUserId, + roleId: projectRole.id, + projectId: testProjectId, + }); + + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'test.read.' + Date.now(), + projectId: testProjectId, + useCache: false, + }); + + expect(hasPermission).toBe(true); + }); + }); + + describe('checkMultiplePermissions', () => { + it('should check multiple permissions at once', async () => { + // Create additional permissions + const perm1 = await createPermission({ + name: 'test.write.' + Date.now(), + displayName: 'Test Write', + resourceType: 'api', + resourceName: 'test', + action: 'create', + }); + + const perm2 = await createPermission({ + name: 'test.delete.' + Date.now(), + displayName: 'Test Delete', + resourceType: 'api', + resourceName: 'test', + action: 'delete', + }); + + // Assign only one permission to role + await assignPermissionsToRole(testRoleId, [perm1.id]); + await assignRole({ userId: testUserId, roleId: testRoleId }); + + const results = await checkMultiplePermissions( + testUserId, + [perm1.name, perm2.name] + ); + + expect(results[perm1.name]).toBe(true); + expect(results[perm2.name]).toBe(false); + }); + + it('should return false for all when user has no roles', async () => { + const results = await checkMultiplePermissions( + testUserId, + ['perm1', 'perm2', 'perm3'] + ); + + expect(results.perm1).toBe(false); + expect(results.perm2).toBe(false); + expect(results.perm3).toBe(false); + }); + }); + + describe('Cache Management', () => { + it('should invalidate user permission cache', async () => { + await assignRole({ userId: testUserId, roleId: testRoleId }); + + const permName = 'test.read.' + Date.now(); + + // Cache permission + await checkPermission({ + userId: testUserId, + permission: permName, + useCache: true, + }); + + // Invalidate cache + await invalidateUserPermissionCache(testUserId); + + // Check permission again - should not use cache + const hasPermission = await checkPermission({ + userId: testUserId, + permission: permName, + useCache: true, + }); + + expect(hasPermission).toBe(true); + }); + + it('should clear expired cache entries', async () => { + // This test would need to manipulate cache expiry times + await clearExpiredCache(); + // Assert that expired entries are removed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent user gracefully', async () => { + const hasPermission = await checkPermission({ + userId: 999999, + permission: 'test.read', + useCache: false, + }); + + expect(hasPermission).toBe(false); + }); + + it('should handle empty permission string', async () => { + const hasPermission = await checkPermission({ + userId: testUserId, + permission: '', + useCache: false, + }); + + expect(hasPermission).toBe(false); + }); + + it('should handle null/undefined projectId', async () => { + await assignRole({ userId: testUserId, roleId: testRoleId }); + + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'test.read.' + Date.now(), + projectId: undefined, + useCache: false, + }); + + expect(hasPermission).toBe(true); + }); + }); +}); diff --git a/docs/development/rbac-development.md b/docs/development/rbac-development.md new file mode 100644 index 0000000..e2fe2b3 --- /dev/null +++ b/docs/development/rbac-development.md @@ -0,0 +1,755 @@ +# RBAC Development Guide + +This guide explains how to use CoreStack's RBAC system when developing new features. + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Protecting Endpoints](#protecting-endpoints) +3. [Creating Custom Permissions](#creating-custom-permissions) +4. [Creating Custom Roles](#creating-custom-roles) +5. [Permission Patterns](#permission-patterns) +6. [Best Practices](#best-practices) +7. [Common Scenarios](#common-scenarios) +8. [Testing](#testing) +9. [Troubleshooting](#troubleshooting) + +## Quick Start + +### 1. Import RBAC Functions + +```typescript +import { permissionProcedure, protectedProcedure } from '@/lib/trpc/trpc'; +import { checkPermission, requireAnyPermission } from '@/lib/rbac'; +``` + +### 2. Protect Your Endpoint + +```typescript +export const myRouter = router({ + // Simple permission check + create: permissionProcedure('myresource.create') + .input(createSchema) + .mutation(async ({ ctx, input }) => { + // Permission automatically checked + return await createResource(input); + }), +}); +``` + +### 3. Define Your Permission + +Add to `scripts/seed-rbac.ts`: + +```typescript +const BUILT_IN_PERMISSIONS = [ + // ...existing permissions + { + name: 'myresource.create', + displayName: 'Create My Resource', + description: 'Create new resources', + resourceType: 'api' as const, + resourceName: 'myresource', + action: 'create' as const, + }, +]; +``` + +## Protecting Endpoints + +### Basic Protection + +Use `permissionProcedure()` for automatic permission checking: + +```typescript +export const userRouter = router({ + // Requires 'user.read' permission + list: permissionProcedure('user.read') + .query(async ({ ctx }) => { + return await ctx.db.select().from(users); + }), + + // Requires 'user.create' permission + create: permissionProcedure('user.create') + .input(createUserSchema) + .mutation(async ({ ctx, input }) => { + return await createUser(input); + }), +}); +``` + +### Manual Permission Checks + +For complex logic, use manual checks: + +```typescript +export const advancedRouter = router({ + updateResource: protectedProcedure + .input(z.object({ + id: z.number(), + data: z.any(), + })) + .mutation(async ({ ctx, input }) => { + // Get the resource + const resource = await getResource(input.id); + + // Check if user owns resource OR has admin permission + const isOwner = resource.ownerId === ctx.user.userId; + const hasAdminPerm = await checkPermission({ + userId: ctx.user.userId, + permission: 'resource.admin', + }); + + if (!isOwner && !hasAdminPerm) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Cannot update this resource', + }); + } + + return await updateResource(input.id, input.data); + }), +}); +``` + +### Multiple Permission Requirements + +#### OR Logic (Any Permission) + +```typescript +import { requireAnyPermission } from '@/lib/rbac'; + +export const myRouter = router({ + action: protectedProcedure + .mutation(async ({ ctx }) => { + // Requires user.update OR user.admin + await requireAnyPermission( + ctx.user.userId, + ['user.update', 'user.admin'] + ); + + // Proceed with action + }), +}); +``` + +#### AND Logic (All Permissions) + +```typescript +import { requireAllPermissions } from '@/lib/rbac'; + +export const myRouter = router({ + sensitiveAction: protectedProcedure + .mutation(async ({ ctx }) => { + // Requires BOTH user.read AND user.admin + await requireAllPermissions( + ctx.user.userId, + ['user.read', 'user.admin'] + ); + + // Proceed with action + }), +}); +``` + +### Project-Scoped Permissions + +For project-specific operations: + +```typescript +export const projectRouter = router({ + // Permission automatically scoped to projectId from input + updateProject: permissionProcedure('project.update') + .input(z.object({ + projectId: z.number(), + name: z.string(), + })) + .mutation(async ({ ctx, input }) => { + // User must have 'project.update' in THIS project + return await updateProject(input.projectId, { name: input.name }); + }), + + // Manual project scope + deleteProject: protectedProcedure + .input(z.object({ projectId: z.number() })) + .mutation(async ({ ctx, input }) => { + const canDelete = await checkPermission({ + userId: ctx.user.userId, + permission: 'project.delete', + projectId: input.projectId, // Scope to this project + }); + + if (!canDelete) { + throw new TRPCError({ code: 'FORBIDDEN' }); + } + + return await deleteProject(input.projectId); + }), +}); +``` + +## Creating Custom Permissions + +### 1. Define Permission + +Add to `scripts/seed-rbac.ts`: + +```typescript +const BUILT_IN_PERMISSIONS = [ + // ...existing permissions + + // Report Permissions + { + name: 'report.generate', + displayName: 'Generate Report', + description: 'Generate system reports', + resourceType: 'api' as const, + resourceName: 'report', + action: 'execute' as const, + }, + { + name: 'report.read', + displayName: 'View Reports', + description: 'View generated reports', + resourceType: 'api' as const, + resourceName: 'report', + action: 'read' as const, + }, +]; +``` + +### 2. Run Seed Script + +```bash +npm run db:seed-rbac +``` + +### 3. Use in Code + +```typescript +export const reportRouter = router({ + generate: permissionProcedure('report.generate') + .input(reportSchema) + .mutation(async ({ ctx, input }) => { + return await generateReport(input); + }), + + list: permissionProcedure('report.read') + .query(async ({ ctx }) => { + return await listReports(); + }), +}); +``` + +## Creating Custom Roles + +### Via Seed Script + +Add to `scripts/seed-rbac.ts`: + +```typescript +const BUILT_IN_ROLES = [ + // ...existing roles + + { + name: 'data_analyst', + displayName: 'Data Analyst', + description: 'Can view and generate reports', + roleType: 'system' as const, + permissions: [ + 'report.read', + 'report.generate', + 'user.read', // Also needs to view users + ], + }, +]; +``` + +### Via API + +```typescript +// Create role +const role = await trpc.rbac.roles.create.mutate({ + name: 'custom_role', + displayName: 'Custom Role', + description: 'My custom role', + roleType: 'project', +}); + +// Assign permissions +await trpc.rbac.roles.assignPermissions.mutate({ + roleId: role.id, + permissionIds: [1, 2, 5], // IDs of permissions +}); + +// Assign to user +await trpc.rbac.userRoles.assign.mutate({ + userId: 123, + roleId: role.id, + projectId: 10, // For project roles +}); +``` + +## Permission Patterns + +### Resource Ownership Pattern + +```typescript +import { checkResourcePermission } from '@/lib/rbac'; + +export const postRouter = router({ + update: protectedProcedure + .input(z.object({ postId: z.number(), content: z.string() })) + .mutation(async ({ ctx, input }) => { + // Get post + const post = await getPost(input.postId); + + // Check if user can update THIS post + const canUpdate = await checkResourcePermission( + ctx.user.userId, + 'post.update', + post.authorId, // Owner ID + post.projectId // Optional project scope + ); + + if (!canUpdate) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Cannot update this post', + }); + } + + return await updatePost(input.postId, { content: input.content }); + }), +}); +``` + +### Hierarchical Permissions Pattern + +```typescript +// Create granular permissions +const permissions = [ + 'user.read.own', // Read own profile + 'user.read.team', // Read team members + 'user.read.all', // Read all users (admin) + 'user.update.own', // Update own profile + 'user.update.all', // Update any user (admin) +]; + +// Check with fallback +export async function canReadUser( + currentUserId: number, + targetUserId: number +): Promise { + // Can always read own profile + if (currentUserId === targetUserId) { + return true; + } + + // Check for broader permissions + const hasReadAll = await checkPermission({ + userId: currentUserId, + permission: 'user.read.all', + }); + + if (hasReadAll) { + return true; + } + + // Check team permission (would need team lookup) + const hasReadTeam = await checkPermission({ + userId: currentUserId, + permission: 'user.read.team', + }); + + if (hasReadTeam) { + return await areInSameTeam(currentUserId, targetUserId); + } + + return false; +} +``` + +### Dynamic Permission Pattern + +```typescript +export const dynamicRouter = router({ + performAction: protectedProcedure + .input(z.object({ + resource: z.enum(['user', 'project', 'post']), + action: z.enum(['create', 'read', 'update', 'delete']), + resourceId: z.number().optional(), + })) + .mutation(async ({ ctx, input }) => { + // Build permission string dynamically + const permission = `${input.resource}.${input.action}`; + + const hasPermission = await checkPermission({ + userId: ctx.user.userId, + permission, + }); + + if (!hasPermission) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Missing permission: ${permission}`, + }); + } + + // Perform action + return await performAction(input); + }), +}); +``` + +## Best Practices + +### 1. Use Specific Permissions + +❌ **Bad**: Using overly broad permissions +```typescript +// Too broad +permission: 'admin' // What kind of admin? +``` + +✅ **Good**: Using specific, granular permissions +```typescript +// Specific and clear +permission: 'user.delete' +permission: 'project.update' +permission: 'rbac.role.create' +``` + +### 2. Follow Naming Convention + +Format: `.[.]` + +✅ **Good**: +```typescript +'user.create' // Create users +'user.read' // Read user data +'user.update.own' // Update own profile +'user.update.all' // Update any user +'project.delete' // Delete projects +'report.generate' // Generate reports +``` + +### 3. Handle Permission Denials Gracefully + +```typescript +export const safeRouter = router({ + getResource: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ ctx, input }) => { + const resource = await getResource(input.id); + + const canView = await checkPermission({ + userId: ctx.user.userId, + permission: 'resource.read', + }); + + if (!canView) { + // Return limited info instead of throwing + return { + id: resource.id, + name: resource.name, + // Omit sensitive fields + }; + } + + // Return full resource + return resource; + }), +}); +``` + +### 4. Cache Appropriately + +```typescript +// Use cache for frequently checked permissions +const hasPermission = await checkPermission({ + userId, + permission: 'user.read', + useCache: true, // Default, uses 5-min cache +}); + +// Bypass cache for critical operations +const canDelete = await checkPermission({ + userId, + permission: 'user.delete', + useCache: false, // Always fresh check +}); +``` + +### 5. Document Required Permissions + +```typescript +/** + * Update user profile + * + * @requires user.update.own - To update own profile + * @requires user.update.all - To update any user (admin) + */ +export const updateUser = permissionProcedure('user.update') + .input(updateUserSchema) + .mutation(async ({ ctx, input }) => { + // ... + }); +``` + +## Common Scenarios + +### Scenario 1: Creating a New Feature + +1. **Define permissions** in `seed-rbac.ts` +2. **Run seed script**: `npm run db:seed-rbac` +3. **Protect endpoints** with `permissionProcedure()` +4. **Assign to roles** in seed script or via API +5. **Test** with different roles + +Example: +```typescript +// 1. Add to seed-rbac.ts +{ + name: 'analytics.view', + displayName: 'View Analytics', + resourceType: 'api', + resourceName: 'analytics', + action: 'read', +} + +// 2. Add to role +{ + name: 'analyst', + displayName: 'Analyst', + roleType: 'system', + permissions: ['analytics.view', 'report.generate'], +} + +// 3. Protect endpoint +export const analyticsRouter = router({ + getDashboard: permissionProcedure('analytics.view') + .query(async ({ ctx }) => { + return await getAnalytics(); + }), +}); +``` + +### Scenario 2: Adding Admin Actions + +```typescript +// Create admin-specific permissions +const adminPermissions = [ + 'system.config.update', + 'system.users.impersonate', + 'system.logs.view', +]; + +// Add to system_admin role +await assignPermissionsToRole(systemAdminRole.id, adminPermissionIds); + +// Protect admin endpoints +export const adminRouter = router({ + updateConfig: permissionProcedure('system.config.update') + .input(configSchema) + .mutation(async ({ ctx, input }) => { + return await updateConfig(input); + }), +}); +``` + +### Scenario 3: Team-Based Access + +```typescript +// Create team group +const team = await createGroup({ + name: 'frontend-team', + groupType: 'functional', +}); + +// Add members +await addUserToGroup(team.id, user1Id); +await addUserToGroup(team.id, user2Id); + +// Create team role +const teamRole = await createRole({ + name: 'frontend-developer', + displayName: 'Frontend Developer', + roleType: 'system', +}); + +// Assign permissions to role +await assignPermissionsToRole(teamRole.id, [ + frontendPermissions, +]); + +// Assign role to team +await assignRole({ + userId: user1Id, + groupId: team.id, + roleId: teamRole.id, +}); +``` + +### Scenario 4: Temporary Access + +```typescript +// Grant temporary access (expires in 7 days) +await assignRole({ + userId: contractorId, + roleId: contractorRoleId, + projectId: projectId, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + grantedBy: adminUserId, +}); +``` + +## Testing + +### Unit Tests + +```typescript +import { checkPermission, assignRole } from '@/lib/rbac'; + +describe('My Feature', () => { + it('should deny access without permission', async () => { + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'myfeature.access', + useCache: false, + }); + + expect(hasPermission).toBe(false); + }); + + it('should grant access with permission', async () => { + await assignRole({ + userId: testUserId, + roleId: myFeatureRoleId, + }); + + const hasPermission = await checkPermission({ + userId: testUserId, + permission: 'myfeature.access', + useCache: false, + }); + + expect(hasPermission).toBe(true); + }); +}); +``` + +### Integration Tests + +```typescript +import { trpc } from '@/lib/trpc/client'; + +describe('Feature API', () => { + it('should require permission', async () => { + // Try without permission + await expect( + trpc.myFeature.create.mutate({}) + ).rejects.toThrow('Insufficient permissions'); + + // Assign permission + await assignRole({ userId, roleId }); + + // Should succeed now + const result = await trpc.myFeature.create.mutate({}); + expect(result).toBeDefined(); + }); +}); +``` + +## Troubleshooting + +### Permission Denied Unexpectedly + +1. **Check user's roles**: +```typescript +const perms = await getUserPermissions(userId); +console.log('User permissions:', perms.permissions); +``` + +2. **Verify permission exists**: +```typescript +const perm = await getPermissionByName('myfeature.create'); +console.log('Permission:', perm); +``` + +3. **Check role assignments**: +```typescript +const userPerms = await getUserPermissions(userId, projectId); +console.log('System roles:', userPerms.systemRoles); +console.log('Project roles:', userPerms.projectRoles); +``` + +4. **Clear cache**: +```typescript +await invalidateUserPermissionCache(userId, projectId); +``` + +### Permission Works in One Project But Not Another + +- Verify you're using project-scoped roles +- Check `projectId` is being passed correctly +- Confirm role is assigned to the specific project + +### Impersonation Not Reflecting + +1. Check `X-Impersonation-Token` header is set +2. Verify session is active +3. Check session hasn't expired +4. Ensure admin is in super_admins group + +## Additional Resources + +- [RBAC Feature Documentation](../features/rbac.md) +- [API Reference](../architecture/api.md) +- [Examples](../../lib/rbac/examples.ts) +- [Tests](__tests__/rbac/) + +## Quick Reference + +### Common Imports + +```typescript +// tRPC procedures +import { protectedProcedure, permissionProcedure } from '@/lib/trpc/trpc'; + +// Permission checking +import { + checkPermission, + checkMultiplePermissions, + requireAnyPermission, + requireAllPermissions, +} from '@/lib/rbac'; + +// Role management +import { + createRole, + assignRole, + revokeRole, + getUserPermissions, +} from '@/lib/rbac'; + +// Group management +import { + createGroup, + addUserToGroup, + removeUserFromGroup, +} from '@/lib/rbac'; +``` + +### Permission Naming Cheat Sheet + +| Resource | Create | Read | Update | Delete | Execute | +|----------|--------|------|--------|--------|---------| +| User | user.create | user.read | user.update | user.delete | - | +| Project | project.create | project.read | project.update | project.delete | - | +| Post | post.create | post.read | post.update | post.delete | - | +| Report | - | report.read | - | - | report.generate | +| SSH | ssh.create | ssh.read | ssh.update | ssh.delete | ssh.execute | +| RBAC | rbac.*.create | rbac.*.read | rbac.*.update | rbac.*.delete | - | + +Replace `*` with: `role`, `permission`, `group`, `user-role` diff --git a/docs/features/rbac.md b/docs/features/rbac.md new file mode 100644 index 0000000..dfb7859 --- /dev/null +++ b/docs/features/rbac.md @@ -0,0 +1,426 @@ +# Role-Based Access Control (RBAC) + +**Status**: ✅ Implemented +**Version**: 1.0 +**Last Updated**: 2025-11-14 + +## Overview + +CoreStack implements a comprehensive Role-Based Access Control (RBAC) system that provides fine-grained access control for users across projects, groups, and system resources. The system supports Django-style permissions with full TypeScript type safety. + +## Key Features + +- ✅ **Flexible User Management**: Single user accounts with multiple project memberships +- ✅ **Dynamic Role Management**: Create and modify roles without code changes +- ✅ **Granular Permissions**: Fine-grained control over API endpoints and resources (CRUD) +- ✅ **Multi-Level Access Control**: Project-specific, cross-project, and global system roles +- ✅ **Audit Trail**: Complete logging of permission changes and access attempts +- ✅ **Performance Optimized**: Permission caching with 5-minute TTL +- ✅ **User Impersonation**: Super admin group can impersonate users for support +- ✅ **TypeScript Support**: Full type safety throughout the system + +## Architecture + +### Database Schema + +The RBAC system uses 13 tables: + +1. **groups** - User groups (project, cross-project, functional) +2. **group_members** - User-group memberships +3. **group_projects** - Project associations for cross-project groups +4. **roles** - Role definitions (system, project, cross-project) +5. **permissions** - Permission definitions (resource.action format) +6. **role_permissions** - Role-permission mappings +7. **user_system_roles** - System-level role assignments +8. **user_project_roles** - Project-level role assignments +9. **user_group_roles** - Group-level role assignments +10. **external_accounts** - External system accounts (NIS, LDAP) +11. **rbac_audit_log** - Complete audit trail +12. **permission_cache** - Performance cache +13. **impersonation_sessions** - Active impersonation sessions + +### Permission Naming Convention + +Format: `.[.]` + +Examples: +- `user.create` - Create users +- `user.read` - Read user data +- `project.update` - Update projects +- `rbac.role.create` - Create RBAC roles +- `ssh.execute` - Execute SSH commands + +## Built-in Roles + +### System Roles + +| Role | Description | Use Case | +|------|-------------|----------| +| **system_admin** | Full system access | System administrators | +| **system_moderator** | User & content moderation | Community managers | +| **system_auditor** | Read-only access | Compliance officers | + +### Project Roles + +| Role | Description | Use Case | +|------|-------------|----------| +| **project_owner** | Full project control | Project creators | +| **project_admin** | Administrative access | Project managers | +| **project_member** | Regular member access | Team members | +| **project_viewer** | Read-only access | Stakeholders | + +## Usage + +### Setup + +1. **Run Database Migration**: +```bash +npm run db:generate +npm run db:push +``` + +2. **Seed RBAC System**: +```bash +npm run db:seed-rbac +``` + +This creates: +- 40+ built-in permissions +- 7 built-in roles +- Super admin group + +### Assigning Roles + +```typescript +import { assignRole } from '@/lib/rbac'; + +// Assign system role +await assignRole({ + userId: 1, + roleId: 1, // system_admin +}); + +// Assign project role +await assignRole({ + userId: 2, + roleId: 4, // project_member + projectId: 10, +}); + +// Assign role with expiration +await assignRole({ + userId: 3, + roleId: 5, + expiresAt: new Date('2025-12-31'), +}); +``` + +### Checking Permissions + +```typescript +import { checkPermission } from '@/lib/rbac'; + +// Check system permission +const canCreateUsers = await checkPermission({ + userId: 1, + permission: 'user.create', +}); + +// Check project-scoped permission +const canUpdateProject = await checkPermission({ + userId: 2, + permission: 'project.update', + projectId: 10, +}); +``` + +### Protecting Endpoints + +```typescript +import { router, permissionProcedure } from '@/lib/trpc/trpc'; + +export const userRouter = router({ + // Requires 'user.create' permission + create: permissionProcedure('user.create') + .input(createUserSchema) + .mutation(async ({ ctx, input }) => { + // Permission already checked + return await createUser(input); + }), + + // Requires 'user.delete' permission + delete: permissionProcedure('user.delete') + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + return await deleteUser(input.id); + }), +}); +``` + +### Advanced Permission Checks + +```typescript +import { + requireAnyPermission, + requireAllPermissions, + checkResourcePermission +} from '@/lib/rbac'; + +// Require ONE of multiple permissions +await requireAnyPermission( + userId, + ['user.update', 'user.admin'] +); + +// Require ALL permissions +await requireAllPermissions( + userId, + ['user.read', 'user.admin'] +); + +// Check with resource ownership +const canUpdate = await checkResourcePermission( + userId, + 'post.update', + post.authorId // Owner ID +); +``` + +## User Impersonation + +Super admins can impersonate users for support and debugging. + +### Setup Super Admin + +```typescript +import { addUserToGroup } from '@/lib/rbac'; + +// Add user to super_admins group +const groups = await listGroups({}); +const superAdminGroup = groups.find(g => g.name === 'super_admins'); + +await addUserToGroup(superAdminGroup.id, adminUserId); +``` + +### Start Impersonation + +```typescript +// Start impersonation session +const session = await trpc.rbac.impersonation.start.mutate({ + targetUserId: 123, + reason: 'Support ticket #456', + durationMs: 3600000, // 1 hour +}); + +// Use session token in API calls +const client = createTRPCClient({ + headers: { + 'Authorization': `Bearer ${adminJWT}`, + 'X-Impersonation-Token': session.sessionToken, + }, +}); + +// All requests now made as user #123 +const data = await client.user.getProfile.query(); + +// End impersonation +await trpc.rbac.impersonation.end.mutate({ + sessionToken: session.sessionToken, +}); +``` + +### Impersonation Security + +- ✅ Only super_admins group members can impersonate +- ✅ Cannot impersonate yourself +- ✅ Sessions expire automatically (default 1 hour) +- ✅ Complete audit trail with reason, IP, user agent +- ✅ All actions logged with admin and impersonated user IDs + +## API Reference + +### Roles + +- `trpc.rbac.roles.list` - List all roles +- `trpc.rbac.roles.create` - Create new role +- `trpc.rbac.roles.update` - Update role +- `trpc.rbac.roles.delete` - Delete role (non-built-in only) +- `trpc.rbac.roles.assignPermissions` - Assign permissions to role +- `trpc.rbac.roles.removePermissions` - Remove permissions from role + +### Permissions + +- `trpc.rbac.permissions.list` - List all permissions +- `trpc.rbac.permissions.create` - Create new permission +- `trpc.rbac.permissions.update` - Update permission +- `trpc.rbac.permissions.delete` - Delete permission + +### User Roles + +- `trpc.rbac.userRoles.assign` - Assign role to user +- `trpc.rbac.userRoles.revoke` - Revoke role from user +- `trpc.rbac.userRoles.getUserPermissions` - Get all user permissions +- `trpc.rbac.userRoles.getMyPermissions` - Get current user's permissions + +### Groups + +- `trpc.rbac.groups.list` - List groups +- `trpc.rbac.groups.create` - Create group +- `trpc.rbac.groups.addMember` - Add user to group +- `trpc.rbac.groups.removeMember` - Remove user from group +- `trpc.rbac.groups.getMembers` - Get group members + +### Impersonation + +- `trpc.rbac.impersonation.start` - Start impersonation session +- `trpc.rbac.impersonation.end` - End impersonation session +- `trpc.rbac.impersonation.getStatus` - Get current impersonation status +- `trpc.rbac.impersonation.getHistory` - Get impersonation audit history + +## Performance + +### Caching + +- Permission checks are cached for 5 minutes +- Cache key format: `user:{userId}:perm:{permission}:project:{projectId}` +- Automatic cache invalidation on role changes +- Use `useCache: false` to bypass cache + +### Optimization Tips + +1. **Batch Permission Checks**: +```typescript +const results = await checkMultiplePermissions( + userId, + ['user.read', 'user.update', 'user.delete'] +); +``` + +2. **Use Permission Caching**: +```typescript +// Enable caching (default) +await checkPermission({ userId, permission, useCache: true }); +``` + +3. **Minimize Role Changes**: Role changes invalidate cache + +## Security + +### Best Practices + +1. **Principle of Least Privilege**: Grant minimum necessary permissions +2. **Regular Audits**: Review audit logs regularly +3. **Time-Limited Roles**: Use `expiresAt` for temporary access +4. **Impersonation Logging**: Always provide reason for impersonation +5. **Permission Granularity**: Use specific permissions, not wildcards + +### Audit Logging + +All RBAC operations are logged: +- Role assignments/revocations +- Permission grants/denials +- Impersonation sessions +- Access attempts + +Query audit logs: +```typescript +const logs = await db + .select() + .from(rbacAuditLog) + .where(eq(rbacAuditLog.userId, userId)) + .orderBy(desc(rbacAuditLog.createdAt)) + .limit(100); +``` + +## Migration from Legacy System + +The old `project_members` table is preserved for backward compatibility. + +### Migration Script + +```typescript +// Migrate project members to RBAC +import { assignRole, getRoleByName } from '@/lib/rbac'; + +const roleMapping = { + 'owner': 'project_owner', + 'admin': 'project_admin', + 'member': 'project_member', + 'viewer': 'project_viewer', +}; + +for (const member of oldMembers) { + const roleName = roleMapping[member.role]; + const role = await getRoleByName(roleName); + + await assignRole({ + userId: member.userId, + roleId: role.id, + projectId: member.projectId, + }); +} +``` + +## Troubleshooting + +### Permission Denied Errors + +1. **Check user's roles**: +```typescript +const perms = await getUserPermissions(userId, projectId); +console.log('Roles:', perms.systemRoles, perms.projectRoles); +console.log('Permissions:', perms.permissions); +``` + +2. **Verify permission exists**: +```typescript +const perm = await getPermissionByName('user.create'); +console.log('Permission:', perm); +``` + +3. **Check role permissions**: +```typescript +const rolePerms = await getRolePermissions(roleId); +console.log('Role permissions:', rolePerms); +``` + +### Cache Issues + +Clear user's permission cache: +```typescript +import { invalidateUserPermissionCache } from '@/lib/rbac'; +await invalidateUserPermissionCache(userId, projectId); +``` + +### Impersonation Not Working + +1. Verify user is in super_admins group +2. Check session hasn't expired +3. Ensure correct header: `X-Impersonation-Token` +4. Verify session token is valid + +## Examples + +See `lib/rbac/examples.ts` for comprehensive examples covering: +- Basic permission protection +- Multiple permission requirements +- Resource-specific permissions +- Project-scoped permissions +- Conditional permissions +- Impersonation-aware logic + +## Future Enhancements + +Planned features: +- Attribute-Based Access Control (ABAC) +- Permission templates +- Advanced audit analytics +- Multi-tenancy support +- API key permissions + +## Related Documentation + +- [Developer Guide](../development/rbac-development.md) +- [API Documentation](../architecture/api.md) +- [Database Schema](../architecture/database/schema.md) +- [Authentication](./authentication.md) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1d92608..58e87b1 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -132,3 +132,174 @@ export const sshErrorNotifications = pgTable('ssh_error_notifications', { }, (table) => ({ uniqueProjectAccount: unique().on(table.projectId, table.sshAccountId), })); + +// ===== RBAC Tables ===== + +// Groups table +export const groups = pgTable('groups', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 255 }).notNull(), + description: text('description'), + groupType: varchar('group_type', { length: 50 }).notNull(), // 'project' | 'cross-project' | 'functional' + metadata: jsonb('metadata'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Group members junction table +export const groupMembers = pgTable('group_members', { + id: serial('id').primaryKey(), + groupId: integer('group_id').references(() => groups.id, { onDelete: 'cascade' }).notNull(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + joinedAt: timestamp('joined_at').defaultNow().notNull(), +}, (table) => ({ + uniqueGroupUser: unique().on(table.groupId, table.userId), +})); + +// Group projects junction table (for cross-project groups) +export const groupProjects = pgTable('group_projects', { + id: serial('id').primaryKey(), + groupId: integer('group_id').references(() => groups.id, { onDelete: 'cascade' }).notNull(), + projectId: integer('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(), + addedAt: timestamp('added_at').defaultNow().notNull(), +}, (table) => ({ + uniqueGroupProject: unique().on(table.groupId, table.projectId), +})); + +// Roles table +export const roles = pgTable('roles', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 100 }).notNull().unique(), + displayName: varchar('display_name', { length: 255 }).notNull(), + description: text('description'), + roleType: varchar('role_type', { length: 50 }).notNull(), // 'system' | 'project' | 'cross-project' + isActive: boolean('is_active').default(true).notNull(), + isBuiltIn: boolean('is_built_in').default(false).notNull(), // true for system-defined roles + metadata: jsonb('metadata'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Permissions table +export const permissions = pgTable('permissions', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 100 }).notNull().unique(), + displayName: varchar('display_name', { length: 255 }).notNull(), + description: text('description'), + resourceType: varchar('resource_type', { length: 50 }).notNull(), // 'api' | 'ui' | 'data' + resourceName: varchar('resource_name', { length: 255 }).notNull(), // e.g., 'user', 'project' + action: varchar('action', { length: 50 }).notNull(), // 'create' | 'read' | 'update' | 'delete' | 'execute' + isActive: boolean('is_active').default(true).notNull(), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => ({ + uniqueResourceAction: unique().on(table.resourceType, table.resourceName, table.action), +})); + +// Role permissions junction table +export const rolePermissions = pgTable('role_permissions', { + id: serial('id').primaryKey(), + roleId: integer('role_id').references(() => roles.id, { onDelete: 'cascade' }).notNull(), + permissionId: integer('permission_id').references(() => permissions.id, { onDelete: 'cascade' }).notNull(), + grantedAt: timestamp('granted_at').defaultNow().notNull(), + grantedBy: integer('granted_by').references(() => users.id), +}, (table) => ({ + uniqueRolePermission: unique().on(table.roleId, table.permissionId), +})); + +// User system roles +export const userSystemRoles = pgTable('user_system_roles', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + roleId: integer('role_id').references(() => roles.id, { onDelete: 'cascade' }).notNull(), + grantedAt: timestamp('granted_at').defaultNow().notNull(), + grantedBy: integer('granted_by').references(() => users.id), + expiresAt: timestamp('expires_at'), // optional expiration +}, (table) => ({ + uniqueUserRole: unique().on(table.userId, table.roleId), +})); + +// User project roles (enhanced version of project_members) +export const userProjectRoles = pgTable('user_project_roles', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + projectId: integer('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(), + roleId: integer('role_id').references(() => roles.id, { onDelete: 'cascade' }).notNull(), + grantedAt: timestamp('granted_at').defaultNow().notNull(), + grantedBy: integer('granted_by').references(() => users.id), + expiresAt: timestamp('expires_at'), +}, (table) => ({ + uniqueUserProjectRole: unique().on(table.userId, table.projectId, table.roleId), +})); + +// User group roles +export const userGroupRoles = pgTable('user_group_roles', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + groupId: integer('group_id').references(() => groups.id, { onDelete: 'cascade' }).notNull(), + roleId: integer('role_id').references(() => roles.id, { onDelete: 'cascade' }).notNull(), + grantedAt: timestamp('granted_at').defaultNow().notNull(), + grantedBy: integer('granted_by').references(() => users.id), + expiresAt: timestamp('expires_at'), +}, (table) => ({ + uniqueUserGroupRole: unique().on(table.userId, table.groupId, table.roleId), +})); + +// External accounts (for NIS integration) +export const externalAccounts = pgTable('external_accounts', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + projectId: integer('project_id').references(() => projects.id, { onDelete: 'cascade' }), // null for system-wide accounts + accountType: varchar('account_type', { length: 50 }).notNull(), // 'nis' | 'ldap' | 'ad' | 'other' + username: varchar('username', { length: 255 }).notNull(), + credentials: text('credentials'), // encrypted + metadata: jsonb('metadata'), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + uniqueAccountTypeProjectUser: unique().on(table.accountType, table.projectId, table.username), +})); + +// Audit log for access control +export const rbacAuditLog = pgTable('rbac_audit_log', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }), + action: varchar('action', { length: 100 }).notNull(), // 'grant_role' | 'revoke_role' | 'access_granted' | 'access_denied' + resourceType: varchar('resource_type', { length: 50 }), + resourceId: integer('resource_id'), + roleId: integer('role_id').references(() => roles.id, { onDelete: 'set null' }), + permissionId: integer('permission_id').references(() => permissions.id, { onDelete: 'set null' }), + result: varchar('result', { length: 50 }), // 'success' | 'denied' | 'error' + metadata: jsonb('metadata'), + ipAddress: varchar('ip_address', { length: 45 }), + userAgent: text('user_agent'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Permission cache (for performance) +export const permissionCache = pgTable('permission_cache', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + projectId: integer('project_id').references(() => projects.id, { onDelete: 'cascade' }), // null for system permissions + permissionId: integer('permission_id').references(() => permissions.id, { onDelete: 'cascade' }).notNull(), + hasPermission: boolean('has_permission').notNull(), + cacheKey: varchar('cache_key', { length: 255 }).notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Impersonation sessions (for admin user switching) +export const impersonationSessions = pgTable('impersonation_sessions', { + id: serial('id').primaryKey(), + adminUserId: integer('admin_user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), // The admin who is impersonating + impersonatedUserId: integer('impersonated_user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), // The user being impersonated + sessionToken: varchar('session_token', { length: 255 }).notNull().unique(), // Unique token for this impersonation session + reason: text('reason'), // Optional reason for impersonation (audit purposes) + ipAddress: varchar('ip_address', { length: 45 }), + userAgent: text('user_agent'), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + expiresAt: timestamp('expires_at').notNull(), // Impersonation session expiry + endedAt: timestamp('ended_at'), // When the impersonation session ended +}); diff --git a/lib/rbac/audit-service.ts b/lib/rbac/audit-service.ts new file mode 100644 index 0000000..ad025cd --- /dev/null +++ b/lib/rbac/audit-service.ts @@ -0,0 +1,138 @@ +/** + * RBAC Audit Service + * + * Handles audit logging for all RBAC operations and access attempts. + */ + +import { db } from '@/lib/db'; +import { rbacAuditLog } from '@/lib/db/schema'; +import { AuditAction, AuditResult } from './types'; +import { createLogger } from '@/lib/observability/logger'; + +const logger = createLogger({ service: 'rbac-audit' }); + +export interface LogAccessAttemptOptions { + userId?: number; + action: AuditAction; + resourceType?: string; + resourceId?: number; + roleId?: number; + permissionId?: number; + result: AuditResult; + metadata?: Record; + ipAddress?: string; + userAgent?: string; +} + +/** + * Log an access attempt or RBAC operation to the audit log + */ +export async function logAccessAttempt(options: LogAccessAttemptOptions): Promise { + try { + await db.insert(rbacAuditLog).values({ + userId: options.userId ?? null, + action: options.action, + resourceType: options.resourceType ?? null, + resourceId: options.resourceId ?? null, + roleId: options.roleId ?? null, + permissionId: options.permissionId ?? null, + result: options.result, + metadata: options.metadata ?? null, + ipAddress: options.ipAddress ?? null, + userAgent: options.userAgent ?? null, + }); + + logger.info({ + userId: options.userId, + action: options.action, + result: options.result, + }, 'RBAC audit log entry created'); + } catch (error) { + logger.error({ error, options }, 'Failed to create audit log entry'); + // Don't throw - audit logging failure shouldn't break the application + } +} + +/** + * Log a successful permission grant + */ +export async function logPermissionGranted( + userId: number, + permission: string, + projectId?: number, + metadata?: Record +): Promise { + await logAccessAttempt({ + userId, + action: 'access_granted', + resourceType: 'permission', + result: 'success', + metadata: { + permission, + projectId, + ...metadata, + }, + }); +} + +/** + * Log a permission denial + */ +export async function logPermissionDenied( + userId: number, + permission: string, + projectId?: number, + reason?: string +): Promise { + await logAccessAttempt({ + userId, + action: 'access_denied', + resourceType: 'permission', + result: 'denied', + metadata: { + permission, + projectId, + reason, + }, + }); +} + +/** + * Log a role assignment + */ +export async function logRoleAssigned( + userId: number, + roleId: number, + grantedBy?: number, + metadata?: Record +): Promise { + await logAccessAttempt({ + userId: grantedBy, + action: 'grant_role', + resourceType: 'role', + resourceId: userId, + roleId, + result: 'success', + metadata, + }); +} + +/** + * Log a role revocation + */ +export async function logRoleRevoked( + userId: number, + roleId: number, + revokedBy?: number, + metadata?: Record +): Promise { + await logAccessAttempt({ + userId: revokedBy, + action: 'revoke_role', + resourceType: 'role', + resourceId: userId, + roleId, + result: 'success', + metadata, + }); +} diff --git a/lib/rbac/examples.ts b/lib/rbac/examples.ts new file mode 100644 index 0000000..eba7791 --- /dev/null +++ b/lib/rbac/examples.ts @@ -0,0 +1,305 @@ +/** + * RBAC Usage Examples + * + * This file demonstrates how to use RBAC permissions in various scenarios. + */ + +import { z } from 'zod'; +import { router, protectedProcedure, permissionProcedure } from '@/lib/trpc/trpc'; +import { checkResourcePermission, requireAnyPermission, requireAllPermissions } from './middleware'; +import { users } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +/** + * Example 1: Basic Permission Protection + * + * Use permissionProcedure() to automatically check permissions + */ +export const exampleUserRouterBasic = router({ + // List users - requires 'user.read' permission + list: permissionProcedure('user.read').query(async ({ ctx }) => { + return await ctx.db.select().from(users); + }), + + // Create user - requires 'user.create' permission + create: permissionProcedure('user.create') + .input( + z.object({ + username: z.string().min(1), + name: z.string().min(1), + email: z.string().email(), + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await ctx.db.insert(users).values(input).returning(); + return result[0]; + }), + + // Delete user - requires 'user.delete' permission + delete: permissionProcedure('user.delete') + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + await ctx.db.delete(users).where(eq(users.id, input.id)); + return { success: true }; + }), +}); + +/** + * Example 2: Multiple Permission Requirements + * + * Require one of several permissions (OR logic) + */ +export const exampleUserRouterMultiple = router({ + // Update user - requires either 'user.update' OR 'user.admin' permission + update: protectedProcedure + .input( + z.object({ + id: z.number(), + name: z.string().min(1).optional(), + email: z.string().email().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Check if user has any of the required permissions + await requireAnyPermission( + ctx.user.userId, + ['user.update', 'user.admin'] + ); + + const { id, ...data } = input; + const result = await ctx.db + .update(users) + .set({ ...data, updatedAt: new Date() }) + .where(eq(users.id, id)) + .returning(); + + return result[0]; + }), + + // Admin action - requires BOTH 'user.read' AND 'user.admin' permissions + adminAction: protectedProcedure.mutation(async ({ ctx }) => { + await requireAllPermissions( + ctx.user.userId, + ['user.read', 'user.admin'] + ); + + // Admin-only logic here + return { success: true }; + }), +}); + +/** + * Example 3: Resource-Specific Permissions + * + * Check permissions based on resource ownership + */ +export const examplePostRouter = router({ + // Update post - user can update their own posts, or with admin permission + updatePost: protectedProcedure + .input( + z.object({ + postId: z.number(), + content: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Get the post to check ownership + const post = await ctx.db.query.posts.findFirst({ + where: (posts, { eq }) => eq(posts.id, input.postId), + }); + + if (!post) { + throw new Error('Post not found'); + } + + // Check if user can update this specific post + const canUpdate = await checkResourcePermission( + ctx.user.userId, + 'post.update', + post.authorId // Resource owner ID + ); + + if (!canUpdate) { + throw new Error('Cannot update this post'); + } + + // Update post... + return { success: true }; + }), +}); + +/** + * Example 4: Project-Scoped Permissions + * + * Permissions that are scoped to a specific project + */ +export const exampleProjectRouter = router({ + // List project members - requires 'project.read' permission in the specific project + listMembers: permissionProcedure('project.read') + .input(z.object({ projectId: z.number() })) + .query(async ({ ctx, input }) => { + // The permissionProcedure automatically extracts projectId from input + // and checks permission in that project scope + + // Get project members... + return []; + }), + + // Manual project-scoped check + updateProject: protectedProcedure + .input( + z.object({ + projectId: z.number(), + name: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Manually check permission in project scope + const { checkPermission } = await import('./permission-checker'); + + const canUpdate = await checkPermission({ + userId: ctx.user.userId, + permission: 'project.update', + projectId: input.projectId, + }); + + if (!canUpdate) { + throw new Error('Cannot update this project'); + } + + // Update project... + return { success: true }; + }), +}); + +/** + * Example 5: Conditional Permissions + * + * Different permission requirements based on context + */ +export const exampleConditionalRouter = router({ + performAction: protectedProcedure + .input( + z.object({ + action: z.enum(['view', 'edit', 'delete']), + resourceId: z.number(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Different permissions for different actions + const permissionMap = { + view: 'resource.read', + edit: 'resource.update', + delete: 'resource.delete', + }; + + const { checkPermission } = await import('./permission-checker'); + + const requiredPermission = permissionMap[input.action]; + const hasPermission = await checkPermission({ + userId: ctx.user.userId, + permission: requiredPermission, + }); + + if (!hasPermission) { + throw new Error(`Permission denied: ${requiredPermission}`); + } + + // Perform action... + return { success: true }; + }), +}); + +/** + * Example 6: Combining Authentication and Authorization + */ +export const exampleCombinedRouter = router({ + // Public endpoint - no auth required + publicInfo: router({ + getStats: protectedProcedure.query(async () => { + return { totalUsers: 100, totalProjects: 50 }; + }), + }), + + // Protected endpoint - auth required, no specific permission + profile: router({ + getMyProfile: protectedProcedure.query(async ({ ctx }) => { + // Any authenticated user can access their own profile + return await ctx.db.query.users.findFirst({ + where: (users, { eq }) => eq(users.id, ctx.user.userId), + }); + }), + }), + + // Admin endpoint - auth + permission required + admin: router({ + getAllProfiles: permissionProcedure('user.read').query(async ({ ctx }) => { + // Only users with 'user.read' permission can access + return await ctx.db.select().from(users); + }), + }), +}); + +/** + * Example 7: Dynamic Permission Checking + * + * Check permissions at runtime based on dynamic data + */ +export const exampleDynamicRouter = router({ + accessResource: protectedProcedure + .input( + z.object({ + resourceType: z.enum(['user', 'project', 'post']), + resourceId: z.number(), + action: z.enum(['read', 'update', 'delete']), + }) + ) + .mutation(async ({ ctx, input }) => { + // Build permission string dynamically + const permission = `${input.resourceType}.${input.action}`; + + const { checkPermission } = await import('./permission-checker'); + + const hasPermission = await checkPermission({ + userId: ctx.user.userId, + permission, + }); + + if (!hasPermission) { + throw new Error(`Permission denied: ${permission}`); + } + + // Access resource... + return { success: true }; + }), +}); + +/** + * Example 8: Impersonation-Aware Permissions + * + * Handle permissions differently when impersonating + */ +export const exampleImpersonationRouter = router({ + sensitiveAction: protectedProcedure.mutation(async ({ ctx }) => { + // Check if user is being impersonated + if (ctx.impersonation.isImpersonating) { + // Log that admin is performing action on behalf of user + console.log(`Admin ${ctx.impersonation.adminUserId} performing action for user ${ctx.user.userId}`); + + // Optionally, restrict certain actions during impersonation + throw new Error('This action cannot be performed during impersonation'); + } + + // Proceed with action... + return { success: true }; + }), + + // Allow action during impersonation + viewData: protectedProcedure.query(async ({ ctx }) => { + // This is safe during impersonation + const userId = ctx.user.userId; // This will be the impersonated user's ID + + // Get user data... + return { userId, impersonating: ctx.impersonation.isImpersonating }; + }), +}); diff --git a/lib/rbac/group-service.ts b/lib/rbac/group-service.ts new file mode 100644 index 0000000..2e44d51 --- /dev/null +++ b/lib/rbac/group-service.ts @@ -0,0 +1,321 @@ +/** + * RBAC Group Service + * + * Handles group management operations. + */ + +import { db } from '@/lib/db'; +import { groups, groupMembers, groupProjects } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { CreateGroupInput, Group } from './types'; +import { logAccessAttempt } from './audit-service'; +import { createLogger } from '@/lib/observability/logger'; + +const logger = createLogger({ service: 'rbac-group-service' }); + +/** + * Create a new group + */ +export async function createGroup(input: CreateGroupInput, createdBy?: number): Promise { + try { + const [group] = await db.insert(groups).values({ + name: input.name, + description: input.description ?? null, + groupType: input.groupType, + metadata: input.metadata ?? null, + }).returning(); + + await logAccessAttempt({ + userId: createdBy, + action: 'role_created', + resourceType: 'group', + resourceId: group.id, + result: 'success', + metadata: { groupName: group.name }, + }); + + logger.info({ groupId: group.id, groupName: group.name }, 'Group created'); + + return group; + } catch (error) { + logger.error({ error, input }, 'Failed to create group'); + throw error; + } +} + +/** + * Get a group by ID + */ +export async function getGroupById(groupId: number): Promise { + try { + const [group] = await db + .select() + .from(groups) + .where(eq(groups.id, groupId)) + .limit(1); + + return group || null; + } catch (error) { + logger.error({ error, groupId }, 'Failed to get group by ID'); + return null; + } +} + +/** + * List all groups + */ +export async function listGroups(options?: { + groupType?: string; +}): Promise { + try { + let query = db.select().from(groups); + + if (options?.groupType) { + query = query.where(eq(groups.groupType, options.groupType)) as any; + } + + const result = await query; + return result; + } catch (error) { + logger.error({ error, options }, 'Failed to list groups'); + return []; + } +} + +/** + * Update a group + */ +export async function updateGroup( + groupId: number, + updates: Partial>, + updatedBy?: number +): Promise { + try { + const [group] = await db + .update(groups) + .set({ + name: updates.name, + description: updates.description, + metadata: updates.metadata, + updatedAt: new Date(), + }) + .where(eq(groups.id, groupId)) + .returning(); + + await logAccessAttempt({ + userId: updatedBy, + action: 'role_updated', + resourceType: 'group', + resourceId: groupId, + result: 'success', + metadata: { updates }, + }); + + logger.info({ groupId, groupName: group.name }, 'Group updated'); + + return group || null; + } catch (error) { + logger.error({ error, groupId, updates }, 'Failed to update group'); + throw error; + } +} + +/** + * Delete a group + */ +export async function deleteGroup(groupId: number, deletedBy?: number): Promise { + try { + const existingGroup = await getGroupById(groupId); + if (!existingGroup) { + return false; + } + + await db.delete(groups).where(eq(groups.id, groupId)); + + await logAccessAttempt({ + userId: deletedBy, + action: 'role_updated', + resourceType: 'group', + resourceId: groupId, + result: 'success', + metadata: { action: 'deleted' }, + }); + + logger.info({ groupId, groupName: existingGroup.name }, 'Group deleted'); + + return true; + } catch (error) { + logger.error({ error, groupId }, 'Failed to delete group'); + throw error; + } +} + +/** + * Add a user to a group + */ +export async function addUserToGroup(groupId: number, userId: number, addedBy?: number): Promise { + try { + await db + .insert(groupMembers) + .values({ + groupId, + userId, + }) + .onConflictDoNothing(); + + await logAccessAttempt({ + userId: addedBy, + action: 'role_updated', + resourceType: 'group', + resourceId: groupId, + result: 'success', + metadata: { action: 'user_added', targetUserId: userId }, + }); + + logger.info({ groupId, userId }, 'User added to group'); + } catch (error) { + logger.error({ error, groupId, userId }, 'Failed to add user to group'); + throw error; + } +} + +/** + * Remove a user from a group + */ +export async function removeUserFromGroup(groupId: number, userId: number, removedBy?: number): Promise { + try { + await db + .delete(groupMembers) + .where( + and( + eq(groupMembers.groupId, groupId), + eq(groupMembers.userId, userId) + ) + ); + + await logAccessAttempt({ + userId: removedBy, + action: 'role_updated', + resourceType: 'group', + resourceId: groupId, + result: 'success', + metadata: { action: 'user_removed', targetUserId: userId }, + }); + + logger.info({ groupId, userId }, 'User removed from group'); + } catch (error) { + logger.error({ error, groupId, userId }, 'Failed to remove user from group'); + throw error; + } +} + +/** + * Get all members of a group + */ +export async function getGroupMembers(groupId: number): Promise { + try { + const members = await db + .select({ userId: groupMembers.userId }) + .from(groupMembers) + .where(eq(groupMembers.groupId, groupId)); + + return members.map(m => m.userId); + } catch (error) { + logger.error({ error, groupId }, 'Failed to get group members'); + return []; + } +} + +/** + * Add a project to a group + */ +export async function addProjectToGroup(groupId: number, projectId: number, addedBy?: number): Promise { + try { + await db + .insert(groupProjects) + .values({ + groupId, + projectId, + }) + .onConflictDoNothing(); + + await logAccessAttempt({ + userId: addedBy, + action: 'role_updated', + resourceType: 'group', + resourceId: groupId, + result: 'success', + metadata: { action: 'project_added', projectId }, + }); + + logger.info({ groupId, projectId }, 'Project added to group'); + } catch (error) { + logger.error({ error, groupId, projectId }, 'Failed to add project to group'); + throw error; + } +} + +/** + * Remove a project from a group + */ +export async function removeProjectFromGroup(groupId: number, projectId: number, removedBy?: number): Promise { + try { + await db + .delete(groupProjects) + .where( + and( + eq(groupProjects.groupId, groupId), + eq(groupProjects.projectId, projectId) + ) + ); + + await logAccessAttempt({ + userId: removedBy, + action: 'role_updated', + resourceType: 'group', + resourceId: groupId, + result: 'success', + metadata: { action: 'project_removed', projectId }, + }); + + logger.info({ groupId, projectId }, 'Project removed from group'); + } catch (error) { + logger.error({ error, groupId, projectId }, 'Failed to remove project from group'); + throw error; + } +} + +/** + * Get all projects in a group + */ +export async function getGroupProjects(groupId: number): Promise { + try { + const projects = await db + .select({ projectId: groupProjects.projectId }) + .from(groupProjects) + .where(eq(groupProjects.groupId, groupId)); + + return projects.map(p => p.projectId); + } catch (error) { + logger.error({ error, groupId }, 'Failed to get group projects'); + return []; + } +} + +/** + * Get all groups a user belongs to + */ +export async function getUserGroups(userId: number): Promise { + try { + const userGroupsData = await db + .select({ group: groups }) + .from(groupMembers) + .innerJoin(groups, eq(groupMembers.groupId, groups.id)) + .where(eq(groupMembers.userId, userId)); + + return userGroupsData.map(g => g.group); + } catch (error) { + logger.error({ error, userId }, 'Failed to get user groups'); + return []; + } +} diff --git a/lib/rbac/impersonation-service.ts b/lib/rbac/impersonation-service.ts new file mode 100644 index 0000000..94fed0f --- /dev/null +++ b/lib/rbac/impersonation-service.ts @@ -0,0 +1,355 @@ +/** + * RBAC Impersonation Service + * + * Handles user impersonation for super admin group members. + * Allows admins to switch to any user's perspective for support and debugging. + */ + +import { db } from '@/lib/db'; +import { impersonationSessions, groupMembers, groups, users } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { logAccessAttempt } from './audit-service'; +import { createLogger } from '@/lib/observability/logger'; +import crypto from 'crypto'; + +const logger = createLogger({ service: 'rbac-impersonation' }); + +// Special group name for super admins +export const SUPER_ADMIN_GROUP_NAME = 'super_admins'; + +// Default impersonation session duration (1 hour) +const DEFAULT_SESSION_DURATION_MS = 60 * 60 * 1000; + +export interface ImpersonationSession { + id: number; + adminUserId: number; + impersonatedUserId: number; + sessionToken: string; + reason: string | null; + ipAddress: string | null; + userAgent: string | null; + isActive: boolean; + createdAt: Date; + expiresAt: Date; + endedAt: Date | null; +} + +export interface StartImpersonationInput { + adminUserId: number; + impersonatedUserId: number; + reason?: string; + ipAddress?: string; + userAgent?: string; + durationMs?: number; +} + +/** + * Check if a user is a member of the super admin group + */ +export async function isSuperAdmin(userId: number): Promise { + try { + const result = await db + .select({ id: groupMembers.id }) + .from(groupMembers) + .innerJoin(groups, eq(groupMembers.groupId, groups.id)) + .where( + and( + eq(groupMembers.userId, userId), + eq(groups.name, SUPER_ADMIN_GROUP_NAME) + ) + ) + .limit(1); + + return result.length > 0; + } catch (error) { + logger.error({ error, userId }, 'Failed to check super admin status'); + return false; + } +} + +/** + * Generate a secure session token + */ +function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Start an impersonation session + */ +export async function startImpersonation(input: StartImpersonationInput): Promise { + const { adminUserId, impersonatedUserId, reason, ipAddress, userAgent, durationMs } = input; + + try { + // 1. Check if admin user is a super admin + const isAdmin = await isSuperAdmin(adminUserId); + if (!isAdmin) { + logger.warn({ adminUserId, impersonatedUserId }, 'Non-super-admin attempted impersonation'); + + await logAccessAttempt({ + userId: adminUserId, + action: 'access_denied', + resourceType: 'impersonation', + resourceId: impersonatedUserId, + result: 'denied', + metadata: { reason: 'not_super_admin' }, + ipAddress, + userAgent, + }); + + return null; + } + + // 2. Verify impersonated user exists + const [targetUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, impersonatedUserId)) + .limit(1); + + if (!targetUser) { + logger.warn({ adminUserId, impersonatedUserId }, 'Attempted to impersonate non-existent user'); + return null; + } + + // 3. Prevent impersonating yourself + if (adminUserId === impersonatedUserId) { + logger.warn({ adminUserId }, 'Attempted to impersonate self'); + return null; + } + + // 4. End any existing active sessions for this admin + await db + .update(impersonationSessions) + .set({ + isActive: false, + endedAt: new Date(), + }) + .where( + and( + eq(impersonationSessions.adminUserId, adminUserId), + eq(impersonationSessions.isActive, true) + ) + ); + + // 5. Create new impersonation session + const sessionToken = generateSessionToken(); + const expiresAt = new Date(Date.now() + (durationMs || DEFAULT_SESSION_DURATION_MS)); + + const [session] = await db + .insert(impersonationSessions) + .values({ + adminUserId, + impersonatedUserId, + sessionToken, + reason: reason ?? null, + ipAddress: ipAddress ?? null, + userAgent: userAgent ?? null, + expiresAt, + }) + .returning(); + + // 6. Audit log + await logAccessAttempt({ + userId: adminUserId, + action: 'access_granted', + resourceType: 'impersonation', + resourceId: impersonatedUserId, + result: 'success', + metadata: { + sessionToken, + reason, + expiresAt: expiresAt.toISOString(), + }, + ipAddress, + userAgent, + }); + + logger.info( + { adminUserId, impersonatedUserId, sessionId: session.id }, + 'Impersonation session started' + ); + + return session; + } catch (error) { + logger.error({ error, input }, 'Failed to start impersonation session'); + return null; + } +} + +/** + * End an impersonation session + */ +export async function endImpersonation(sessionToken: string): Promise { + try { + const [session] = await db + .update(impersonationSessions) + .set({ + isActive: false, + endedAt: new Date(), + }) + .where( + and( + eq(impersonationSessions.sessionToken, sessionToken), + eq(impersonationSessions.isActive, true) + ) + ) + .returning(); + + if (!session) { + logger.warn({ sessionToken }, 'Attempted to end non-existent or inactive session'); + return false; + } + + await logAccessAttempt({ + userId: session.adminUserId, + action: 'access_granted', + resourceType: 'impersonation', + resourceId: session.impersonatedUserId, + result: 'success', + metadata: { + action: 'ended', + sessionId: session.id, + }, + }); + + logger.info( + { sessionId: session.id, adminUserId: session.adminUserId }, + 'Impersonation session ended' + ); + + return true; + } catch (error) { + logger.error({ error, sessionToken }, 'Failed to end impersonation session'); + return false; + } +} + +/** + * Get an active impersonation session by token + */ +export async function getImpersonationSession(sessionToken: string): Promise { + try { + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.sessionToken, sessionToken), + eq(impersonationSessions.isActive, true) + ) + ) + .limit(1); + + if (!session) { + return null; + } + + // Check if session has expired + if (new Date() > session.expiresAt) { + // Auto-expire the session + await db + .update(impersonationSessions) + .set({ + isActive: false, + endedAt: new Date(), + }) + .where(eq(impersonationSessions.id, session.id)); + + logger.info({ sessionId: session.id }, 'Impersonation session auto-expired'); + return null; + } + + return session; + } catch (error) { + logger.error({ error, sessionToken }, 'Failed to get impersonation session'); + return null; + } +} + +/** + * Get all active impersonation sessions for an admin + */ +export async function getAdminActiveSessions(adminUserId: number): Promise { + try { + const sessions = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.adminUserId, adminUserId), + eq(impersonationSessions.isActive, true) + ) + ); + + return sessions; + } catch (error) { + logger.error({ error, adminUserId }, 'Failed to get admin active sessions'); + return []; + } +} + +/** + * Get all impersonation sessions (for audit purposes) + */ +export async function getImpersonationHistory(options?: { + adminUserId?: number; + impersonatedUserId?: number; + limit?: number; +}): Promise { + try { + let query = db.select().from(impersonationSessions); + + const conditions = []; + if (options?.adminUserId) { + conditions.push(eq(impersonationSessions.adminUserId, options.adminUserId)); + } + if (options?.impersonatedUserId) { + conditions.push(eq(impersonationSessions.impersonatedUserId, options.impersonatedUserId)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any; + } + + const sessions = await query.limit(options?.limit || 100); + return sessions; + } catch (error) { + logger.error({ error, options }, 'Failed to get impersonation history'); + return []; + } +} + +/** + * Validate if a user can be impersonated + */ +export async function canImpersonate(adminUserId: number, targetUserId: number): Promise { + try { + // Check if admin is super admin + const isAdmin = await isSuperAdmin(adminUserId); + if (!isAdmin) { + return false; + } + + // Check if target user exists + const [targetUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, targetUserId)) + .limit(1); + + if (!targetUser) { + return false; + } + + // Cannot impersonate yourself + if (adminUserId === targetUserId) { + return false; + } + + return true; + } catch (error) { + logger.error({ error, adminUserId, targetUserId }, 'Failed to check impersonation capability'); + return false; + } +} diff --git a/lib/rbac/index.ts b/lib/rbac/index.ts new file mode 100644 index 0000000..d9ce7c8 --- /dev/null +++ b/lib/rbac/index.ts @@ -0,0 +1,100 @@ +/** + * RBAC System Main Exports + * + * This is the main entry point for the RBAC system. + * Import RBAC functionality from this module. + */ + +// Types +export * from './types'; + +// Permission Checker +export { + checkPermission, + checkMultiplePermissions, + invalidateUserPermissionCache, + clearExpiredCache, +} from './permission-checker'; + +// Role Service +export { + createRole, + getRoleById, + getRoleByName, + listRoles, + updateRole, + setRoleActive, + deleteRole, + assignPermissionsToRole, + removePermissionsFromRole, + getRolePermissions, +} from './role-service'; + +// Permission Service +export { + createPermission, + getPermissionById, + getPermissionByName, + listPermissions, + updatePermission, + setPermissionActive, + deletePermission, + getOrCreatePermission, +} from './permission-service'; + +// User Role Service +export { + assignRole, + revokeRole, + getUserPermissions, + getUsersByRole, + userHasRole, +} from './user-role-service'; + +// Group Service +export { + createGroup, + getGroupById, + listGroups, + updateGroup, + deleteGroup, + addUserToGroup, + removeUserFromGroup, + getGroupMembers, + addProjectToGroup, + removeProjectFromGroup, + getGroupProjects, + getUserGroups, +} from './group-service'; + +// Audit Service +export { + logAccessAttempt, + logPermissionGranted, + logPermissionDenied, + logRoleAssigned, + logRoleRevoked, +} from './audit-service'; + +// Impersonation Service +export { + isSuperAdmin, + startImpersonation, + endImpersonation, + getImpersonationSession, + getAdminActiveSessions, + getImpersonationHistory, + canImpersonate, + SUPER_ADMIN_GROUP_NAME, +} from './impersonation-service'; +export type { ImpersonationSession, StartImpersonationInput } from './impersonation-service'; + +// Middleware & Utilities +export { + requireAnyPermission, + requireAllPermissions, + checkResourcePermission, + checkPermissionRequirement, + extractProjectId, +} from './middleware'; +export type { PermissionRequirement } from './middleware'; diff --git a/lib/rbac/middleware.ts b/lib/rbac/middleware.ts new file mode 100644 index 0000000..31c0905 --- /dev/null +++ b/lib/rbac/middleware.ts @@ -0,0 +1,166 @@ +/** + * RBAC Middleware Utilities + * + * Helper functions and decorators for applying permission checks to endpoints. + */ + +import { checkPermission, checkMultiplePermissions } from './permission-checker'; +import { TRPCError } from '@trpc/server'; + +/** + * Require one of multiple permissions (OR logic) + */ +export async function requireAnyPermission( + userId: number, + permissions: string[], + projectId?: number +): Promise { + const results = await checkMultiplePermissions(userId, permissions, projectId); + const hasAny = Object.values(results).some(has => has); + + if (!hasAny) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Requires one of: ${permissions.join(', ')}`, + }); + } +} + +/** + * Require all of multiple permissions (AND logic) + */ +export async function requireAllPermissions( + userId: number, + permissions: string[], + projectId?: number +): Promise { + const results = await checkMultiplePermissions(userId, permissions, projectId); + const hasAll = Object.values(results).every(has => has); + + if (!hasAll) { + const missing = Object.entries(results) + .filter(([_, has]) => !has) + .map(([perm]) => perm); + + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Missing permissions: ${missing.join(', ')}`, + }); + } +} + +/** + * Check resource-specific permission + * Useful for checking permissions on specific resources (e.g., "can user edit THIS post?") + */ +export async function checkResourcePermission( + userId: number, + permission: string, + resourceOwnerId?: number, + projectId?: number +): Promise { + // Check base permission + const hasPermission = await checkPermission({ + userId, + permission, + projectId, + }); + + if (!hasPermission) { + return false; + } + + // If checking on a specific resource with an owner, verify ownership + // unless user has admin-level permission + if (resourceOwnerId !== undefined) { + // User owns the resource + if (userId === resourceOwnerId) { + return true; + } + + // Check if user has admin-level permission (e.g., "*.update.all" vs "*.update.own") + const adminPermission = permission.replace('.', '.all.'); + const hasAdminPerm = await checkPermission({ + userId, + permission: adminPermission, + projectId, + useCache: true, + }); + + return hasAdminPerm; + } + + return true; +} + +/** + * Permission requirement configurations + */ +export interface PermissionRequirement { + // Single permission + permission?: string; + + // Multiple permissions (OR logic) + anyOf?: string[]; + + // Multiple permissions (AND logic) + allOf?: string[]; + + // Optional project scope + projectId?: number; + + // Custom error message + errorMessage?: string; +} + +/** + * Check permission requirement + */ +export async function checkPermissionRequirement( + userId: number, + requirement: PermissionRequirement +): Promise { + const { permission, anyOf, allOf, projectId, errorMessage } = requirement; + + try { + if (permission) { + const has = await checkPermission({ userId, permission, projectId }); + if (!has) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: errorMessage || `Permission required: ${permission}`, + }); + } + } else if (anyOf) { + await requireAnyPermission(userId, anyOf, projectId); + } else if (allOf) { + await requireAllPermissions(userId, allOf, projectId); + } + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: 'FORBIDDEN', + message: errorMessage || 'Insufficient permissions', + }); + } +} + +/** + * Extract project ID from various input formats + */ +export function extractProjectId(input: any): number | undefined { + if (!input) return undefined; + + // Direct projectId field + if (input.projectId) return input.projectId; + + // Nested in params + if (input.params?.projectId) return input.params.projectId; + + // Nested in data + if (input.data?.projectId) return input.data.projectId; + + return undefined; +} diff --git a/lib/rbac/permission-checker.ts b/lib/rbac/permission-checker.ts new file mode 100644 index 0000000..92d2cb3 --- /dev/null +++ b/lib/rbac/permission-checker.ts @@ -0,0 +1,366 @@ +/** + * RBAC Permission Checker + * + * Core permission checking logic with caching support. + */ + +import { db } from '@/lib/db'; +import { + permissions, + roles, + rolePermissions, + userSystemRoles, + userProjectRoles, + userGroupRoles, + permissionCache, +} from '@/lib/db/schema'; +import { eq, and, or, isNull, lt } from 'drizzle-orm'; +import { CheckPermissionOptions } from './types'; +import { logPermissionGranted, logPermissionDenied } from './audit-service'; +import { createLogger } from '@/lib/observability/logger'; + +const logger = createLogger({ service: 'rbac-permission-checker' }); + +// Cache TTL in seconds (5 minutes) +const CACHE_TTL_SECONDS = 300; + +/** + * Generate a cache key for permission checks + */ +function generateCacheKey(userId: number, permission: string, projectId?: number): string { + return `user:${userId}:perm:${permission}${projectId ? `:project:${projectId}` : ''}`; +} + +/** + * Check cached permission + */ +async function checkPermissionCache( + userId: number, + permissionName: string, + projectId?: number +): Promise { + const cacheKey = generateCacheKey(userId, permissionName, projectId); + + try { + const cached = await db + .select() + .from(permissionCache) + .where( + and( + eq(permissionCache.cacheKey, cacheKey), + lt(new Date(), permissionCache.expiresAt) + ) + ) + .limit(1); + + if (cached.length > 0) { + logger.debug({ userId, permission: permissionName, projectId, cached: true }, 'Permission cache hit'); + return cached[0].hasPermission; + } + + return null; + } catch (error) { + logger.error({ error, userId, permission: permissionName }, 'Failed to check permission cache'); + return null; + } +} + +/** + * Cache a permission result + */ +async function cachePermissionResult( + userId: number, + permissionId: number, + permissionName: string, + projectId: number | undefined, + hasPermission: boolean +): Promise { + const cacheKey = generateCacheKey(userId, permissionName, projectId); + const expiresAt = new Date(Date.now() + CACHE_TTL_SECONDS * 1000); + + try { + // Delete existing cache entry if it exists + await db.delete(permissionCache).where(eq(permissionCache.cacheKey, cacheKey)); + + // Insert new cache entry + await db.insert(permissionCache).values({ + userId, + projectId: projectId ?? null, + permissionId, + hasPermission, + cacheKey, + expiresAt, + }); + + logger.debug({ userId, permission: permissionName, projectId, hasPermission }, 'Permission cached'); + } catch (error) { + logger.error({ error, userId, permission: permissionName }, 'Failed to cache permission'); + // Don't throw - caching failure shouldn't break permission checks + } +} + +/** + * Get all user's roles (system, project, and group) + */ +async function getUserAllRoles(userId: number, projectId?: number): Promise { + const roleIds: number[] = []; + + try { + // 1. Get system roles + const systemRoles = await db + .select({ roleId: userSystemRoles.roleId }) + .from(userSystemRoles) + .where( + and( + eq(userSystemRoles.userId, userId), + or( + isNull(userSystemRoles.expiresAt), + lt(new Date(), userSystemRoles.expiresAt) + ) + ) + ); + + roleIds.push(...systemRoles.map(r => r.roleId)); + + // 2. Get project roles if projectId is provided + if (projectId) { + const projectRoles = await db + .select({ roleId: userProjectRoles.roleId }) + .from(userProjectRoles) + .where( + and( + eq(userProjectRoles.userId, userId), + eq(userProjectRoles.projectId, projectId), + or( + isNull(userProjectRoles.expiresAt), + lt(new Date(), userProjectRoles.expiresAt) + ) + ) + ); + + roleIds.push(...projectRoles.map(r => r.roleId)); + + // 3. Get group roles for this project's groups + const groupRoles = await db + .select({ roleId: userGroupRoles.roleId }) + .from(userGroupRoles) + .where( + and( + eq(userGroupRoles.userId, userId), + or( + isNull(userGroupRoles.expiresAt), + lt(new Date(), userGroupRoles.expiresAt) + ) + ) + ); + + roleIds.push(...groupRoles.map(r => r.roleId)); + } + + return [...new Set(roleIds)]; // Remove duplicates + } catch (error) { + logger.error({ error, userId, projectId }, 'Failed to get user roles'); + return []; + } +} + +/** + * Get all permissions from a list of roles + */ +async function getRolePermissions(roleIds: number[]): Promise { + if (roleIds.length === 0) { + return []; + } + + try { + const perms = await db + .select({ name: permissions.name }) + .from(rolePermissions) + .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id)) + .innerJoin(roles, eq(rolePermissions.roleId, roles.id)) + .where( + and( + eq(permissions.isActive, true), + eq(roles.isActive, true), + or(...roleIds.map(id => eq(rolePermissions.roleId, id))) + ) + ); + + return perms.map(p => p.name); + } catch (error) { + logger.error({ error, roleIds }, 'Failed to get role permissions'); + return []; + } +} + +/** + * Check if a user has a specific permission + * + * Permission resolution follows this hierarchy: + * 1. System-level permissions (user_system_roles) + * 2. Group-level permissions (user_group_roles) + * 3. Project-level permissions (user_project_roles) + */ +export async function checkPermission(options: CheckPermissionOptions): Promise { + const { userId, permission, projectId, useCache = true } = options; + + logger.debug({ userId, permission, projectId }, 'Checking permission'); + + try { + // 1. Check cache first if enabled + if (useCache) { + const cached = await checkPermissionCache(userId, permission, projectId); + if (cached !== null) { + if (!cached) { + await logPermissionDenied(userId, permission, projectId, 'cached_denial'); + } + return cached; + } + } + + // 2. Get the permission ID + const perm = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.name, permission), + eq(permissions.isActive, true) + ) + ) + .limit(1); + + if (perm.length === 0) { + logger.warn({ permission }, 'Permission not found'); + await logPermissionDenied(userId, permission, projectId, 'permission_not_found'); + return false; + } + + const permissionId = perm[0].id; + + // 3. Get all user's roles (system, project, group) + const userRoles = await getUserAllRoles(userId, projectId); + + if (userRoles.length === 0) { + logger.debug({ userId, permission, projectId }, 'User has no roles'); + await logPermissionDenied(userId, permission, projectId, 'no_roles'); + + // Cache the denial + if (useCache) { + await cachePermissionResult(userId, permissionId, permission, projectId, false); + } + + return false; + } + + // 4. Get all permissions from roles + const userPermissions = await getRolePermissions(userRoles); + + // 5. Check if permission exists + const hasPermission = userPermissions.includes(permission); + + // 6. Cache result + if (useCache) { + await cachePermissionResult(userId, permissionId, permission, projectId, hasPermission); + } + + // 7. Audit log + if (hasPermission) { + await logPermissionGranted(userId, permission, projectId); + } else { + await logPermissionDenied(userId, permission, projectId, 'insufficient_permissions'); + } + + logger.info({ userId, permission, projectId, hasPermission }, 'Permission check completed'); + + return hasPermission; + } catch (error) { + logger.error({ error, userId, permission, projectId }, 'Permission check failed'); + await logPermissionDenied(userId, permission, projectId, 'error'); + return false; + } +} + +/** + * Check multiple permissions at once (optimized) + */ +export async function checkMultiplePermissions( + userId: number, + permissionNames: string[], + projectId?: number +): Promise> { + const result: Record = {}; + + try { + // Get all user's roles once + const userRoles = await getUserAllRoles(userId, projectId); + + if (userRoles.length === 0) { + // User has no roles, deny all permissions + for (const perm of permissionNames) { + result[perm] = false; + } + return result; + } + + // Get all permissions from roles + const userPermissions = await getRolePermissions(userRoles); + + // Check each permission + for (const perm of permissionNames) { + result[perm] = userPermissions.includes(perm); + } + + return result; + } catch (error) { + logger.error({ error, userId, permissions: permissionNames, projectId }, 'Multiple permission check failed'); + + // On error, deny all permissions + for (const perm of permissionNames) { + result[perm] = false; + } + + return result; + } +} + +/** + * Invalidate permission cache for a user + */ +export async function invalidateUserPermissionCache(userId: number, projectId?: number): Promise { + try { + if (projectId) { + await db + .delete(permissionCache) + .where( + and( + eq(permissionCache.userId, userId), + eq(permissionCache.projectId, projectId) + ) + ); + } else { + await db + .delete(permissionCache) + .where(eq(permissionCache.userId, userId)); + } + + logger.info({ userId, projectId }, 'User permission cache invalidated'); + } catch (error) { + logger.error({ error, userId, projectId }, 'Failed to invalidate permission cache'); + } +} + +/** + * Clear all expired cache entries + */ +export async function clearExpiredCache(): Promise { + try { + await db + .delete(permissionCache) + .where(lt(permissionCache.expiresAt, new Date())); + + logger.info('Expired permission cache entries cleared'); + } catch (error) { + logger.error({ error }, 'Failed to clear expired cache'); + } +} diff --git a/lib/rbac/permission-service.ts b/lib/rbac/permission-service.ts new file mode 100644 index 0000000..fdad1b4 --- /dev/null +++ b/lib/rbac/permission-service.ts @@ -0,0 +1,245 @@ +/** + * RBAC Permission Service + * + * Handles CRUD operations for permissions. + */ + +import { db } from '@/lib/db'; +import { permissions } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { CreatePermissionInput, Permission } from './types'; +import { logAccessAttempt } from './audit-service'; +import { createLogger } from '@/lib/observability/logger'; + +const logger = createLogger({ service: 'rbac-permission-service' }); + +/** + * Create a new permission + */ +export async function createPermission(input: CreatePermissionInput, createdBy?: number): Promise { + try { + const [permission] = await db.insert(permissions).values({ + name: input.name, + displayName: input.displayName, + description: input.description ?? null, + resourceType: input.resourceType, + resourceName: input.resourceName, + action: input.action, + metadata: input.metadata ?? null, + }).returning(); + + await logAccessAttempt({ + userId: createdBy, + action: 'permission_created', + resourceType: 'permission', + resourceId: permission.id, + permissionId: permission.id, + result: 'success', + metadata: { permissionName: permission.name }, + }); + + logger.info({ permissionId: permission.id, permissionName: permission.name }, 'Permission created'); + + return permission; + } catch (error) { + logger.error({ error, input }, 'Failed to create permission'); + throw error; + } +} + +/** + * Get a permission by ID + */ +export async function getPermissionById(permissionId: number): Promise { + try { + const [permission] = await db + .select() + .from(permissions) + .where(eq(permissions.id, permissionId)) + .limit(1); + + return permission || null; + } catch (error) { + logger.error({ error, permissionId }, 'Failed to get permission by ID'); + return null; + } +} + +/** + * Get a permission by name + */ +export async function getPermissionByName(name: string): Promise { + try { + const [permission] = await db + .select() + .from(permissions) + .where(eq(permissions.name, name)) + .limit(1); + + return permission || null; + } catch (error) { + logger.error({ error, name }, 'Failed to get permission by name'); + return null; + } +} + +/** + * List all permissions + */ +export async function listPermissions(options?: { + resourceType?: string; + resourceName?: string; + action?: string; + isActive?: boolean; +}): Promise { + try { + let query = db.select().from(permissions); + + const conditions = []; + + if (options?.resourceType) { + conditions.push(eq(permissions.resourceType, options.resourceType)); + } + + if (options?.resourceName) { + conditions.push(eq(permissions.resourceName, options.resourceName)); + } + + if (options?.action) { + conditions.push(eq(permissions.action, options.action)); + } + + if (options?.isActive !== undefined) { + conditions.push(eq(permissions.isActive, options.isActive)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any; + } + + const result = await query; + return result; + } catch (error) { + logger.error({ error, options }, 'Failed to list permissions'); + return []; + } +} + +/** + * Update a permission + */ +export async function updatePermission( + permissionId: number, + updates: Partial>, + updatedBy?: number +): Promise { + try { + const [permission] = await db + .update(permissions) + .set({ + displayName: updates.displayName, + description: updates.description, + metadata: updates.metadata, + }) + .where(eq(permissions.id, permissionId)) + .returning(); + + await logAccessAttempt({ + userId: updatedBy, + action: 'permission_updated', + resourceType: 'permission', + resourceId: permissionId, + permissionId, + result: 'success', + metadata: { updates }, + }); + + logger.info({ permissionId, permissionName: permission.name }, 'Permission updated'); + + return permission || null; + } catch (error) { + logger.error({ error, permissionId, updates }, 'Failed to update permission'); + throw error; + } +} + +/** + * Activate/deactivate a permission + */ +export async function setPermissionActive(permissionId: number, isActive: boolean, updatedBy?: number): Promise { + try { + await db + .update(permissions) + .set({ isActive }) + .where(eq(permissions.id, permissionId)); + + await logAccessAttempt({ + userId: updatedBy, + action: 'permission_updated', + resourceType: 'permission', + resourceId: permissionId, + permissionId, + result: 'success', + metadata: { isActive }, + }); + + logger.info({ permissionId, isActive }, 'Permission activation status updated'); + + return true; + } catch (error) { + logger.error({ error, permissionId, isActive }, 'Failed to set permission active status'); + throw error; + } +} + +/** + * Delete a permission + */ +export async function deletePermission(permissionId: number, deletedBy?: number): Promise { + try { + const existingPermission = await getPermissionById(permissionId); + if (!existingPermission) { + return false; + } + + await db.delete(permissions).where(eq(permissions.id, permissionId)); + + await logAccessAttempt({ + userId: deletedBy, + action: 'permission_updated', + resourceType: 'permission', + resourceId: permissionId, + permissionId, + result: 'success', + metadata: { action: 'deleted' }, + }); + + logger.info({ permissionId, permissionName: existingPermission.name }, 'Permission deleted'); + + return true; + } catch (error) { + logger.error({ error, permissionId }, 'Failed to delete permission'); + throw error; + } +} + +/** + * Get or create a permission + * + * Useful for ensuring permissions exist during runtime. + */ +export async function getOrCreatePermission(input: CreatePermissionInput): Promise { + try { + // Try to find existing permission + const existing = await getPermissionByName(input.name); + if (existing) { + return existing; + } + + // Create new permission + return await createPermission(input); + } catch (error) { + logger.error({ error, input }, 'Failed to get or create permission'); + throw error; + } +} diff --git a/lib/rbac/role-service.ts b/lib/rbac/role-service.ts new file mode 100644 index 0000000..7bf41f6 --- /dev/null +++ b/lib/rbac/role-service.ts @@ -0,0 +1,355 @@ +/** + * RBAC Role Service + * + * Handles CRUD operations for roles and role-permission assignments. + */ + +import { db } from '@/lib/db'; +import { roles, rolePermissions, permissions } from '@/lib/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { CreateRoleInput, Role } from './types'; +import { logAccessAttempt } from './audit-service'; +import { createLogger } from '@/lib/observability/logger'; + +const logger = createLogger({ service: 'rbac-role-service' }); + +/** + * Create a new role + */ +export async function createRole(input: CreateRoleInput, createdBy?: number): Promise { + try { + const [role] = await db.insert(roles).values({ + name: input.name, + displayName: input.displayName, + description: input.description ?? null, + roleType: input.roleType, + isBuiltIn: input.isBuiltIn ?? false, + metadata: input.metadata ?? null, + }).returning(); + + await logAccessAttempt({ + userId: createdBy, + action: 'role_created', + resourceType: 'role', + resourceId: role.id, + roleId: role.id, + result: 'success', + metadata: { roleName: role.name }, + }); + + logger.info({ roleId: role.id, roleName: role.name }, 'Role created'); + + return role; + } catch (error) { + logger.error({ error, input }, 'Failed to create role'); + throw error; + } +} + +/** + * Get a role by ID + */ +export async function getRoleById(roleId: number): Promise { + try { + const [role] = await db + .select() + .from(roles) + .where(eq(roles.id, roleId)) + .limit(1); + + return role || null; + } catch (error) { + logger.error({ error, roleId }, 'Failed to get role by ID'); + return null; + } +} + +/** + * Get a role by name + */ +export async function getRoleByName(name: string): Promise { + try { + const [role] = await db + .select() + .from(roles) + .where(eq(roles.name, name)) + .limit(1); + + return role || null; + } catch (error) { + logger.error({ error, name }, 'Failed to get role by name'); + return null; + } +} + +/** + * List all roles + */ +export async function listRoles(options?: { + roleType?: string; + isActive?: boolean; + includeBuiltIn?: boolean; +}): Promise { + try { + let query = db.select().from(roles); + + const conditions = []; + + if (options?.roleType) { + conditions.push(eq(roles.roleType, options.roleType)); + } + + if (options?.isActive !== undefined) { + conditions.push(eq(roles.isActive, options.isActive)); + } + + if (options?.includeBuiltIn === false) { + conditions.push(eq(roles.isBuiltIn, false)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any; + } + + const result = await query; + return result; + } catch (error) { + logger.error({ error, options }, 'Failed to list roles'); + return []; + } +} + +/** + * Update a role + */ +export async function updateRole( + roleId: number, + updates: Partial>, + updatedBy?: number +): Promise { + try { + // Check if role is built-in + const existingRole = await getRoleById(roleId); + if (!existingRole) { + logger.warn({ roleId }, 'Role not found for update'); + return null; + } + + if (existingRole.isBuiltIn) { + logger.warn({ roleId, roleName: existingRole.name }, 'Cannot update built-in role'); + throw new Error('Cannot update built-in roles'); + } + + const [role] = await db + .update(roles) + .set({ + displayName: updates.displayName, + description: updates.description, + metadata: updates.metadata, + updatedAt: new Date(), + }) + .where(eq(roles.id, roleId)) + .returning(); + + await logAccessAttempt({ + userId: updatedBy, + action: 'role_updated', + resourceType: 'role', + resourceId: roleId, + roleId, + result: 'success', + metadata: { updates }, + }); + + logger.info({ roleId, roleName: role.name }, 'Role updated'); + + return role || null; + } catch (error) { + logger.error({ error, roleId, updates }, 'Failed to update role'); + throw error; + } +} + +/** + * Activate/deactivate a role + */ +export async function setRoleActive(roleId: number, isActive: boolean, updatedBy?: number): Promise { + try { + const existingRole = await getRoleById(roleId); + if (!existingRole) { + return false; + } + + if (existingRole.isBuiltIn) { + logger.warn({ roleId, roleName: existingRole.name }, 'Cannot deactivate built-in role'); + throw new Error('Cannot deactivate built-in roles'); + } + + await db + .update(roles) + .set({ isActive, updatedAt: new Date() }) + .where(eq(roles.id, roleId)); + + await logAccessAttempt({ + userId: updatedBy, + action: 'role_updated', + resourceType: 'role', + resourceId: roleId, + roleId, + result: 'success', + metadata: { isActive }, + }); + + logger.info({ roleId, isActive }, 'Role activation status updated'); + + return true; + } catch (error) { + logger.error({ error, roleId, isActive }, 'Failed to set role active status'); + throw error; + } +} + +/** + * Delete a role (only non-built-in roles) + */ +export async function deleteRole(roleId: number, deletedBy?: number): Promise { + try { + const existingRole = await getRoleById(roleId); + if (!existingRole) { + return false; + } + + if (existingRole.isBuiltIn) { + logger.warn({ roleId, roleName: existingRole.name }, 'Cannot delete built-in role'); + throw new Error('Cannot delete built-in roles'); + } + + await db.delete(roles).where(eq(roles.id, roleId)); + + await logAccessAttempt({ + userId: deletedBy, + action: 'role_updated', + resourceType: 'role', + resourceId: roleId, + roleId, + result: 'success', + metadata: { action: 'deleted' }, + }); + + logger.info({ roleId, roleName: existingRole.name }, 'Role deleted'); + + return true; + } catch (error) { + logger.error({ error, roleId }, 'Failed to delete role'); + throw error; + } +} + +/** + * Assign permissions to a role + */ +export async function assignPermissionsToRole( + roleId: number, + permissionIds: number[], + grantedBy?: number +): Promise { + try { + // Remove duplicates + const uniquePermissionIds = [...new Set(permissionIds)]; + + // Verify all permissions exist + const existingPermissions = await db + .select({ id: permissions.id }) + .from(permissions) + .where(inArray(permissions.id, uniquePermissionIds)); + + if (existingPermissions.length !== uniquePermissionIds.length) { + throw new Error('One or more permission IDs are invalid'); + } + + // Insert role-permission mappings + for (const permissionId of uniquePermissionIds) { + await db + .insert(rolePermissions) + .values({ + roleId, + permissionId, + grantedBy: grantedBy ?? null, + }) + .onConflictDoNothing(); + } + + await logAccessAttempt({ + userId: grantedBy, + action: 'role_updated', + resourceType: 'role', + resourceId: roleId, + roleId, + result: 'success', + metadata: { action: 'permissions_assigned', permissionIds: uniquePermissionIds }, + }); + + logger.info({ roleId, permissionIds: uniquePermissionIds }, 'Permissions assigned to role'); + } catch (error) { + logger.error({ error, roleId, permissionIds }, 'Failed to assign permissions to role'); + throw error; + } +} + +/** + * Remove permissions from a role + */ +export async function removePermissionsFromRole( + roleId: number, + permissionIds: number[], + revokedBy?: number +): Promise { + try { + await db + .delete(rolePermissions) + .where( + and( + eq(rolePermissions.roleId, roleId), + inArray(rolePermissions.permissionId, permissionIds) + ) + ); + + await logAccessAttempt({ + userId: revokedBy, + action: 'role_updated', + resourceType: 'role', + resourceId: roleId, + roleId, + result: 'success', + metadata: { action: 'permissions_removed', permissionIds }, + }); + + logger.info({ roleId, permissionIds }, 'Permissions removed from role'); + } catch (error) { + logger.error({ error, roleId, permissionIds }, 'Failed to remove permissions from role'); + throw error; + } +} + +/** + * Get all permissions for a role + */ +export async function getRolePermissions(roleId: number): Promise { + try { + const perms = await db + .select({ name: permissions.name }) + .from(rolePermissions) + .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id)) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(permissions.isActive, true) + ) + ); + + return perms.map(p => p.name); + } catch (error) { + logger.error({ error, roleId }, 'Failed to get role permissions'); + return []; + } +} diff --git a/lib/rbac/types.ts b/lib/rbac/types.ts new file mode 100644 index 0000000..0195e71 --- /dev/null +++ b/lib/rbac/types.ts @@ -0,0 +1,142 @@ +/** + * RBAC Type Definitions + * + * This file contains all TypeScript types and interfaces for the RBAC system. + */ + +export type RoleType = 'system' | 'project' | 'cross-project'; +export type ResourceType = 'api' | 'ui' | 'data'; +export type Action = 'create' | 'read' | 'update' | 'delete' | 'execute'; +export type AuditAction = 'grant_role' | 'revoke_role' | 'access_granted' | 'access_denied' | 'permission_created' | 'permission_updated' | 'role_created' | 'role_updated'; +export type AuditResult = 'success' | 'denied' | 'error'; +export type AccountType = 'nis' | 'ldap' | 'ad' | 'other'; +export type GroupType = 'project' | 'cross-project' | 'functional'; + +export interface Role { + id: number; + name: string; + displayName: string; + description: string | null; + roleType: RoleType; + isActive: boolean; + isBuiltIn: boolean; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface Permission { + id: number; + name: string; + displayName: string; + description: string | null; + resourceType: ResourceType; + resourceName: string; + action: Action; + isActive: boolean; + metadata: Record | null; + createdAt: Date; +} + +export interface UserRole { + userId: number; + roleId: number; + grantedAt: Date; + grantedBy: number | null; + expiresAt: Date | null; +} + +export interface UserProjectRole extends UserRole { + projectId: number; +} + +export interface UserGroupRole extends UserRole { + groupId: number; +} + +export interface Group { + id: number; + name: string; + description: string | null; + groupType: GroupType; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ExternalAccount { + id: number; + userId: number; + projectId: number | null; + accountType: AccountType; + username: string; + credentials: string | null; + metadata: Record | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface AuditLogEntry { + id: number; + userId: number | null; + action: AuditAction; + resourceType: string | null; + resourceId: number | null; + roleId: number | null; + permissionId: number | null; + result: AuditResult | null; + metadata: Record | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: Date; +} + +export interface CheckPermissionOptions { + userId: number; + permission: string; + projectId?: number; + useCache?: boolean; +} + +export interface CreateRoleInput { + name: string; + displayName: string; + description?: string; + roleType: RoleType; + isBuiltIn?: boolean; + metadata?: Record; +} + +export interface CreatePermissionInput { + name: string; + displayName: string; + description?: string; + resourceType: ResourceType; + resourceName: string; + action: Action; + metadata?: Record; +} + +export interface AssignRoleInput { + userId: number; + roleId: number; + grantedBy?: number; + expiresAt?: Date; + projectId?: number; + groupId?: number; +} + +export interface CreateGroupInput { + name: string; + description?: string; + groupType: GroupType; + metadata?: Record; +} + +export interface UserPermissionsResult { + systemRoles: Role[]; + projectRoles: (Role & { projectId: number })[]; + groupRoles: (Role & { groupId: number })[]; + permissions: string[]; +} diff --git a/lib/rbac/user-role-service.ts b/lib/rbac/user-role-service.ts new file mode 100644 index 0000000..a8ac4fa --- /dev/null +++ b/lib/rbac/user-role-service.ts @@ -0,0 +1,371 @@ +/** + * RBAC User Role Service + * + * Handles user role assignments and revocations. + */ + +import { db } from '@/lib/db'; +import { + userSystemRoles, + userProjectRoles, + userGroupRoles, + roles, + permissions, + rolePermissions, +} from '@/lib/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { AssignRoleInput, UserPermissionsResult } from './types'; +import { logRoleAssigned, logRoleRevoked } from './audit-service'; +import { invalidateUserPermissionCache } from './permission-checker'; +import { createLogger } from '@/lib/observability/logger'; + +const logger = createLogger({ service: 'rbac-user-role-service' }); + +/** + * Assign a role to a user + */ +export async function assignRole(input: AssignRoleInput): Promise { + const { userId, roleId, grantedBy, expiresAt, projectId, groupId } = input; + + try { + if (projectId) { + // Assign project role + await db + .insert(userProjectRoles) + .values({ + userId, + projectId, + roleId, + grantedBy: grantedBy ?? null, + expiresAt: expiresAt ?? null, + }) + .onConflictDoNothing(); + + logger.info({ userId, roleId, projectId }, 'Project role assigned'); + } else if (groupId) { + // Assign group role + await db + .insert(userGroupRoles) + .values({ + userId, + groupId, + roleId, + grantedBy: grantedBy ?? null, + expiresAt: expiresAt ?? null, + }) + .onConflictDoNothing(); + + logger.info({ userId, roleId, groupId }, 'Group role assigned'); + } else { + // Assign system role + await db + .insert(userSystemRoles) + .values({ + userId, + roleId, + grantedBy: grantedBy ?? null, + expiresAt: expiresAt ?? null, + }) + .onConflictDoNothing(); + + logger.info({ userId, roleId }, 'System role assigned'); + } + + // Invalidate cache + await invalidateUserPermissionCache(userId, projectId); + + // Audit log + await logRoleAssigned(userId, roleId, grantedBy, { projectId, groupId, expiresAt }); + } catch (error) { + logger.error({ error, input }, 'Failed to assign role'); + throw error; + } +} + +/** + * Revoke a role from a user + */ +export async function revokeRole( + userId: number, + roleId: number, + options?: { + projectId?: number; + groupId?: number; + revokedBy?: number; + } +): Promise { + try { + if (options?.projectId) { + // Revoke project role + await db + .delete(userProjectRoles) + .where( + and( + eq(userProjectRoles.userId, userId), + eq(userProjectRoles.roleId, roleId), + eq(userProjectRoles.projectId, options.projectId) + ) + ); + + logger.info({ userId, roleId, projectId: options.projectId }, 'Project role revoked'); + } else if (options?.groupId) { + // Revoke group role + await db + .delete(userGroupRoles) + .where( + and( + eq(userGroupRoles.userId, userId), + eq(userGroupRoles.roleId, roleId), + eq(userGroupRoles.groupId, options.groupId) + ) + ); + + logger.info({ userId, roleId, groupId: options.groupId }, 'Group role revoked'); + } else { + // Revoke system role + await db + .delete(userSystemRoles) + .where( + and( + eq(userSystemRoles.userId, userId), + eq(userSystemRoles.roleId, roleId) + ) + ); + + logger.info({ userId, roleId }, 'System role revoked'); + } + + // Invalidate cache + await invalidateUserPermissionCache(userId, options?.projectId); + + // Audit log + await logRoleRevoked(userId, roleId, options?.revokedBy, { + projectId: options?.projectId, + groupId: options?.groupId, + }); + } catch (error) { + logger.error({ error, userId, roleId, options }, 'Failed to revoke role'); + throw error; + } +} + +/** + * Get all roles and permissions for a user + */ +export async function getUserPermissions(userId: number, projectId?: number): Promise { + try { + const result: UserPermissionsResult = { + systemRoles: [], + projectRoles: [], + groupRoles: [], + permissions: [], + }; + + // 1. Get system roles + const systemRolesData = await db + .select({ + role: roles, + }) + .from(userSystemRoles) + .innerJoin(roles, eq(userSystemRoles.roleId, roles.id)) + .where( + and( + eq(userSystemRoles.userId, userId), + eq(roles.isActive, true) + ) + ); + + result.systemRoles = systemRolesData.map(r => r.role); + + // 2. Get project roles if projectId is provided + if (projectId) { + const projectRolesData = await db + .select({ + role: roles, + projectId: userProjectRoles.projectId, + }) + .from(userProjectRoles) + .innerJoin(roles, eq(userProjectRoles.roleId, roles.id)) + .where( + and( + eq(userProjectRoles.userId, userId), + eq(userProjectRoles.projectId, projectId), + eq(roles.isActive, true) + ) + ); + + result.projectRoles = projectRolesData.map(r => ({ + ...r.role, + projectId: r.projectId, + })); + + // 3. Get group roles + const groupRolesData = await db + .select({ + role: roles, + groupId: userGroupRoles.groupId, + }) + .from(userGroupRoles) + .innerJoin(roles, eq(userGroupRoles.roleId, roles.id)) + .where( + and( + eq(userGroupRoles.userId, userId), + eq(roles.isActive, true) + ) + ); + + result.groupRoles = groupRolesData.map(r => ({ + ...r.role, + groupId: r.groupId, + })); + } + + // 4. Get all permissions from all roles + const allRoleIds = [ + ...result.systemRoles.map(r => r.id), + ...result.projectRoles.map(r => r.id), + ...result.groupRoles.map(r => r.id), + ]; + + if (allRoleIds.length > 0) { + const perms = await db + .select({ name: permissions.name }) + .from(rolePermissions) + .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id)) + .where( + and( + inArray(rolePermissions.roleId, allRoleIds), + eq(permissions.isActive, true) + ) + ); + + result.permissions = [...new Set(perms.map(p => p.name))]; + } + + return result; + } catch (error) { + logger.error({ error, userId, projectId }, 'Failed to get user permissions'); + return { + systemRoles: [], + projectRoles: [], + groupRoles: [], + permissions: [], + }; + } +} + +/** + * Get users by role + */ +export async function getUsersByRole( + roleId: number, + options?: { + projectId?: number; + groupId?: number; + } +): Promise { + try { + const userIds: number[] = []; + + if (options?.projectId) { + // Get users with this project role + const users = await db + .select({ userId: userProjectRoles.userId }) + .from(userProjectRoles) + .where( + and( + eq(userProjectRoles.roleId, roleId), + eq(userProjectRoles.projectId, options.projectId) + ) + ); + + userIds.push(...users.map(u => u.userId)); + } else if (options?.groupId) { + // Get users with this group role + const users = await db + .select({ userId: userGroupRoles.userId }) + .from(userGroupRoles) + .where( + and( + eq(userGroupRoles.roleId, roleId), + eq(userGroupRoles.groupId, options.groupId) + ) + ); + + userIds.push(...users.map(u => u.userId)); + } else { + // Get users with this system role + const users = await db + .select({ userId: userSystemRoles.userId }) + .from(userSystemRoles) + .where(eq(userSystemRoles.roleId, roleId)); + + userIds.push(...users.map(u => u.userId)); + } + + return [...new Set(userIds)]; + } catch (error) { + logger.error({ error, roleId, options }, 'Failed to get users by role'); + return []; + } +} + +/** + * Check if a user has a specific role + */ +export async function userHasRole( + userId: number, + roleId: number, + options?: { + projectId?: number; + groupId?: number; + } +): Promise { + try { + if (options?.projectId) { + const [result] = await db + .select() + .from(userProjectRoles) + .where( + and( + eq(userProjectRoles.userId, userId), + eq(userProjectRoles.roleId, roleId), + eq(userProjectRoles.projectId, options.projectId) + ) + ) + .limit(1); + + return !!result; + } else if (options?.groupId) { + const [result] = await db + .select() + .from(userGroupRoles) + .where( + and( + eq(userGroupRoles.userId, userId), + eq(userGroupRoles.roleId, roleId), + eq(userGroupRoles.groupId, options.groupId) + ) + ) + .limit(1); + + return !!result; + } else { + const [result] = await db + .select() + .from(userSystemRoles) + .where( + and( + eq(userSystemRoles.userId, userId), + eq(userSystemRoles.roleId, roleId) + ) + ) + .limit(1); + + return !!result; + } + } catch (error) { + logger.error({ error, userId, roleId, options }, 'Failed to check if user has role'); + return false; + } +} diff --git a/lib/trpc/context.ts b/lib/trpc/context.ts index 86ea76b..7f23f91 100644 --- a/lib/trpc/context.ts +++ b/lib/trpc/context.ts @@ -1,8 +1,77 @@ import { db } from '@/lib/db'; +import { verifyToken, type JWTPayload } from '@/lib/auth/jwt'; +import { getImpersonationSession } from '@/lib/rbac'; +import { users } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +export interface ContextUser extends JWTPayload { + // Extend with additional fields if needed +} + +export interface ImpersonationInfo { + isImpersonating: boolean; + adminUserId?: number; // The original admin user ID + sessionToken?: string; // The impersonation session token +} + +export async function createContext(opts?: { + headers?: Headers; + req?: Request; +}) { + let user: ContextUser | null = null; + let impersonation: ImpersonationInfo = { isImpersonating: false }; + + // Extract token from headers + const headers = opts?.headers; + if (headers) { + const authHeader = headers.get('authorization'); + if (authHeader) { + const token = authHeader.replace('Bearer ', ''); + const payload = await verifyToken(token); + if (payload) { + user = payload; + + // Check for impersonation token + const impersonationToken = headers.get('x-impersonation-token'); + if (impersonationToken) { + const session = await getImpersonationSession(impersonationToken); + if (session && session.adminUserId === payload.userId) { + // Valid impersonation session - switch to impersonated user + const [impersonatedUser] = await db + .select({ + id: users.id, + username: users.username, + email: users.email, + authType: users.authType, + }) + .from(users) + .where(eq(users.id, session.impersonatedUserId)) + .limit(1); + + if (impersonatedUser) { + user = { + userId: impersonatedUser.id, + username: impersonatedUser.username, + email: impersonatedUser.email, + authType: impersonatedUser.authType, + }; + + impersonation = { + isImpersonating: true, + adminUserId: session.adminUserId, + sessionToken: impersonationToken, + }; + } + } + } + } + } + } -export async function createContext() { return { db, + user, + impersonation, }; } diff --git a/lib/trpc/trpc.ts b/lib/trpc/trpc.ts index 96211b2..b5ff018 100644 --- a/lib/trpc/trpc.ts +++ b/lib/trpc/trpc.ts @@ -49,3 +49,71 @@ const observabilityMiddleware = t.middleware(async ({ path, type, next }) => { export const router = t.router; export const publicProcedure = t.procedure.use(observabilityMiddleware); + +/** + * Protected procedure - requires authentication + * + * Use this for endpoints that require a valid user session. + */ +export const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + }); + } + + return next({ + ctx: { + ...ctx, + user: ctx.user, // Now TypeScript knows user is not null + }, + }); +}); + +/** + * Permission-based procedure factory + * + * Creates a procedure that requires a specific permission. + * Optionally checks project-level permissions if projectId is in the input. + * + * @param permission - The permission name to check (e.g., 'user.create', 'project.update') + * @returns A procedure that checks for the given permission + * + * @example + * ```typescript + * const userRouter = router({ + * create: permissionProcedure('user.create') + * .input(createUserSchema) + * .mutation(async ({ ctx, input }) => { + * // Permission already checked + * return createUser(input); + * }), + * }); + * ``` + */ +export function permissionProcedure(permission: string) { + return protectedProcedure.use(async ({ ctx, next, input }) => { + // Import dynamically to avoid circular dependency + const { checkPermission } = await import('@/lib/rbac'); + + // Extract projectId from input if it exists + const projectId = (input as any)?.projectId; + + // Check permission + const hasPermission = await checkPermission({ + userId: ctx.user.userId, + permission, + projectId, + }); + + if (!hasPermission) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Insufficient permissions: ${permission}`, + }); + } + + return next({ ctx }); + }); +} diff --git a/package.json b/package.json index 959ccfb..9806edb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "tsx scripts/seed.ts", + "db:seed-rbac": "tsx scripts/seed-rbac.ts", "ws:server": "tsx server/websocket.ts", "queue:worker": "tsx server/queue/worker.ts", "temporal:worker": "tsx server/temporal/workers/main.worker.ts", diff --git a/scripts/seed-rbac.ts b/scripts/seed-rbac.ts new file mode 100644 index 0000000..d9bff57 --- /dev/null +++ b/scripts/seed-rbac.ts @@ -0,0 +1,328 @@ +/** + * RBAC Seed Script + * + * Seeds the database with built-in roles and permissions. + * Run this script after database migration to set up the RBAC system. + */ + +import { + createRole, + createPermission, + assignPermissionsToRole, + getRoleByName, + getPermissionByName, + createGroup, + getGroupById, + SUPER_ADMIN_GROUP_NAME, +} from '@/lib/rbac'; +import { createLogger } from '@/lib/observability/logger'; +import { db } from '@/lib/db'; +import { groups } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +const logger = createLogger({ service: 'rbac-seed' }); + +// ===== Built-in Permissions ===== + +const BUILT_IN_PERMISSIONS = [ + // User Permissions + { name: 'user.create', displayName: 'Create User', description: 'Create new users', resourceType: 'api' as const, resourceName: 'user', action: 'create' as const }, + { name: 'user.read', displayName: 'Read User', description: 'View user information', resourceType: 'api' as const, resourceName: 'user', action: 'read' as const }, + { name: 'user.update', displayName: 'Update User', description: 'Update user information', resourceType: 'api' as const, resourceName: 'user', action: 'update' as const }, + { name: 'user.delete', displayName: 'Delete User', description: 'Delete users', resourceType: 'api' as const, resourceName: 'user', action: 'delete' as const }, + + // Project Permissions + { name: 'project.create', displayName: 'Create Project', description: 'Create new projects', resourceType: 'api' as const, resourceName: 'project', action: 'create' as const }, + { name: 'project.read', displayName: 'Read Project', description: 'View project information', resourceType: 'api' as const, resourceName: 'project', action: 'read' as const }, + { name: 'project.update', displayName: 'Update Project', description: 'Update project information', resourceType: 'api' as const, resourceName: 'project', action: 'update' as const }, + { name: 'project.delete', displayName: 'Delete Project', description: 'Delete projects', resourceType: 'api' as const, resourceName: 'project', action: 'delete' as const }, + + // Post Permissions + { name: 'post.create', displayName: 'Create Post', description: 'Create new posts', resourceType: 'api' as const, resourceName: 'post', action: 'create' as const }, + { name: 'post.read', displayName: 'Read Post', description: 'View posts', resourceType: 'api' as const, resourceName: 'post', action: 'read' as const }, + { name: 'post.update', displayName: 'Update Post', description: 'Update posts', resourceType: 'api' as const, resourceName: 'post', action: 'update' as const }, + { name: 'post.delete', displayName: 'Delete Post', description: 'Delete posts', resourceType: 'api' as const, resourceName: 'post', action: 'delete' as const }, + + // Task Permissions + { name: 'task.create', displayName: 'Create Task', description: 'Create new tasks', resourceType: 'api' as const, resourceName: 'task', action: 'create' as const }, + { name: 'task.read', displayName: 'Read Task', description: 'View tasks', resourceType: 'api' as const, resourceName: 'task', action: 'read' as const }, + { name: 'task.update', displayName: 'Update Task', description: 'Update tasks', resourceType: 'api' as const, resourceName: 'task', action: 'update' as const }, + { name: 'task.delete', displayName: 'Delete Task', description: 'Delete tasks', resourceType: 'api' as const, resourceName: 'task', action: 'delete' as const }, + + // RBAC Permissions + { name: 'rbac.role.create', displayName: 'Create Role', description: 'Create new roles', resourceType: 'api' as const, resourceName: 'role', action: 'create' as const }, + { name: 'rbac.role.read', displayName: 'Read Role', description: 'View roles', resourceType: 'api' as const, resourceName: 'role', action: 'read' as const }, + { name: 'rbac.role.update', displayName: 'Update Role', description: 'Update roles', resourceType: 'api' as const, resourceName: 'role', action: 'update' as const }, + { name: 'rbac.role.delete', displayName: 'Delete Role', description: 'Delete roles', resourceType: 'api' as const, resourceName: 'role', action: 'delete' as const }, + + { name: 'rbac.permission.create', displayName: 'Create Permission', description: 'Create new permissions', resourceType: 'api' as const, resourceName: 'permission', action: 'create' as const }, + { name: 'rbac.permission.read', displayName: 'Read Permission', description: 'View permissions', resourceType: 'api' as const, resourceName: 'permission', action: 'read' as const }, + { name: 'rbac.permission.update', displayName: 'Update Permission', description: 'Update permissions', resourceType: 'api' as const, resourceName: 'permission', action: 'update' as const }, + { name: 'rbac.permission.delete', displayName: 'Delete Permission', description: 'Delete permissions', resourceType: 'api' as const, resourceName: 'permission', action: 'delete' as const }, + + { name: 'rbac.user-role.assign', displayName: 'Assign User Role', description: 'Assign roles to users', resourceType: 'api' as const, resourceName: 'user-role', action: 'create' as const }, + { name: 'rbac.user-role.revoke', displayName: 'Revoke User Role', description: 'Revoke roles from users', resourceType: 'api' as const, resourceName: 'user-role', action: 'delete' as const }, + { name: 'rbac.user-role.read', displayName: 'Read User Role', description: 'View user roles', resourceType: 'api' as const, resourceName: 'user-role', action: 'read' as const }, + + { name: 'rbac.group.create', displayName: 'Create Group', description: 'Create new groups', resourceType: 'api' as const, resourceName: 'group', action: 'create' as const }, + { name: 'rbac.group.read', displayName: 'Read Group', description: 'View groups', resourceType: 'api' as const, resourceName: 'group', action: 'read' as const }, + { name: 'rbac.group.update', displayName: 'Update Group', description: 'Update groups', resourceType: 'api' as const, resourceName: 'group', action: 'update' as const }, + { name: 'rbac.group.delete', displayName: 'Delete Group', description: 'Delete groups', resourceType: 'api' as const, resourceName: 'group', action: 'delete' as const }, + + { name: 'rbac.audit.read', displayName: 'Read Audit Log', description: 'View audit logs', resourceType: 'api' as const, resourceName: 'audit', action: 'read' as const }, + + // SSH Permissions + { name: 'ssh.create', displayName: 'Create SSH Account', description: 'Create SSH accounts', resourceType: 'api' as const, resourceName: 'ssh', action: 'create' as const }, + { name: 'ssh.read', displayName: 'Read SSH Account', description: 'View SSH accounts', resourceType: 'api' as const, resourceName: 'ssh', action: 'read' as const }, + { name: 'ssh.update', displayName: 'Update SSH Account', description: 'Update SSH accounts', resourceType: 'api' as const, resourceName: 'ssh', action: 'update' as const }, + { name: 'ssh.delete', displayName: 'Delete SSH Account', description: 'Delete SSH accounts', resourceType: 'api' as const, resourceName: 'ssh', action: 'delete' as const }, + { name: 'ssh.execute', displayName: 'Execute SSH Commands', description: 'Execute commands via SSH', resourceType: 'api' as const, resourceName: 'ssh', action: 'execute' as const }, +]; + +// ===== Built-in Roles ===== + +const BUILT_IN_ROLES = [ + // System Roles + { + name: 'system_admin', + displayName: 'System Administrator', + description: 'Full system access with all permissions', + roleType: 'system' as const, + permissions: [ + // All permissions + 'user.create', 'user.read', 'user.update', 'user.delete', + 'project.create', 'project.read', 'project.update', 'project.delete', + 'post.create', 'post.read', 'post.update', 'post.delete', + 'task.create', 'task.read', 'task.update', 'task.delete', + 'rbac.role.create', 'rbac.role.read', 'rbac.role.update', 'rbac.role.delete', + 'rbac.permission.create', 'rbac.permission.read', 'rbac.permission.update', 'rbac.permission.delete', + 'rbac.user-role.assign', 'rbac.user-role.revoke', 'rbac.user-role.read', + 'rbac.group.create', 'rbac.group.read', 'rbac.group.update', 'rbac.group.delete', + 'rbac.audit.read', + 'ssh.create', 'ssh.read', 'ssh.update', 'ssh.delete', 'ssh.execute', + ], + }, + { + name: 'system_moderator', + displayName: 'System Moderator', + description: 'User and content moderation', + roleType: 'system' as const, + permissions: [ + 'user.read', 'user.update', + 'project.read', + 'post.read', 'post.update', 'post.delete', + 'rbac.group.create', 'rbac.group.read', 'rbac.group.update', + 'rbac.audit.read', + ], + }, + { + name: 'system_auditor', + displayName: 'System Auditor', + description: 'Read-only access for compliance', + roleType: 'system' as const, + permissions: [ + 'user.read', + 'project.read', + 'post.read', + 'task.read', + 'rbac.role.read', + 'rbac.permission.read', + 'rbac.user-role.read', + 'rbac.group.read', + 'rbac.audit.read', + 'ssh.read', + ], + }, + + // Project Roles + { + name: 'project_owner', + displayName: 'Project Owner', + description: 'Full project control', + roleType: 'project' as const, + permissions: [ + 'project.read', 'project.update', 'project.delete', + 'post.create', 'post.read', 'post.update', 'post.delete', + 'task.create', 'task.read', 'task.update', 'task.delete', + 'rbac.user-role.assign', 'rbac.user-role.revoke', 'rbac.user-role.read', + 'ssh.create', 'ssh.read', 'ssh.update', 'ssh.delete', 'ssh.execute', + ], + }, + { + name: 'project_admin', + displayName: 'Project Admin', + description: 'Administrative project access', + roleType: 'project' as const, + permissions: [ + 'project.read', 'project.update', + 'post.create', 'post.read', 'post.update', 'post.delete', + 'task.create', 'task.read', 'task.update', 'task.delete', + 'rbac.user-role.read', + 'ssh.read', 'ssh.execute', + ], + }, + { + name: 'project_member', + displayName: 'Project Member', + description: 'Regular project member', + roleType: 'project' as const, + permissions: [ + 'project.read', + 'post.create', 'post.read', 'post.update', + 'task.create', 'task.read', 'task.update', + 'ssh.read', 'ssh.execute', + ], + }, + { + name: 'project_viewer', + displayName: 'Project Viewer', + description: 'Read-only project access', + roleType: 'project' as const, + permissions: [ + 'project.read', + 'post.read', + 'task.read', + ], + }, +]; + +// ===== Seed Function ===== + +async function seedRBAC() { + console.log('🌱 Starting RBAC seed...\n'); + + try { + // 1. Create permissions + console.log('Creating permissions...'); + const createdPermissions: Record = {}; + + for (const perm of BUILT_IN_PERMISSIONS) { + try { + // Check if permission already exists + const existing = await getPermissionByName(perm.name); + if (existing) { + console.log(` ✓ Permission "${perm.name}" already exists`); + createdPermissions[perm.name] = existing.id; + continue; + } + + const created = await createPermission(perm); + createdPermissions[perm.name] = created.id; + console.log(` ✓ Created permission: ${perm.name}`); + } catch (error: any) { + console.error(` ✗ Failed to create permission ${perm.name}:`, error.message); + } + } + + console.log(`\n✓ Created ${Object.keys(createdPermissions).length} permissions\n`); + + // 2. Create roles + console.log('Creating roles...'); + const createdRoles: Record = {}; + + for (const role of BUILT_IN_ROLES) { + try { + // Check if role already exists + const existing = await getRoleByName(role.name); + if (existing) { + console.log(` ✓ Role "${role.name}" already exists`); + createdRoles[role.name] = existing.id; + continue; + } + + const created = await createRole({ + name: role.name, + displayName: role.displayName, + description: role.description, + roleType: role.roleType, + isBuiltIn: true, + }); + + createdRoles[role.name] = created.id; + console.log(` ✓ Created role: ${role.name}`); + } catch (error: any) { + console.error(` ✗ Failed to create role ${role.name}:`, error.message); + } + } + + console.log(`\n✓ Created ${Object.keys(createdRoles).length} roles\n`); + + // 3. Assign permissions to roles + console.log('Assigning permissions to roles...'); + + for (const role of BUILT_IN_ROLES) { + try { + const roleId = createdRoles[role.name]; + if (!roleId) continue; + + const permissionIds = role.permissions + .map(permName => createdPermissions[permName]) + .filter(id => id !== undefined); + + if (permissionIds.length === 0) { + console.log(` ⚠ No permissions to assign to role: ${role.name}`); + continue; + } + + await assignPermissionsToRole(roleId, permissionIds); + console.log(` ✓ Assigned ${permissionIds.length} permissions to role: ${role.name}`); + } catch (error: any) { + console.error(` ✗ Failed to assign permissions to role ${role.name}:`, error.message); + } + } + + // 4. Create super admin group + console.log('\nCreating super admin group...'); + + try { + // Check if super admin group already exists + const [existingGroup] = await db + .select() + .from(groups) + .where(eq(groups.name, SUPER_ADMIN_GROUP_NAME)) + .limit(1); + + if (existingGroup) { + console.log(` ✓ Super admin group "${SUPER_ADMIN_GROUP_NAME}" already exists`); + } else { + const superAdminGroup = await createGroup({ + name: SUPER_ADMIN_GROUP_NAME, + description: 'Special group for super administrators with impersonation capabilities', + groupType: 'functional', + metadata: { + isSpecial: true, + capabilities: ['impersonation', 'full-access'], + }, + }); + + console.log(` ✓ Created super admin group: ${SUPER_ADMIN_GROUP_NAME} (ID: ${superAdminGroup.id})`); + } + } catch (error: any) { + console.error(` ✗ Failed to create super admin group:`, error.message); + } + + console.log('\n✅ RBAC seed completed successfully!\n'); + console.log('Summary:'); + console.log(` - ${Object.keys(createdPermissions).length} permissions`); + console.log(` - ${Object.keys(createdRoles).length} roles`); + console.log(` - 1 special group (${SUPER_ADMIN_GROUP_NAME})`); + console.log('\nNext steps:'); + console.log(' 1. Assign system_admin role to your admin user'); + console.log(' 2. Add admin users to the super_admins group for impersonation capability'); + console.log(' 3. Assign project roles to project members'); + console.log(' 4. Create custom roles and permissions as needed\n'); + + } catch (error) { + console.error('❌ RBAC seed failed:', error); + process.exit(1); + } +} + +// Run seed +seedRBAC().then(() => { + console.log('Done!'); + process.exit(0); +}).catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/server/routers/_app.ts b/server/routers/_app.ts index 5b5bd60..b39d083 100644 --- a/server/routers/_app.ts +++ b/server/routers/_app.ts @@ -6,6 +6,7 @@ import { projectRouter } from './project'; import { sshConfigRouter } from './ssh-config'; import { sshRouter } from './ssh'; import { temporalRouter } from './temporal'; +import { rbacRouter } from './rbac'; export const appRouter = router({ user: userRouter, @@ -15,6 +16,7 @@ export const appRouter = router({ sshConfig: sshConfigRouter, ssh: sshRouter, temporal: temporalRouter, + rbac: rbacRouter, }); export type AppRouter = typeof appRouter; diff --git a/server/routers/rbac.ts b/server/routers/rbac.ts new file mode 100644 index 0000000..0e3023a --- /dev/null +++ b/server/routers/rbac.ts @@ -0,0 +1,473 @@ +/** + * RBAC Management Router + * + * Provides API endpoints for managing roles, permissions, user roles, and groups. + */ + +import { router, protectedProcedure, permissionProcedure } from '@/lib/trpc/trpc'; +import { z } from 'zod'; +import { + // Role Service + createRole, + listRoles, + getRoleById, + updateRole, + setRoleActive, + deleteRole, + assignPermissionsToRole, + removePermissionsFromRole, + getRolePermissions as getRolePermissionsList, + // Permission Service + createPermission, + listPermissions, + getPermissionById, + updatePermission, + setPermissionActive, + deletePermission, + // User Role Service + assignRole, + revokeRole, + getUserPermissions, + getUsersByRole, + // Group Service + createGroup, + listGroups, + getGroupById, + updateGroup, + deleteGroup, + addUserToGroup, + removeUserFromGroup, + getGroupMembers, + addProjectToGroup, + removeProjectFromGroup, + getGroupProjects, + getUserGroups, + // Impersonation Service + isSuperAdmin, + startImpersonation, + endImpersonation, + getAdminActiveSessions, + getImpersonationHistory, + canImpersonate, +} from '@/lib/rbac'; + +// ===== Input Schemas ===== + +const createRoleSchema = z.object({ + name: z.string().min(1).max(100), + displayName: z.string().min(1).max(255), + description: z.string().optional(), + roleType: z.enum(['system', 'project', 'cross-project']), + isBuiltIn: z.boolean().optional(), + metadata: z.record(z.any()).optional(), +}); + +const updateRoleSchema = z.object({ + roleId: z.number(), + displayName: z.string().min(1).max(255).optional(), + description: z.string().optional(), + metadata: z.record(z.any()).optional(), +}); + +const createPermissionSchema = z.object({ + name: z.string().min(1).max(100), + displayName: z.string().min(1).max(255), + description: z.string().optional(), + resourceType: z.enum(['api', 'ui', 'data']), + resourceName: z.string().min(1).max(255), + action: z.enum(['create', 'read', 'update', 'delete', 'execute']), + metadata: z.record(z.any()).optional(), +}); + +const updatePermissionSchema = z.object({ + permissionId: z.number(), + displayName: z.string().min(1).max(255).optional(), + description: z.string().optional(), + metadata: z.record(z.any()).optional(), +}); + +const assignRoleSchema = z.object({ + userId: z.number(), + roleId: z.number(), + projectId: z.number().optional(), + groupId: z.number().optional(), + expiresAt: z.date().optional(), +}); + +const revokeRoleSchema = z.object({ + userId: z.number(), + roleId: z.number(), + projectId: z.number().optional(), + groupId: z.number().optional(), +}); + +const createGroupSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + groupType: z.enum(['project', 'cross-project', 'functional']), + metadata: z.record(z.any()).optional(), +}); + +const updateGroupSchema = z.object({ + groupId: z.number(), + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), + metadata: z.record(z.any()).optional(), +}); + +// ===== Router Definition ===== + +export const rbacRouter = router({ + // ===== Roles ===== + roles: router({ + list: permissionProcedure('rbac.role.read') + .input(z.object({ + roleType: z.string().optional(), + isActive: z.boolean().optional(), + includeBuiltIn: z.boolean().optional(), + })) + .query(async ({ input }) => { + return await listRoles(input); + }), + + getById: permissionProcedure('rbac.role.read') + .input(z.object({ roleId: z.number() })) + .query(async ({ input }) => { + return await getRoleById(input.roleId); + }), + + create: permissionProcedure('rbac.role.create') + .input(createRoleSchema) + .mutation(async ({ ctx, input }) => { + return await createRole(input, ctx.user.userId); + }), + + update: permissionProcedure('rbac.role.update') + .input(updateRoleSchema) + .mutation(async ({ ctx, input }) => { + const { roleId, ...updates } = input; + return await updateRole(roleId, updates, ctx.user.userId); + }), + + setActive: permissionProcedure('rbac.role.update') + .input(z.object({ roleId: z.number(), isActive: z.boolean() })) + .mutation(async ({ ctx, input }) => { + return await setRoleActive(input.roleId, input.isActive, ctx.user.userId); + }), + + delete: permissionProcedure('rbac.role.delete') + .input(z.object({ roleId: z.number() })) + .mutation(async ({ ctx, input }) => { + return await deleteRole(input.roleId, ctx.user.userId); + }), + + assignPermissions: permissionProcedure('rbac.role.update') + .input(z.object({ + roleId: z.number(), + permissionIds: z.array(z.number()), + })) + .mutation(async ({ ctx, input }) => { + await assignPermissionsToRole(input.roleId, input.permissionIds, ctx.user.userId); + return { success: true }; + }), + + removePermissions: permissionProcedure('rbac.role.update') + .input(z.object({ + roleId: z.number(), + permissionIds: z.array(z.number()), + })) + .mutation(async ({ ctx, input }) => { + await removePermissionsFromRole(input.roleId, input.permissionIds, ctx.user.userId); + return { success: true }; + }), + + getPermissions: permissionProcedure('rbac.role.read') + .input(z.object({ roleId: z.number() })) + .query(async ({ input }) => { + return await getRolePermissionsList(input.roleId); + }), + }), + + // ===== Permissions ===== + permissions: router({ + list: permissionProcedure('rbac.permission.read') + .input(z.object({ + resourceType: z.string().optional(), + resourceName: z.string().optional(), + action: z.string().optional(), + isActive: z.boolean().optional(), + })) + .query(async ({ input }) => { + return await listPermissions(input); + }), + + getById: permissionProcedure('rbac.permission.read') + .input(z.object({ permissionId: z.number() })) + .query(async ({ input }) => { + return await getPermissionById(input.permissionId); + }), + + create: permissionProcedure('rbac.permission.create') + .input(createPermissionSchema) + .mutation(async ({ ctx, input }) => { + return await createPermission(input, ctx.user.userId); + }), + + update: permissionProcedure('rbac.permission.update') + .input(updatePermissionSchema) + .mutation(async ({ ctx, input }) => { + const { permissionId, ...updates } = input; + return await updatePermission(permissionId, updates, ctx.user.userId); + }), + + setActive: permissionProcedure('rbac.permission.update') + .input(z.object({ permissionId: z.number(), isActive: z.boolean() })) + .mutation(async ({ ctx, input }) => { + return await setPermissionActive(input.permissionId, input.isActive, ctx.user.userId); + }), + + delete: permissionProcedure('rbac.permission.delete') + .input(z.object({ permissionId: z.number() })) + .mutation(async ({ ctx, input }) => { + return await deletePermission(input.permissionId, ctx.user.userId); + }), + }), + + // ===== User Roles ===== + userRoles: router({ + assign: permissionProcedure('rbac.user-role.assign') + .input(assignRoleSchema) + .mutation(async ({ ctx, input }) => { + await assignRole({ + ...input, + grantedBy: ctx.user.userId, + }); + return { success: true }; + }), + + revoke: permissionProcedure('rbac.user-role.revoke') + .input(revokeRoleSchema) + .mutation(async ({ ctx, input }) => { + await revokeRole(input.userId, input.roleId, { + projectId: input.projectId, + groupId: input.groupId, + revokedBy: ctx.user.userId, + }); + return { success: true }; + }), + + getUserPermissions: permissionProcedure('rbac.user-role.read') + .input(z.object({ + userId: z.number(), + projectId: z.number().optional(), + })) + .query(async ({ input }) => { + return await getUserPermissions(input.userId, input.projectId); + }), + + getUsersByRole: permissionProcedure('rbac.user-role.read') + .input(z.object({ + roleId: z.number(), + projectId: z.number().optional(), + groupId: z.number().optional(), + })) + .query(async ({ input }) => { + return await getUsersByRole(input.roleId, { + projectId: input.projectId, + groupId: input.groupId, + }); + }), + + // Get current user's permissions (no special permission required) + getMyPermissions: protectedProcedure + .input(z.object({ projectId: z.number().optional() })) + .query(async ({ ctx, input }) => { + return await getUserPermissions(ctx.user.userId, input.projectId); + }), + }), + + // ===== Groups ===== + groups: router({ + list: permissionProcedure('rbac.group.read') + .input(z.object({ + groupType: z.string().optional(), + })) + .query(async ({ input }) => { + return await listGroups(input); + }), + + getById: permissionProcedure('rbac.group.read') + .input(z.object({ groupId: z.number() })) + .query(async ({ input }) => { + return await getGroupById(input.groupId); + }), + + create: permissionProcedure('rbac.group.create') + .input(createGroupSchema) + .mutation(async ({ ctx, input }) => { + return await createGroup(input, ctx.user.userId); + }), + + update: permissionProcedure('rbac.group.update') + .input(updateGroupSchema) + .mutation(async ({ ctx, input }) => { + const { groupId, ...updates } = input; + return await updateGroup(groupId, updates, ctx.user.userId); + }), + + delete: permissionProcedure('rbac.group.delete') + .input(z.object({ groupId: z.number() })) + .mutation(async ({ ctx, input }) => { + return await deleteGroup(input.groupId, ctx.user.userId); + }), + + addMember: permissionProcedure('rbac.group.update') + .input(z.object({ + groupId: z.number(), + userId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + await addUserToGroup(input.groupId, input.userId, ctx.user.userId); + return { success: true }; + }), + + removeMember: permissionProcedure('rbac.group.update') + .input(z.object({ + groupId: z.number(), + userId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + await removeUserFromGroup(input.groupId, input.userId, ctx.user.userId); + return { success: true }; + }), + + getMembers: permissionProcedure('rbac.group.read') + .input(z.object({ groupId: z.number() })) + .query(async ({ input }) => { + return await getGroupMembers(input.groupId); + }), + + addProject: permissionProcedure('rbac.group.update') + .input(z.object({ + groupId: z.number(), + projectId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + await addProjectToGroup(input.groupId, input.projectId, ctx.user.userId); + return { success: true }; + }), + + removeProject: permissionProcedure('rbac.group.update') + .input(z.object({ + groupId: z.number(), + projectId: z.number(), + })) + .mutation(async ({ ctx, input }) => { + await removeProjectFromGroup(input.groupId, input.projectId, ctx.user.userId); + return { success: true }; + }), + + getProjects: permissionProcedure('rbac.group.read') + .input(z.object({ groupId: z.number() })) + .query(async ({ input }) => { + return await getGroupProjects(input.groupId); + }), + + getUserGroups: permissionProcedure('rbac.group.read') + .input(z.object({ userId: z.number() })) + .query(async ({ input }) => { + return await getUserGroups(input.userId); + }), + + // Get current user's groups (no special permission required) + getMyGroups: protectedProcedure + .query(async ({ ctx }) => { + return await getUserGroups(ctx.user.userId); + }), + }), + + // ===== Impersonation ===== + impersonation: router({ + // Check if current user is a super admin + isSuperAdmin: protectedProcedure + .query(async ({ ctx }) => { + return await isSuperAdmin(ctx.user.userId); + }), + + // Check if current user can impersonate a target user + canImpersonate: protectedProcedure + .input(z.object({ targetUserId: z.number() })) + .query(async ({ ctx, input }) => { + return await canImpersonate(ctx.user.userId, input.targetUserId); + }), + + // Start impersonation session (super admin only) + start: protectedProcedure + .input(z.object({ + targetUserId: z.number(), + reason: z.string().optional(), + durationMs: z.number().optional(), + })) + .mutation(async ({ ctx, input }) => { + // Check if user is super admin + const isAdmin = await isSuperAdmin(ctx.user.userId); + if (!isAdmin) { + throw new Error('Only super admins can start impersonation sessions'); + } + + // Extract request metadata + const ipAddress = undefined; // Would need to be extracted from request + const userAgent = undefined; // Would need to be extracted from request + + const session = await startImpersonation({ + adminUserId: ctx.user.userId, + impersonatedUserId: input.targetUserId, + reason: input.reason, + ipAddress, + userAgent, + durationMs: input.durationMs, + }); + + if (!session) { + throw new Error('Failed to start impersonation session'); + } + + return { + sessionToken: session.sessionToken, + expiresAt: session.expiresAt, + impersonatedUserId: session.impersonatedUserId, + }; + }), + + // End impersonation session + end: protectedProcedure + .input(z.object({ sessionToken: z.string() })) + .mutation(async ({ input }) => { + const success = await endImpersonation(input.sessionToken); + return { success }; + }), + + // Get active sessions for current admin + getActiveSessions: protectedProcedure + .query(async ({ ctx }) => { + return await getAdminActiveSessions(ctx.user.userId); + }), + + // Get impersonation history + getHistory: protectedProcedure + .input(z.object({ + adminUserId: z.number().optional(), + impersonatedUserId: z.number().optional(), + limit: z.number().optional(), + })) + .query(async ({ input }) => { + return await getImpersonationHistory(input); + }), + + // Get current impersonation status + getStatus: protectedProcedure + .query(async ({ ctx }) => { + return ctx.impersonation; + }), + }), +});