123
This commit is contained in:
55
backend/src/app.ts
Normal file
55
backend/src/app.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { env } from "./config/env.js";
|
||||
import { sessionStore } from "./db/session-store.js";
|
||||
import { attachRequestContext } from "./middleware/request-context.js";
|
||||
import { errorHandler } from "./middleware/error-handler.js";
|
||||
import routes from "./routes/index.js";
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.ALLOWED_ORIGIN,
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(
|
||||
session({
|
||||
store: sessionStore,
|
||||
secret: env.SESSION_SECRET,
|
||||
name: env.SESSION_NAME,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.COOKIE_SECURE || env.NODE_ENV === "production",
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
app.use(attachRequestContext);
|
||||
app.get("/health", (_request, response) => {
|
||||
response.json({ status: "ok" });
|
||||
});
|
||||
app.use("/api", routes);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
20
backend/src/config/env.ts
Normal file
20
backend/src/config/env.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().default(4000),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
SESSION_SECRET: z.string().min(12),
|
||||
SESSION_NAME: z.string().default("pgcc.sid"),
|
||||
ALLOWED_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
POSTGRES_CONTAINER_NAME: z.string().default("pg-control-postgres"),
|
||||
COOKIE_SECURE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === "true")
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
16
backend/src/constants/permissions.ts
Normal file
16
backend/src/constants/permissions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const SYSTEM_ROLE_CODES = {
|
||||
ROOT: "root",
|
||||
GROUP_ADMIN: "group_admin",
|
||||
EDITOR: "editor",
|
||||
VIEWER: "viewer"
|
||||
} as const;
|
||||
|
||||
export const AUDIT_ACTIONS = {
|
||||
LOGIN_SUCCESS: "login_success",
|
||||
LOGIN_FAILURE: "login_failure",
|
||||
SQL_EXECUTE: "sql_execute",
|
||||
DATA_MUTATION: "data_mutation",
|
||||
SCHEMA_CHANGE: "schema_change",
|
||||
USER_MANAGEMENT: "user_management",
|
||||
ROLE_MANAGEMENT: "role_management"
|
||||
} as const;
|
||||
26
backend/src/controllers/admin.controller.ts
Normal file
26
backend/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { AuditService } from "../services/audit.service.js";
|
||||
import { LogsService } from "../services/logs.service.js";
|
||||
import { RbacService } from "../services/rbac.service.js";
|
||||
|
||||
const rbacService = new RbacService();
|
||||
const auditService = new AuditService();
|
||||
const logsService = new LogsService();
|
||||
|
||||
export class AdminController {
|
||||
async users(_request: Request, response: Response) {
|
||||
response.json({ users: await rbacService.listUsers() });
|
||||
}
|
||||
|
||||
async roles(_request: Request, response: Response) {
|
||||
response.json({ roles: await rbacService.listRoles() });
|
||||
}
|
||||
|
||||
async audit(request: Request, response: Response) {
|
||||
response.json({ logs: await auditService.list(request.query.search?.toString()) });
|
||||
}
|
||||
|
||||
async postgresLogs(request: Request, response: Response) {
|
||||
response.json({ logs: await logsService.readLogs(request.query.search?.toString()) });
|
||||
}
|
||||
}
|
||||
22
backend/src/controllers/auth.controller.ts
Normal file
22
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthService } from "../services/auth.service.js";
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export class AuthController {
|
||||
async login(request: Request, response: Response) {
|
||||
const user = await authService.login(request.body.username, request.body.password);
|
||||
request.session.user = user;
|
||||
response.json({ user });
|
||||
}
|
||||
|
||||
async logout(request: Request, response: Response) {
|
||||
request.session.destroy(() => {
|
||||
response.status(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
async me(request: Request, response: Response) {
|
||||
response.json({ user: request.session.user ?? null });
|
||||
}
|
||||
}
|
||||
78
backend/src/controllers/metadata.controller.ts
Normal file
78
backend/src/controllers/metadata.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { MetadataService } from "../services/metadata.service.js";
|
||||
|
||||
const service = new MetadataService();
|
||||
|
||||
export class MetadataController {
|
||||
async listTables(_request: Request, response: Response) {
|
||||
response.json({ tables: await service.listTables() });
|
||||
}
|
||||
|
||||
async getTableDetails(request: Request, response: Response) {
|
||||
response.json(await service.getTableDetails(request.params.tableName));
|
||||
}
|
||||
|
||||
async listRows(request: Request, response: Response) {
|
||||
response.json(
|
||||
await service.listRows({
|
||||
userId: request.session.user!.id,
|
||||
tableName: request.params.tableName,
|
||||
page: Number(request.query.page ?? 1),
|
||||
pageSize: Number(request.query.pageSize ?? 25),
|
||||
search: request.query.search?.toString(),
|
||||
sortBy: request.query.sortBy?.toString(),
|
||||
sortDirection: request.query.sortDirection === "desc" ? "desc" : "asc"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async createTable(request: Request, response: Response) {
|
||||
await service.createTable(request.session.user!.id, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async deleteTable(request: Request, response: Response) {
|
||||
await service.deleteTable(request.session.user!.id, request.params.tableName);
|
||||
response.status(204).send();
|
||||
}
|
||||
|
||||
async addColumn(request: Request, response: Response) {
|
||||
await service.addColumn(request.session.user!.id, request.params.tableName, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async alterColumn(request: Request, response: Response) {
|
||||
await service.alterColumnType(
|
||||
request.session.user!.id,
|
||||
request.params.tableName,
|
||||
request.params.columnName,
|
||||
request.body.dataType
|
||||
);
|
||||
response.json({ success: true });
|
||||
}
|
||||
|
||||
async dropColumn(request: Request, response: Response) {
|
||||
await service.dropColumn(request.session.user!.id, request.params.tableName, request.params.columnName);
|
||||
response.status(204).send();
|
||||
}
|
||||
|
||||
async createIndex(request: Request, response: Response) {
|
||||
await service.createIndex(request.session.user!.id, request.params.tableName, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async createRow(request: Request, response: Response) {
|
||||
await service.createRow(request.session.user!.id, request.params.tableName, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async updateRow(request: Request, response: Response) {
|
||||
await service.updateRow(request.session.user!.id, request.params.tableName, request.params.id, request.body);
|
||||
response.json({ success: true });
|
||||
}
|
||||
|
||||
async deleteRow(request: Request, response: Response) {
|
||||
await service.deleteRow(request.session.user!.id, request.params.tableName, request.params.id);
|
||||
response.status(204).send();
|
||||
}
|
||||
}
|
||||
10
backend/src/controllers/sql.controller.ts
Normal file
10
backend/src/controllers/sql.controller.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { SqlConsoleService } from "../services/sql-console.service.js";
|
||||
|
||||
const service = new SqlConsoleService();
|
||||
|
||||
export class SqlController {
|
||||
async execute(request: Request, response: Response) {
|
||||
response.json(await service.execute(request.session.user!.id, request.body.sql));
|
||||
}
|
||||
}
|
||||
8
backend/src/db/pool.ts
Normal file
8
backend/src/db/pool.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import pg from "pg";
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: env.DATABASE_URL
|
||||
});
|
||||
11
backend/src/db/session-store.ts
Normal file
11
backend/src/db/session-store.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import session from "express-session";
|
||||
import connectPgSimple from "connect-pg-simple";
|
||||
import { pool } from "./pool.js";
|
||||
|
||||
const PgStore = connectPgSimple(session);
|
||||
|
||||
export const sessionStore = new PgStore({
|
||||
pool,
|
||||
tableName: "user_sessions",
|
||||
createTableIfMissing: true
|
||||
});
|
||||
17
backend/src/middleware/admin.ts
Normal file
17
backend/src/middleware/admin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { RbacService } from "../services/rbac.service.js";
|
||||
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
|
||||
|
||||
const rbacService = new RbacService();
|
||||
|
||||
export async function requireAdmin(request: Request, _response: Response, next: NextFunction) {
|
||||
try {
|
||||
await rbacService.assertAnyRole(request.session.user!.id, [
|
||||
SYSTEM_ROLE_CODES.ROOT,
|
||||
SYSTEM_ROLE_CODES.GROUP_ADMIN
|
||||
]);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
10
backend/src/middleware/auth.ts
Normal file
10
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { UnauthorizedError } from "../utils/errors.js";
|
||||
|
||||
export function requireAuth(request: Request, _response: Response, next: NextFunction) {
|
||||
if (!request.session.user) {
|
||||
return next(new UnauthorizedError());
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
10
backend/src/middleware/error-handler.ts
Normal file
10
backend/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { AppError } from "../utils/errors.js";
|
||||
|
||||
export function errorHandler(error: Error, _request: Request, response: Response, _next: NextFunction) {
|
||||
if (error instanceof AppError) {
|
||||
return response.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
|
||||
return response.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
6
backend/src/middleware/request-context.ts
Normal file
6
backend/src/middleware/request-context.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export function attachRequestContext(request: Request, _response: Response, next: NextFunction) {
|
||||
request.app.locals.requestStartedAt = new Date();
|
||||
next();
|
||||
}
|
||||
18
backend/src/middleware/validate.ts
Normal file
18
backend/src/middleware/validate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { ZodSchema } from "zod";
|
||||
import { AppError } from "../utils/errors.js";
|
||||
|
||||
export function validateBody(schema: ZodSchema) {
|
||||
return (request: Request, _response: Response, next: NextFunction) => {
|
||||
const result = schema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return next(
|
||||
new AppError(result.error.issues.map((issue: { message: string }) => issue.message).join(", "), 400)
|
||||
);
|
||||
}
|
||||
|
||||
request.body = result.data;
|
||||
return next();
|
||||
};
|
||||
}
|
||||
57
backend/src/repositories/audit.repository.ts
Normal file
57
backend/src/repositories/audit.repository.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PoolClient } from "pg";
|
||||
import { pool } from "../db/pool.js";
|
||||
|
||||
export interface AuditLogInput {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceName?: string | null;
|
||||
sqlText?: string | null;
|
||||
details?: unknown;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export class AuditRepository {
|
||||
async create(entry: AuditLogInput, client?: PoolClient) {
|
||||
const executor = client ?? pool;
|
||||
await executor.query(
|
||||
`
|
||||
insert into audit_logs (
|
||||
user_id,
|
||||
action,
|
||||
resource_type,
|
||||
resource_name,
|
||||
sql_text,
|
||||
details,
|
||||
success
|
||||
) values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
entry.userId ?? null,
|
||||
entry.action,
|
||||
entry.resourceType,
|
||||
entry.resourceName ?? null,
|
||||
entry.sqlText ?? null,
|
||||
JSON.stringify(entry.details ?? {}),
|
||||
entry.success
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async list(search?: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select a.id, a.action, a.resource_type, a.resource_name, a.sql_text, a.details, a.success, a.created_at,
|
||||
u.username
|
||||
from audit_logs a
|
||||
left join app_users u on u.id = a.user_id
|
||||
where ($1::text is null or a.action ilike $1 or a.resource_name ilike $1 or coalesce(u.username, '') ilike $1)
|
||||
order by a.created_at desc
|
||||
limit 200
|
||||
`,
|
||||
[search ? `%${search}%` : null]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
30
backend/src/repositories/auth.repository.ts
Normal file
30
backend/src/repositories/auth.repository.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
|
||||
export class AuthRepository {
|
||||
async findUserByUsername(username: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select id, username, password_hash, is_active
|
||||
from app_users
|
||||
where username = $1
|
||||
`,
|
||||
[username]
|
||||
);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async loadUserRoles(userId: string): Promise<string[]> {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select r.code
|
||||
from user_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return rows.map((row: { code: string }) => row.code);
|
||||
}
|
||||
}
|
||||
63
backend/src/repositories/metadata.repository.ts
Normal file
63
backend/src/repositories/metadata.repository.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
|
||||
export class MetadataRepository {
|
||||
async listTables() {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select
|
||||
t.table_name,
|
||||
coalesce(tgt.table_group_id::text, '') as table_group_id,
|
||||
coalesce(tg.name, 'ungrouped') as table_group,
|
||||
obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass) as description
|
||||
from information_schema.tables t
|
||||
left join table_group_tables tgt on tgt.table_name = t.table_name
|
||||
left join table_groups tg on tg.id = tgt.table_group_id
|
||||
where t.table_schema = 'public'
|
||||
and t.table_type = 'BASE TABLE'
|
||||
and t.table_name not in (
|
||||
'audit_logs', 'user_sessions', 'app_users', 'roles', 'permissions',
|
||||
'role_permissions', 'user_roles', 'table_groups', 'table_group_tables'
|
||||
)
|
||||
order by tg.name nulls last, t.table_name
|
||||
`
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listTableColumns(tableName: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select column_name, data_type, is_nullable, column_default
|
||||
from information_schema.columns
|
||||
where table_schema = 'public' and table_name = $1
|
||||
order by ordinal_position
|
||||
`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listForeignKeys(tableName: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select
|
||||
tc.constraint_name,
|
||||
kcu.column_name,
|
||||
ccu.table_name as foreign_table_name,
|
||||
ccu.column_name as foreign_column_name
|
||||
from information_schema.table_constraints tc
|
||||
join information_schema.key_column_usage kcu
|
||||
on tc.constraint_name = kcu.constraint_name
|
||||
join information_schema.constraint_column_usage ccu
|
||||
on ccu.constraint_name = tc.constraint_name
|
||||
where tc.constraint_type = 'FOREIGN KEY'
|
||||
and tc.table_name = $1
|
||||
`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
94
backend/src/repositories/rbac.repository.ts
Normal file
94
backend/src/repositories/rbac.repository.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
import type { PermissionAction } from "../types/api.js";
|
||||
|
||||
export class RbacRepository {
|
||||
async hasSystemRole(userId: string, roleCode: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from user_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = $1 and r.code = $2
|
||||
limit 1
|
||||
`,
|
||||
[userId, roleCode]
|
||||
);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async hasAnyRole(userId: string, roleCodes: string[]) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from user_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = $1 and r.code = any($2::text[])
|
||||
limit 1
|
||||
`,
|
||||
[userId, roleCodes]
|
||||
);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async hasTablePermission(userId: string, tableName: string, action: PermissionAction) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from user_roles ur
|
||||
join role_permissions rp on rp.role_id = ur.role_id
|
||||
join permissions p on p.id = rp.permission_id
|
||||
left join table_groups tg on tg.id = rp.table_group_id
|
||||
left join table_group_tables tgt on tgt.table_group_id = tg.id
|
||||
where ur.user_id = $1
|
||||
and p.action = $2
|
||||
and (tgt.table_name = $3 or p.resource = 'global')
|
||||
limit 1
|
||||
`,
|
||||
[userId, action, tableName]
|
||||
);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select u.id, u.username, u.is_active, coalesce(array_agg(r.code) filter (where r.code is not null), '{}') as roles
|
||||
from app_users u
|
||||
left join user_roles ur on ur.user_id = u.id
|
||||
left join roles r on r.id = ur.role_id
|
||||
group by u.id
|
||||
order by u.username
|
||||
`
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select r.id, r.code, r.name, r.description,
|
||||
coalesce(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'resource', p.resource,
|
||||
'action', p.action,
|
||||
'tableGroupId', rp.table_group_id
|
||||
)
|
||||
) filter (where p.id is not null),
|
||||
'[]'::json
|
||||
) as permissions
|
||||
from roles r
|
||||
left join role_permissions rp on rp.role_id = r.id
|
||||
left join permissions p on p.id = rp.permission_id
|
||||
group by r.id
|
||||
order by r.code
|
||||
`
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
18
backend/src/routes/admin.routes.ts
Normal file
18
backend/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from "express";
|
||||
import { AdminController } from "../controllers/admin.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { requireAdmin } from "../middleware/admin.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new AdminController();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.get("/users", asyncHandler(controller.users.bind(controller)));
|
||||
router.get("/roles", asyncHandler(controller.roles.bind(controller)));
|
||||
router.get("/audit", asyncHandler(controller.audit.bind(controller)));
|
||||
router.get("/postgres-logs", asyncHandler(controller.postgresLogs.bind(controller)));
|
||||
|
||||
export default router;
|
||||
14
backend/src/routes/auth.routes.ts
Normal file
14
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import { AuthController } from "../controllers/auth.controller.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
import { validateBody } from "../middleware/validate.js";
|
||||
import { loginSchema } from "../validators/auth.validators.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new AuthController();
|
||||
|
||||
router.post("/login", validateBody(loginSchema), asyncHandler(controller.login.bind(controller)));
|
||||
router.post("/logout", asyncHandler(controller.logout.bind(controller)));
|
||||
router.get("/me", asyncHandler(controller.me.bind(controller)));
|
||||
|
||||
export default router;
|
||||
14
backend/src/routes/index.ts
Normal file
14
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import authRoutes from "./auth.routes.js";
|
||||
import metadataRoutes from "./metadata.routes.js";
|
||||
import sqlRoutes from "./sql.routes.js";
|
||||
import adminRoutes from "./admin.routes.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use("/auth", authRoutes);
|
||||
router.use("/db", metadataRoutes);
|
||||
router.use("/sql", sqlRoutes);
|
||||
router.use("/admin", adminRoutes);
|
||||
|
||||
export default router;
|
||||
36
backend/src/routes/metadata.routes.ts
Normal file
36
backend/src/routes/metadata.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from "express";
|
||||
import { MetadataController } from "../controllers/metadata.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { validateBody } from "../middleware/validate.js";
|
||||
import {
|
||||
addColumnSchema,
|
||||
alterColumnSchema,
|
||||
createIndexSchema,
|
||||
createTableSchema,
|
||||
rowSchema
|
||||
} from "../validators/metadata.validators.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new MetadataController();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get("/tables", asyncHandler(controller.listTables.bind(controller)));
|
||||
router.get("/tables/:tableName/details", asyncHandler(controller.getTableDetails.bind(controller)));
|
||||
router.get("/tables/:tableName/rows", asyncHandler(controller.listRows.bind(controller)));
|
||||
router.post("/tables", validateBody(createTableSchema), asyncHandler(controller.createTable.bind(controller)));
|
||||
router.delete("/tables/:tableName", asyncHandler(controller.deleteTable.bind(controller)));
|
||||
router.post("/tables/:tableName/columns", validateBody(addColumnSchema), asyncHandler(controller.addColumn.bind(controller)));
|
||||
router.patch(
|
||||
"/tables/:tableName/columns/:columnName",
|
||||
validateBody(alterColumnSchema),
|
||||
asyncHandler(controller.alterColumn.bind(controller))
|
||||
);
|
||||
router.delete("/tables/:tableName/columns/:columnName", asyncHandler(controller.dropColumn.bind(controller)));
|
||||
router.post("/tables/:tableName/indexes", validateBody(createIndexSchema), asyncHandler(controller.createIndex.bind(controller)));
|
||||
router.post("/tables/:tableName/rows", validateBody(rowSchema), asyncHandler(controller.createRow.bind(controller)));
|
||||
router.put("/tables/:tableName/rows/:id", validateBody(rowSchema), asyncHandler(controller.updateRow.bind(controller)));
|
||||
router.delete("/tables/:tableName/rows/:id", asyncHandler(controller.deleteRow.bind(controller)));
|
||||
|
||||
export default router;
|
||||
24
backend/src/routes/sql.routes.ts
Normal file
24
backend/src/routes/sql.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { SqlController } from "../controllers/sql.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { validateBody } from "../middleware/validate.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
import { sqlExecuteSchema } from "../validators/metadata.validators.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new SqlController();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
);
|
||||
|
||||
router.post("/execute", validateBody(sqlExecuteSchema), asyncHandler(controller.execute.bind(controller)));
|
||||
|
||||
export default router;
|
||||
8
backend/src/server.ts
Normal file
8
backend/src/server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "./app.js";
|
||||
import { env } from "./config/env.js";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
app.listen(env.PORT, () => {
|
||||
console.log(`Backend listening on port ${env.PORT}`);
|
||||
});
|
||||
14
backend/src/services/audit.service.ts
Normal file
14
backend/src/services/audit.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { AuditLogInput } from "../repositories/audit.repository.js";
|
||||
import { AuditRepository } from "../repositories/audit.repository.js";
|
||||
|
||||
const repository = new AuditRepository();
|
||||
|
||||
export class AuditService {
|
||||
async log(params: AuditLogInput) {
|
||||
await repository.create(params);
|
||||
}
|
||||
|
||||
async list(search?: string) {
|
||||
return repository.list(search);
|
||||
}
|
||||
}
|
||||
52
backend/src/services/auth.service.ts
Normal file
52
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||
import { AuthRepository } from "../repositories/auth.repository.js";
|
||||
import { AuditService } from "./audit.service.js";
|
||||
import { UnauthorizedError } from "../utils/errors.js";
|
||||
|
||||
const authRepository = new AuthRepository();
|
||||
const auditService = new AuditService();
|
||||
|
||||
export class AuthService {
|
||||
async login(username: string, password: string) {
|
||||
const user = await authRepository.findUserByUsername(username);
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
await auditService.log({
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILURE,
|
||||
resourceType: "auth",
|
||||
resourceName: username,
|
||||
success: false
|
||||
});
|
||||
throw new UnauthorizedError("Invalid credentials");
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
await auditService.log({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILURE,
|
||||
resourceType: "auth",
|
||||
resourceName: username,
|
||||
success: false
|
||||
});
|
||||
throw new UnauthorizedError("Invalid credentials");
|
||||
}
|
||||
|
||||
const roleCodes = await authRepository.loadUserRoles(user.id);
|
||||
|
||||
await auditService.log({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.LOGIN_SUCCESS,
|
||||
resourceType: "auth",
|
||||
resourceName: username,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roleCodes
|
||||
};
|
||||
}
|
||||
}
|
||||
26
backend/src/services/logs.service.ts
Normal file
26
backend/src/services/logs.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Docker from "dockerode";
|
||||
import { env } from "../config/env.js";
|
||||
import { AppError } from "../utils/errors.js";
|
||||
|
||||
const docker = new Docker({ socketPath: "//./pipe/docker_engine" });
|
||||
|
||||
export class LogsService {
|
||||
async readLogs(search?: string) {
|
||||
try {
|
||||
const container = docker.getContainer(env.POSTGRES_CONTAINER_NAME);
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: 300
|
||||
});
|
||||
|
||||
const content = logs.toString("utf-8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.filter((line: string) => (search ? line.toLowerCase().includes(search.toLowerCase()) : true));
|
||||
} catch {
|
||||
throw new AppError("Unable to read PostgreSQL container logs. Ensure Docker is available.", 503);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
backend/src/services/metadata.service.ts
Normal file
208
backend/src/services/metadata.service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||
import { MetadataRepository } from "../repositories/metadata.repository.js";
|
||||
import { assertIdentifier, quoteIdentifier } from "../utils/identifiers.js";
|
||||
import { AuditService } from "./audit.service.js";
|
||||
import { RbacService } from "./rbac.service.js";
|
||||
|
||||
const repository = new MetadataRepository();
|
||||
const auditService = new AuditService();
|
||||
const rbacService = new RbacService();
|
||||
|
||||
export class MetadataService {
|
||||
async listTables() {
|
||||
return repository.listTables();
|
||||
}
|
||||
|
||||
async getTableDetails(tableName: string) {
|
||||
assertIdentifier(tableName, "table name");
|
||||
const [columns, foreignKeys] = await Promise.all([
|
||||
repository.listTableColumns(tableName),
|
||||
repository.listForeignKeys(tableName)
|
||||
]);
|
||||
|
||||
return { columns, foreignKeys };
|
||||
}
|
||||
|
||||
async listRows(params: {
|
||||
userId: string;
|
||||
tableName: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: "asc" | "desc";
|
||||
}) {
|
||||
await rbacService.assertPermission(params.userId, params.tableName, "read");
|
||||
assertIdentifier(params.tableName, "table name");
|
||||
|
||||
const columns = await repository.listTableColumns(params.tableName);
|
||||
const searchableColumns = columns.map((column: { column_name: string }) => column.column_name);
|
||||
const offset = (params.page - 1) * params.pageSize;
|
||||
const orderColumn = params.sortBy ? quoteIdentifier(params.sortBy) : "\"id\"";
|
||||
const direction = params.sortDirection === "desc" ? "DESC" : "ASC";
|
||||
|
||||
const filterValues: unknown[] = [];
|
||||
let whereSql = "";
|
||||
|
||||
if (params.search && searchableColumns.length > 0) {
|
||||
filterValues.push(`%${params.search}%`);
|
||||
whereSql = `where ${searchableColumns
|
||||
.map((column: string) => `cast(${quoteIdentifier(column)} as text) ilike $1`)
|
||||
.join(" or ")}`;
|
||||
}
|
||||
|
||||
const rowValues = [...filterValues, params.pageSize, offset];
|
||||
|
||||
const rowsQuery = `
|
||||
select *
|
||||
from ${quoteIdentifier(params.tableName)}
|
||||
${whereSql}
|
||||
order by ${orderColumn} ${direction}
|
||||
limit $${rowValues.length - 1} offset $${rowValues.length}
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
select count(*)::int as total
|
||||
from ${quoteIdentifier(params.tableName)}
|
||||
${whereSql}
|
||||
`;
|
||||
|
||||
const [rowsResult, countResult] = await Promise.all([
|
||||
pool.query(rowsQuery, rowValues),
|
||||
pool.query(countQuery, filterValues)
|
||||
]);
|
||||
|
||||
return {
|
||||
rows: rowsResult.rows,
|
||||
total: countResult.rows[0]?.total ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) {
|
||||
await this.assertSchemaPermission(userId, payload.tableName);
|
||||
|
||||
const columnSql = payload.columns
|
||||
.map(
|
||||
(column: { name: string; type: string; nullable?: boolean }) =>
|
||||
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
await pool.query(`create table ${quoteIdentifier(payload.tableName)} (${columnSql})`);
|
||||
await this.logSchema(userId, payload.tableName, "create_table", payload);
|
||||
}
|
||||
|
||||
async deleteTable(userId: string, tableName: string) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(`drop table if exists ${quoteIdentifier(tableName)} cascade`);
|
||||
await this.logSchema(userId, tableName, "delete_table", {});
|
||||
}
|
||||
|
||||
async addColumn(userId: string, tableName: string, column: { name: string; type: string; nullable?: boolean }) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(
|
||||
`alter table ${quoteIdentifier(tableName)} add column ${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "add_column", column);
|
||||
}
|
||||
|
||||
async alterColumnType(userId: string, tableName: string, columnName: string, dataType: string) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(
|
||||
`alter table ${quoteIdentifier(tableName)} alter column ${quoteIdentifier(columnName)} type ${dataType}`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "alter_column_type", { columnName, dataType });
|
||||
}
|
||||
|
||||
async dropColumn(userId: string, tableName: string, columnName: string) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(
|
||||
`alter table ${quoteIdentifier(tableName)} drop column if exists ${quoteIdentifier(columnName)}`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "drop_column", { columnName });
|
||||
}
|
||||
|
||||
async createIndex(userId: string, tableName: string, payload: { indexName: string; columns: string[]; unique?: boolean }) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
const columnsSql = payload.columns.map((column: string) => quoteIdentifier(column)).join(", ");
|
||||
await pool.query(
|
||||
`create ${payload.unique ? "unique " : ""}index ${quoteIdentifier(payload.indexName)} on ${quoteIdentifier(tableName)} (${columnsSql})`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "create_index", payload);
|
||||
}
|
||||
|
||||
async createRow(userId: string, tableName: string, data: Record<string, unknown>) {
|
||||
await rbacService.assertPermission(userId, tableName, "write");
|
||||
await this.mutateRow(userId, tableName, "insert", data);
|
||||
}
|
||||
|
||||
async updateRow(userId: string, tableName: string, id: string | number, data: Record<string, unknown>) {
|
||||
await rbacService.assertPermission(userId, tableName, "write");
|
||||
await this.mutateRow(userId, tableName, "update", data, id);
|
||||
}
|
||||
|
||||
async deleteRow(userId: string, tableName: string, id: string | number) {
|
||||
await rbacService.assertPermission(userId, tableName, "delete");
|
||||
await pool.query(`delete from ${quoteIdentifier(tableName)} where id = $1`, [id]);
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.DATA_MUTATION,
|
||||
resourceType: "row",
|
||||
resourceName: `${tableName}:${id}`,
|
||||
details: { id, operation: "delete" },
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
private async mutateRow(
|
||||
userId: string,
|
||||
tableName: string,
|
||||
operation: "insert" | "update",
|
||||
data: Record<string, unknown>,
|
||||
id?: string | number
|
||||
) {
|
||||
assertIdentifier(tableName, "table name");
|
||||
const entries = Object.entries(data);
|
||||
const columns = entries.map(([column]: [string, unknown]) => quoteIdentifier(column));
|
||||
const values = entries.map(([, value]: [string, unknown]) => value);
|
||||
|
||||
if (operation === "insert") {
|
||||
const placeholders = values.map((_value: unknown, index: number) => `$${index + 1}`).join(", ");
|
||||
await pool.query(
|
||||
`insert into ${quoteIdentifier(tableName)} (${columns.join(", ")}) values (${placeholders})`,
|
||||
values
|
||||
);
|
||||
} else {
|
||||
const setSql = columns.map((column: string, index: number) => `${column} = $${index + 1}`).join(", ");
|
||||
await pool.query(
|
||||
`update ${quoteIdentifier(tableName)} set ${setSql} where id = $${values.length + 1}`,
|
||||
[...values, id]
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.DATA_MUTATION,
|
||||
resourceType: "row",
|
||||
resourceName: `${tableName}:${id ?? "new"}`,
|
||||
details: { operation, data },
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
private async assertSchemaPermission(userId: string, tableName: string) {
|
||||
await rbacService.assertPermission(userId, tableName, "schema");
|
||||
}
|
||||
|
||||
private async logSchema(userId: string, tableName: string, operation: string, details: unknown) {
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.SCHEMA_CHANGE,
|
||||
resourceType: "table",
|
||||
resourceName: tableName,
|
||||
details: { operation, ...((details as object) ?? {}) },
|
||||
success: true
|
||||
});
|
||||
}
|
||||
}
|
||||
35
backend/src/services/rbac.service.ts
Normal file
35
backend/src/services/rbac.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
|
||||
import { RbacRepository } from "../repositories/rbac.repository.js";
|
||||
import type { PermissionAction } from "../types/api.js";
|
||||
import { ForbiddenError } from "../utils/errors.js";
|
||||
|
||||
const repository = new RbacRepository();
|
||||
|
||||
export class RbacService {
|
||||
async assertPermission(userId: string, tableName: string, action: PermissionAction) {
|
||||
const isRoot = await repository.hasSystemRole(userId, SYSTEM_ROLE_CODES.ROOT);
|
||||
if (isRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await repository.hasTablePermission(userId, tableName, action);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenError(`Missing ${action} access to table ${tableName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async assertAnyRole(userId: string, roleCodes: string[]) {
|
||||
const allowed = await repository.hasAnyRole(userId, roleCodes);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenError("Administrative role required");
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
return repository.listUsers();
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
return repository.listRoles();
|
||||
}
|
||||
}
|
||||
43
backend/src/services/sql-console.service.ts
Normal file
43
backend/src/services/sql-console.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||
import { AuditService } from "./audit.service.js";
|
||||
import { RbacService } from "./rbac.service.js";
|
||||
import { assertSafeSql, inferQueryAction } from "../utils/sql-safety.js";
|
||||
|
||||
const auditService = new AuditService();
|
||||
const rbacService = new RbacService();
|
||||
|
||||
export class SqlConsoleService {
|
||||
async execute(userId: string, sql: string) {
|
||||
assertSafeSql(sql);
|
||||
const action = inferQueryAction(sql);
|
||||
|
||||
if (action === "schema" || action === "execute") {
|
||||
await rbacService.assertPermission(userId, "audit_logs", "schema");
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await pool.query(sql);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.SQL_EXECUTE,
|
||||
resourceType: "sql_console",
|
||||
sqlText: sql,
|
||||
details: { rowCount: result.rowCount, durationMs, action },
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
command: result.command,
|
||||
rowCount: result.rowCount,
|
||||
durationMs,
|
||||
fields: result.fields.map((field: { name: string; dataTypeID: number }) => ({
|
||||
name: field.name,
|
||||
dataTypeId: field.dataTypeID
|
||||
})),
|
||||
rows: result.rows
|
||||
};
|
||||
}
|
||||
}
|
||||
7
backend/src/types/api.ts
Normal file
7
backend/src/types/api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type PermissionAction = "read" | "write" | "delete" | "schema" | "execute";
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
roleCodes: string[];
|
||||
}
|
||||
11
backend/src/types/express-session.d.ts
vendored
Normal file
11
backend/src/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleCodes: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
9
backend/src/utils/async-handler.ts
Normal file
9
backend/src/utils/async-handler.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (request: Request, response: Response, next: NextFunction) => Promise<unknown>
|
||||
) {
|
||||
return (request: Request, response: Response, next: NextFunction) => {
|
||||
void handler(request, response, next).catch(next);
|
||||
};
|
||||
}
|
||||
20
backend/src/utils/errors.ts
Normal file
20
backend/src/utils/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class AppError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = "Forbidden") {
|
||||
super(message, 403);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = "Unauthorized") {
|
||||
super(message, 401);
|
||||
}
|
||||
}
|
||||
15
backend/src/utils/identifiers.ts
Normal file
15
backend/src/utils/identifiers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AppError } from "./errors.js";
|
||||
|
||||
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
export function assertIdentifier(value: string, label = "identifier"): string {
|
||||
if (!SAFE_IDENTIFIER.test(value)) {
|
||||
throw new AppError(`Invalid ${label}`, 400);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function quoteIdentifier(value: string): string {
|
||||
return `"${assertIdentifier(value).replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
40
backend/src/utils/sql-safety.ts
Normal file
40
backend/src/utils/sql-safety.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AppError } from "./errors.js";
|
||||
|
||||
const BLOCKED_PATTERNS = [
|
||||
/\bdrop\s+database\b/i,
|
||||
/\bdrop\s+role\b/i,
|
||||
/\balter\s+system\b/i,
|
||||
/\bcopy\s+.+\s+to\s+program\b/i,
|
||||
/\bgrant\s+superuser\b/i,
|
||||
/\btruncate\b/i
|
||||
];
|
||||
|
||||
export function assertSafeSql(sql: string) {
|
||||
for (const pattern of BLOCKED_PATTERNS) {
|
||||
if (pattern.test(sql)) {
|
||||
throw new AppError("Query contains a blocked operation", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function inferQueryAction(sql: string): "read" | "write" | "delete" | "schema" | "execute" {
|
||||
const normalized = sql.trim().toLowerCase();
|
||||
|
||||
if (normalized.startsWith("select") || normalized.startsWith("with")) {
|
||||
return "read";
|
||||
}
|
||||
|
||||
if (normalized.startsWith("insert") || normalized.startsWith("update")) {
|
||||
return "write";
|
||||
}
|
||||
|
||||
if (normalized.startsWith("delete")) {
|
||||
return "delete";
|
||||
}
|
||||
|
||||
if (normalized.startsWith("create") || normalized.startsWith("alter") || normalized.startsWith("drop")) {
|
||||
return "schema";
|
||||
}
|
||||
|
||||
return "execute";
|
||||
}
|
||||
6
backend/src/validators/auth.validators.ts
Normal file
6
backend/src/validators/auth.validators.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
password: z.string().min(8)
|
||||
});
|
||||
34
backend/src/validators/metadata.validators.ts
Normal file
34
backend/src/validators/metadata.validators.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createTableSchema = z.object({
|
||||
tableName: z.string().min(1),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
nullable: z.boolean().optional()
|
||||
})
|
||||
).min(1)
|
||||
});
|
||||
|
||||
export const addColumnSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
nullable: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const alterColumnSchema = z.object({
|
||||
dataType: z.string().min(1)
|
||||
});
|
||||
|
||||
export const createIndexSchema = z.object({
|
||||
indexName: z.string().min(1),
|
||||
columns: z.array(z.string().min(1)).min(1),
|
||||
unique: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const rowSchema = z.record(z.any());
|
||||
|
||||
export const sqlExecuteSchema = z.object({
|
||||
sql: z.string().min(1)
|
||||
});
|
||||
Reference in New Issue
Block a user