- 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>
95 lines
2.4 KiB
TypeScript
95 lines
2.4 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import type { PrismaClient } from '@prisma/client';
|
|
import bcrypt from 'bcrypt';
|
|
|
|
/** 30 days in milliseconds */
|
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
export interface LoginResult {
|
|
token: string;
|
|
expiresAt: Date;
|
|
user: { id: string; email: string; role: string };
|
|
}
|
|
|
|
export class AuthenticationError extends Error {
|
|
readonly statusCode = 401;
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'AuthenticationError';
|
|
}
|
|
}
|
|
|
|
export class AuthService {
|
|
constructor(private readonly prisma: PrismaClient) {}
|
|
|
|
async login(email: string, password: string): Promise<LoginResult> {
|
|
const user = await this.prisma.user.findUnique({ where: { email } });
|
|
if (user === null) {
|
|
throw new AuthenticationError('Invalid email or password');
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
if (!valid) {
|
|
throw new AuthenticationError('Invalid email or password');
|
|
}
|
|
|
|
const token = randomUUID();
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
|
|
await this.prisma.session.create({
|
|
data: {
|
|
token,
|
|
userId: user.id,
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
return {
|
|
token,
|
|
expiresAt,
|
|
user: { id: user.id, email: user.email, role: user.role },
|
|
};
|
|
}
|
|
|
|
async logout(token: string): Promise<void> {
|
|
// Delete the session by token; ignore if already deleted
|
|
await this.prisma.session.deleteMany({ where: { token } });
|
|
}
|
|
|
|
async findSession(token: string): Promise<{ userId: string; expiresAt: Date } | null> {
|
|
const session = await this.prisma.session.findUnique({ where: { token } });
|
|
if (session === null) {
|
|
return null;
|
|
}
|
|
return { userId: session.userId, expiresAt: session.expiresAt };
|
|
}
|
|
|
|
/**
|
|
* Create a session for a user by email without requiring their password.
|
|
* Used for admin impersonation.
|
|
*/
|
|
async impersonate(email: string): Promise<LoginResult> {
|
|
const user = await this.prisma.user.findUnique({ where: { email } });
|
|
if (user === null) {
|
|
throw new AuthenticationError('User not found');
|
|
}
|
|
|
|
const token = randomUUID();
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
|
|
await this.prisma.session.create({
|
|
data: {
|
|
token,
|
|
userId: user.id,
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
return {
|
|
token,
|
|
expiresAt,
|
|
user: { id: user.id, email: user.email, role: user.role },
|
|
};
|
|
}
|
|
}
|