Merge SERVICE role into AGENT
Every AGENT now gets an auto-generated API key on creation, shown once in a modal. AGENTs log in with password and authenticate to the API with X-Api-Key. pre-push.sql defensively migrates any residual SERVICE rows to AGENT before Prisma rewrites the enum. Goddard is no longer baked into the seed — create agents via Admin → Users. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -5,8 +5,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server/src/index.js",
|
||||
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
|
||||
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
|
||||
"start:prod": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
|
||||
"db:push": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
|
||||
"db:generate": "prisma generate",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Idempotent SQL applied BEFORE `prisma db push`.
|
||||
-- Flips any residual SERVICE-role users to AGENT before Prisma rewrites the Role enum.
|
||||
-- Safe no-op on fresh databases or databases already migrated past the SERVICE role.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_enum e ON e.enumtypid = t.oid
|
||||
WHERE t.typname = 'Role' AND e.enumlabel = 'SERVICE'
|
||||
) THEN
|
||||
EXECUTE 'UPDATE "User" SET "role" = ''AGENT'' WHERE "role"::text = ''SERVICE''';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -11,7 +11,6 @@ enum Role {
|
||||
ADMIN
|
||||
AGENT
|
||||
USER
|
||||
SERVICE
|
||||
}
|
||||
|
||||
enum TicketStatus {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -20,25 +19,6 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Goddard — n8n service account
|
||||
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'goddard' },
|
||||
update: {},
|
||||
create: {
|
||||
username: 'goddard',
|
||||
email: 'goddard@internal',
|
||||
displayName: 'Goddard',
|
||||
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
|
||||
role: 'SERVICE',
|
||||
apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } });
|
||||
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`);
|
||||
console.log('(This key is only displayed once on first seed — copy it now)\n');
|
||||
|
||||
// Sample CTI structure
|
||||
const theWrightServer = await prisma.category.upsert({
|
||||
where: { name: 'TheWrightServer' },
|
||||
|
||||
@@ -15,7 +15,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
||||
|
||||
if (apiKey) {
|
||||
const user = await prisma.user.findUnique({ where: { apiKey } });
|
||||
if (!user || user.role !== 'SERVICE') {
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
req.user = { id: user.id, role: user.role, username: user.username };
|
||||
@@ -48,7 +48,7 @@ export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction
|
||||
next();
|
||||
};
|
||||
|
||||
// Blocks USER role — allows ADMIN, AGENT, SERVICE
|
||||
// Blocks USER role — allows ADMIN and AGENT
|
||||
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (req.user?.role === 'USER') {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
|
||||
@@ -51,24 +51,4 @@ describe('authService.login', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects SERVICE role from password login', async () => {
|
||||
const password = 'svc-pw';
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'svc',
|
||||
username: 'goddard',
|
||||
email: 'g@x.io',
|
||||
displayName: 'Goddard',
|
||||
passwordHash: await bcrypt.hash(password, 4),
|
||||
role: 'SERVICE',
|
||||
apiKey: 'sk_xyz',
|
||||
notificationPrefs: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(login({ username: 'goddard', password })).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: expect.stringMatching(/API key/i),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,6 @@ export async function login({ username, password }: LoginInput) {
|
||||
throw new HttpError(401, 'Invalid credentials');
|
||||
}
|
||||
|
||||
if (user.role === 'SERVICE') {
|
||||
throw new HttpError(401, 'Service accounts must authenticate via API key');
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, role: user.role, username: user.username },
|
||||
process.env.JWT_SECRET!,
|
||||
|
||||
@@ -17,15 +17,15 @@ const stubUser = {
|
||||
};
|
||||
|
||||
describe('userService.createUser', () => {
|
||||
it('hashes the password and omits apiKey for non-SERVICE roles', async () => {
|
||||
prismaMock.user.create.mockResolvedValue(stubUser);
|
||||
it('hashes the password and omits apiKey for ADMIN and USER roles', async () => {
|
||||
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'USER' });
|
||||
|
||||
await createUser({
|
||||
username: 'bob',
|
||||
email: 'b@x.io',
|
||||
displayName: 'Bob',
|
||||
password: 'hunter2!',
|
||||
role: 'AGENT',
|
||||
role: 'USER',
|
||||
});
|
||||
|
||||
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
@@ -34,14 +34,15 @@ describe('userService.createUser', () => {
|
||||
expect(call.data.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('assigns an apiKey for SERVICE role', async () => {
|
||||
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' });
|
||||
it('assigns an apiKey for AGENT role', async () => {
|
||||
prismaMock.user.create.mockResolvedValue(stubUser);
|
||||
|
||||
await createUser({
|
||||
username: 'svc',
|
||||
email: 's@x.io',
|
||||
displayName: 'Svc',
|
||||
role: 'SERVICE',
|
||||
username: 'agent',
|
||||
email: 'a@x.io',
|
||||
displayName: 'Agent',
|
||||
password: 'hunter2!',
|
||||
role: 'AGENT',
|
||||
});
|
||||
|
||||
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
|
||||
@@ -41,12 +41,10 @@ export async function getCurrentUser(id: string) {
|
||||
}
|
||||
|
||||
export async function createUser(data: CreateUserInput) {
|
||||
const passwordHash = data.password
|
||||
? await bcrypt.hash(data.password, 12)
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
|
||||
const apiKey =
|
||||
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
|
||||
data.role === 'AGENT' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
@@ -68,8 +66,13 @@ export async function updateUser(id: string, data: UpdateUserInput) {
|
||||
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
|
||||
if (data.role) {
|
||||
update.role = data.role;
|
||||
if (data.role === 'SERVICE' && !update.apiKey) {
|
||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
if (data.role === 'AGENT') {
|
||||
const existing = await prisma.user.findUnique({ where: { id }, select: { apiKey: true } });
|
||||
if (!existing?.apiKey) {
|
||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
} else {
|
||||
update.apiKey = null;
|
||||
}
|
||||
}
|
||||
if (data.regenerateApiKey) {
|
||||
|
||||
Reference in New Issue
Block a user