This commit is contained in:
2026-03-19 16:07:35 +07:00
commit 39b0358b08
63 changed files with 3128 additions and 0 deletions

55
backend/src/app.ts Normal file
View 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
View 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);

View 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;

View 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()) });
}
}

View 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 });
}
}

View 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();
}
}

View 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
View 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
});

View 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
});

View 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);
}
}

View 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();
}

View 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" });
}

View 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();
}

View 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();
};
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;

View 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;

View 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;

View 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;

View 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
View 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}`);
});

View 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);
}
}

View 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
};
}
}

View 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);
}
}
}

View 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
});
}
}

View 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();
}
}

View 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
View 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
View File

@@ -0,0 +1,11 @@
import "express-session";
declare module "express-session" {
interface SessionData {
user?: {
id: string;
username: string;
roleCodes: string[];
};
}
}

View 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);
};
}

View 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);
}
}

View 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, "\"\"")}"`;
}

View 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";
}

View File

@@ -0,0 +1,6 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string().min(3),
password: z.string().min(8)
});

View 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)
});