feat: granular RBAC with resource/operation bindings, users, groups
- 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>
This commit is contained in:
424
src/mcpd/tests/auth-bootstrap.test.ts
Normal file
424
src/mcpd/tests/auth-bootstrap.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user