- Replace admin role with granular roles: view, create, delete, edit, run - Two binding types: resource bindings (role+resource+optional name) and operation bindings (role:run + action like backup, logs, impersonate) - Name-scoped resource bindings for per-instance access control - Remove role from project members (all permissions via RBAC) - Add users, groups, RBAC CRUD endpoints and CLI commands - describe user/group shows all RBAC access (direct + inherited) - create rbac supports --subject, --binding, --operation flags - Backup/restore handles users, groups, RBAC definitions - mcplocal project-based MCP endpoint discovery - Full test coverage for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
import Fastify from 'fastify';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { registerAuthRoutes } from '../src/routes/auth.js';
|
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
|
import type { AuthService, LoginResult } from '../src/services/auth.service.js';
|
|
import type { UserService } from '../src/services/user.service.js';
|
|
import type { GroupService } from '../src/services/group.service.js';
|
|
import type { RbacDefinitionService } from '../src/services/rbac-definition.service.js';
|
|
import type { RbacService, RbacAction } from '../src/services/rbac.service.js';
|
|
import type { SafeUser } from '../src/repositories/user.repository.js';
|
|
import type { RbacDefinition } from '@prisma/client';
|
|
|
|
let app: FastifyInstance;
|
|
|
|
afterEach(async () => {
|
|
if (app) await app.close();
|
|
});
|
|
|
|
function makeLoginResult(overrides?: Partial<LoginResult>): LoginResult {
|
|
return {
|
|
token: 'test-token-123',
|
|
expiresAt: new Date(Date.now() + 86400_000),
|
|
user: { id: 'user-1', email: 'admin@example.com', role: 'user' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSafeUser(overrides?: Partial<SafeUser>): SafeUser {
|
|
return {
|
|
id: 'user-1',
|
|
email: 'admin@example.com',
|
|
name: null,
|
|
role: 'user',
|
|
provider: 'local',
|
|
externalId: null,
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRbacDef(overrides?: Partial<RbacDefinition>): RbacDefinition {
|
|
return {
|
|
id: 'rbac-1',
|
|
name: 'bootstrap-admin',
|
|
subjects: [{ kind: 'Group', name: 'admin' }],
|
|
roleBindings: [
|
|
{ role: 'edit', resource: '*' },
|
|
{ role: 'run', resource: '*' },
|
|
{ role: 'run', action: 'impersonate' },
|
|
{ role: 'run', action: 'logs' },
|
|
{ role: 'run', action: 'backup' },
|
|
{ role: 'run', action: 'restore' },
|
|
{ role: 'run', action: 'audit-purge' },
|
|
],
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
interface MockDeps {
|
|
authService: {
|
|
login: ReturnType<typeof vi.fn>;
|
|
logout: ReturnType<typeof vi.fn>;
|
|
findSession: ReturnType<typeof vi.fn>;
|
|
impersonate: ReturnType<typeof vi.fn>;
|
|
};
|
|
userService: {
|
|
count: ReturnType<typeof vi.fn>;
|
|
create: ReturnType<typeof vi.fn>;
|
|
list: ReturnType<typeof vi.fn>;
|
|
getById: ReturnType<typeof vi.fn>;
|
|
getByEmail: ReturnType<typeof vi.fn>;
|
|
delete: ReturnType<typeof vi.fn>;
|
|
};
|
|
groupService: {
|
|
create: ReturnType<typeof vi.fn>;
|
|
list: ReturnType<typeof vi.fn>;
|
|
getById: ReturnType<typeof vi.fn>;
|
|
getByName: ReturnType<typeof vi.fn>;
|
|
update: ReturnType<typeof vi.fn>;
|
|
delete: ReturnType<typeof vi.fn>;
|
|
};
|
|
rbacDefinitionService: {
|
|
create: ReturnType<typeof vi.fn>;
|
|
list: ReturnType<typeof vi.fn>;
|
|
getById: ReturnType<typeof vi.fn>;
|
|
getByName: ReturnType<typeof vi.fn>;
|
|
update: ReturnType<typeof vi.fn>;
|
|
delete: ReturnType<typeof vi.fn>;
|
|
};
|
|
rbacService: {
|
|
canAccess: ReturnType<typeof vi.fn>;
|
|
canRunOperation: ReturnType<typeof vi.fn>;
|
|
getPermissions: ReturnType<typeof vi.fn>;
|
|
};
|
|
}
|
|
|
|
function createMockDeps(): MockDeps {
|
|
return {
|
|
authService: {
|
|
login: vi.fn(async () => makeLoginResult()),
|
|
logout: vi.fn(async () => {}),
|
|
findSession: vi.fn(async () => null),
|
|
impersonate: vi.fn(async () => makeLoginResult({ token: 'impersonated-token' })),
|
|
},
|
|
userService: {
|
|
count: vi.fn(async () => 0),
|
|
create: vi.fn(async () => makeSafeUser()),
|
|
list: vi.fn(async () => []),
|
|
getById: vi.fn(async () => makeSafeUser()),
|
|
getByEmail: vi.fn(async () => makeSafeUser()),
|
|
delete: vi.fn(async () => {}),
|
|
},
|
|
groupService: {
|
|
create: vi.fn(async () => ({ id: 'grp-1', name: 'admin', description: 'Bootstrap admin group', members: [] })),
|
|
list: vi.fn(async () => []),
|
|
getById: vi.fn(async () => null),
|
|
getByName: vi.fn(async () => null),
|
|
update: vi.fn(async () => null),
|
|
delete: vi.fn(async () => {}),
|
|
},
|
|
rbacDefinitionService: {
|
|
create: vi.fn(async () => makeRbacDef()),
|
|
list: vi.fn(async () => []),
|
|
getById: vi.fn(async () => makeRbacDef()),
|
|
getByName: vi.fn(async () => null),
|
|
update: vi.fn(async () => makeRbacDef()),
|
|
delete: vi.fn(async () => {}),
|
|
},
|
|
rbacService: {
|
|
canAccess: vi.fn(async () => false),
|
|
canRunOperation: vi.fn(async () => false),
|
|
getPermissions: vi.fn(async () => []),
|
|
},
|
|
};
|
|
}
|
|
|
|
function createApp(deps: MockDeps): Promise<FastifyInstance> {
|
|
app = Fastify({ logger: false });
|
|
app.setErrorHandler(errorHandler);
|
|
registerAuthRoutes(app, deps as unknown as {
|
|
authService: AuthService;
|
|
userService: UserService;
|
|
groupService: GroupService;
|
|
rbacDefinitionService: RbacDefinitionService;
|
|
rbacService: RbacService;
|
|
});
|
|
return app.ready();
|
|
}
|
|
|
|
describe('Auth Bootstrap', () => {
|
|
describe('GET /api/v1/auth/status', () => {
|
|
it('returns hasUsers: false when no users exist', async () => {
|
|
const deps = createMockDeps();
|
|
deps.userService.count.mockResolvedValue(0);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(false);
|
|
});
|
|
|
|
it('returns hasUsers: true when users exist', async () => {
|
|
const deps = createMockDeps();
|
|
deps.userService.count.mockResolvedValue(1);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/auth/bootstrap', () => {
|
|
it('creates admin user, admin group, RBAC definition targeting group, and returns session token', async () => {
|
|
const deps = createMockDeps();
|
|
deps.userService.count.mockResolvedValue(0);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/bootstrap',
|
|
payload: { email: 'admin@example.com', password: 'securepass123' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
const body = res.json<LoginResult>();
|
|
expect(body.token).toBe('test-token-123');
|
|
expect(body.user.email).toBe('admin@example.com');
|
|
|
|
// Verify user was created
|
|
expect(deps.userService.create).toHaveBeenCalledWith({
|
|
email: 'admin@example.com',
|
|
password: 'securepass123',
|
|
});
|
|
|
|
// Verify admin group was created with the user as member
|
|
expect(deps.groupService.create).toHaveBeenCalledWith({
|
|
name: 'admin',
|
|
description: 'Bootstrap admin group',
|
|
members: ['admin@example.com'],
|
|
});
|
|
|
|
// Verify RBAC definition targets the Group, not the User
|
|
expect(deps.rbacDefinitionService.create).toHaveBeenCalledWith({
|
|
name: 'bootstrap-admin',
|
|
subjects: [{ kind: 'Group', name: 'admin' }],
|
|
roleBindings: [
|
|
{ role: 'edit', resource: '*' },
|
|
{ role: 'run', resource: '*' },
|
|
{ role: 'run', action: 'impersonate' },
|
|
{ role: 'run', action: 'logs' },
|
|
{ role: 'run', action: 'backup' },
|
|
{ role: 'run', action: 'restore' },
|
|
{ role: 'run', action: 'audit-purge' },
|
|
],
|
|
});
|
|
|
|
// Verify auto-login was called
|
|
expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123');
|
|
});
|
|
|
|
it('passes name when provided', async () => {
|
|
const deps = createMockDeps();
|
|
deps.userService.count.mockResolvedValue(0);
|
|
await createApp(deps);
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/bootstrap',
|
|
payload: { email: 'admin@example.com', password: 'securepass123', name: 'Admin User' },
|
|
});
|
|
|
|
expect(deps.userService.create).toHaveBeenCalledWith({
|
|
email: 'admin@example.com',
|
|
password: 'securepass123',
|
|
name: 'Admin User',
|
|
});
|
|
});
|
|
|
|
it('returns 409 when users already exist', async () => {
|
|
const deps = createMockDeps();
|
|
deps.userService.count.mockResolvedValue(1);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/bootstrap',
|
|
payload: { email: 'admin@example.com', password: 'securepass123' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json<{ error: string }>().error).toContain('Users already exist');
|
|
|
|
// Should NOT have created user, group, or RBAC
|
|
expect(deps.userService.create).not.toHaveBeenCalled();
|
|
expect(deps.groupService.create).not.toHaveBeenCalled();
|
|
expect(deps.rbacDefinitionService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('validates email and password via UserService', async () => {
|
|
const deps = createMockDeps();
|
|
deps.userService.count.mockResolvedValue(0);
|
|
// Simulate Zod validation error from UserService
|
|
deps.userService.create.mockRejectedValue(
|
|
Object.assign(new Error('Validation error'), { statusCode: 400, issues: [] }),
|
|
);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/bootstrap',
|
|
payload: { email: 'not-an-email', password: 'short' },
|
|
});
|
|
|
|
// The error handler should handle the validation error
|
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/auth/login', () => {
|
|
it('logs in successfully', async () => {
|
|
const deps = createMockDeps();
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: 'admin@example.com', password: 'securepass123' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json<LoginResult>().token).toBe('test-token-123');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/auth/logout', () => {
|
|
it('logs out with valid token', async () => {
|
|
const deps = createMockDeps();
|
|
deps.authService.findSession.mockResolvedValue({
|
|
userId: 'user-1',
|
|
expiresAt: new Date(Date.now() + 86400_000),
|
|
});
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
headers: { authorization: 'Bearer valid-token' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json<{ success: boolean }>().success).toBe(true);
|
|
expect(deps.authService.logout).toHaveBeenCalledWith('valid-token');
|
|
});
|
|
|
|
it('returns 401 without auth', async () => {
|
|
const deps = createMockDeps();
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/auth/impersonate', () => {
|
|
it('creates session for target user when caller is admin', async () => {
|
|
const deps = createMockDeps();
|
|
// Auth: valid session
|
|
deps.authService.findSession.mockResolvedValue({
|
|
userId: 'admin-user-id',
|
|
expiresAt: new Date(Date.now() + 86400_000),
|
|
});
|
|
// RBAC: allow impersonate operation
|
|
deps.rbacService.canRunOperation.mockResolvedValue(true);
|
|
// Impersonate returns token for target
|
|
deps.authService.impersonate.mockResolvedValue(
|
|
makeLoginResult({ token: 'impersonated-token', user: { id: 'user-2', email: 'target@example.com', role: 'user' } }),
|
|
);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/impersonate',
|
|
headers: { authorization: 'Bearer admin-token' },
|
|
payload: { email: 'target@example.com' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json<LoginResult>();
|
|
expect(body.token).toBe('impersonated-token');
|
|
expect(body.user.email).toBe('target@example.com');
|
|
expect(deps.authService.impersonate).toHaveBeenCalledWith('target@example.com');
|
|
});
|
|
|
|
it('returns 401 without auth', async () => {
|
|
const deps = createMockDeps();
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/impersonate',
|
|
payload: { email: 'target@example.com' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('returns 403 when caller lacks admin permission on users', async () => {
|
|
const deps = createMockDeps();
|
|
// Auth: valid session
|
|
deps.authService.findSession.mockResolvedValue({
|
|
userId: 'non-admin-id',
|
|
expiresAt: new Date(Date.now() + 86400_000),
|
|
});
|
|
// RBAC: deny
|
|
deps.rbacService.canRunOperation.mockResolvedValue(false);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/impersonate',
|
|
headers: { authorization: 'Bearer regular-token' },
|
|
payload: { email: 'target@example.com' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('returns 401 when impersonation target does not exist', async () => {
|
|
const deps = createMockDeps();
|
|
// Auth: valid session
|
|
deps.authService.findSession.mockResolvedValue({
|
|
userId: 'admin-user-id',
|
|
expiresAt: new Date(Date.now() + 86400_000),
|
|
});
|
|
// RBAC: allow
|
|
deps.rbacService.canRunOperation.mockResolvedValue(true);
|
|
// Impersonate fails — user not found
|
|
const authError = new Error('User not found');
|
|
(authError as Error & { statusCode: number }).statusCode = 401;
|
|
deps.authService.impersonate.mockRejectedValue(authError);
|
|
await createApp(deps);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/impersonate',
|
|
headers: { authorization: 'Bearer admin-token' },
|
|
payload: { email: 'nonexistent@example.com' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
});
|