This commit is contained in:
2026-03-19 18:00:46 +07:00
commit f72ad2769f
98 changed files with 9299 additions and 0 deletions

21
backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:24-alpine AS base
WORKDIR /app
COPY package.json /app/package.json
COPY backend/package.json /app/backend/package.json
RUN npm install --workspaces --include-workspace-root=false
FROM base AS build
COPY backend /app/backend
WORKDIR /app/backend
RUN npm run build
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=base /app/node_modules /app/node_modules
COPY --from=build /app/backend/dist /app/backend/dist
COPY backend/package.json /app/backend/package.json
WORKDIR /app/backend
USER node
EXPOSE 4000
CMD ["node", "dist/index.js"]

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@pg-admin/backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "tsx --test tests/**/*.test.ts"
},
"dependencies": {
"argon2": "^0.41.1",
"cookie": "^1.0.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"pg": "^8.16.3",
"zod": "^3.25.67"
},
"devDependencies": {
"@types/cookie": "^1.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.0.10",
"@types/pg": "^8.15.5",
"tsx": "^4.20.3",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,36 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import { env } from "../config/env.js";
import { requestIdMiddleware } from "../middleware/request-id.js";
import { sessionMiddleware } from "../middleware/auth.js";
import { errorHandler } from "../middleware/error-handler.js";
import { apiLimiter, authLimiter } from "./rate-limit.js";
import { healthRouter } from "./health.routes.js";
import { apiRouter } from "./routes.js";
export function createApp() {
const app = express();
if (env.TRUST_PROXY) {
app.set("trust proxy", 1);
}
app.use(requestIdMiddleware);
app.use(
cors({
origin: env.FRONTEND_ORIGIN,
credentials: true
})
);
app.use(helmet());
app.use(express.json({ limit: "1mb" }));
app.use(sessionMiddleware);
app.use("/health", healthRouter);
app.use("/api/v1/auth", authLimiter);
app.use("/api/v1", apiLimiter);
app.use("/api/v1", apiRouter);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,12 @@
import { Router } from "express";
import { ok } from "../lib/api-response.js";
export const healthRouter = Router();
healthRouter.get("/live", (_req, res) => {
res.json(ok({ status: "live" }));
});
healthRouter.get("/ready", (_req, res) => {
res.json(ok({ status: "ready" }));
});

View File

@@ -0,0 +1,16 @@
import rateLimit from "express-rate-limit";
import { env } from "../config/env.js";
export const apiLimiter = rateLimit({
windowMs: env.RATE_LIMIT_WINDOW_MS,
max: env.RATE_LIMIT_MAX,
standardHeaders: true,
legacyHeaders: false
});
export const authLimiter = rateLimit({
windowMs: env.RATE_LIMIT_WINDOW_MS,
max: env.AUTH_RATE_LIMIT_MAX,
standardHeaders: true,
legacyHeaders: false
});

33
backend/src/app/routes.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Router } from "express";
import { authRouter } from "../modules/auth/auth.routes.js";
import { navigationRouter } from "../modules/navigation/navigation.routes.js";
import { tablesRouter } from "../modules/tables/tables.routes.js";
import { recordsRouter } from "../modules/records/records.routes.js";
import { schemaRouter } from "../modules/schema/schema.routes.js";
import { indexesRouter, globalIndexesRouter } from "../modules/indexes/indexes.routes.js";
import { sqlConsoleRouter } from "../modules/sql-console/sql-console.routes.js";
import { auditRouter } from "../modules/audit/audit.routes.js";
import { usersRouter } from "../modules/users/users.routes.js";
import { rolesRouter } from "../modules/roles/roles.routes.js";
import { permissionsRouter } from "../modules/permissions/permissions.routes.js";
import { connectionsRouter } from "../modules/connections/connections.routes.js";
import { logsRouter } from "../modules/logs/logs.routes.js";
import { requireAuth } from "../middleware/auth.js";
export const apiRouter = Router();
apiRouter.use("/auth", authRouter);
apiRouter.use(requireAuth);
apiRouter.use("/navigation", navigationRouter);
apiRouter.use("/tables", tablesRouter);
apiRouter.use("/tables/:table/records", recordsRouter);
apiRouter.use("/tables/:table/columns", schemaRouter);
apiRouter.use("/tables/:table/indexes", indexesRouter);
apiRouter.use("/indexes", globalIndexesRouter);
apiRouter.use("/sql", sqlConsoleRouter);
apiRouter.use("/audit", auditRouter);
apiRouter.use("/users", usersRouter);
apiRouter.use("/roles", rolesRouter);
apiRouter.use("/permissions", permissionsRouter);
apiRouter.use("/connections", connectionsRouter);
apiRouter.use("/logs", logsRouter);

47
backend/src/config/env.ts Normal file
View File

@@ -0,0 +1,47 @@
import "dotenv/config";
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
APP_PORT: z.coerce.number().default(4000),
APP_HOST: z.string().default("0.0.0.0"),
FRONTEND_ORIGIN: z.string().url().default("http://localhost:5173"),
SESSION_COOKIE_NAME: z.string().default("pg_admin_sid"),
SESSION_TTL_HOURS: z.coerce.number().positive().default(12),
SESSION_SECRET: z.string().min(8),
TRUST_PROXY: z
.string()
.transform((value) => value === "true")
.default("false"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
LOG_SOURCE_PATH: z.string().default("/var/log/postgresql/postgresql.log"),
FEATURE_SQL_CONSOLE: z
.string()
.transform((value) => value !== "false")
.default("true"),
FEATURE_LOG_VIEWER: z
.string()
.transform((value) => value !== "false")
.default("true"),
FEATURE_SCHEMA_MUTATIONS: z
.string()
.transform((value) => value !== "false")
.default("true"),
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000),
RATE_LIMIT_MAX: z.coerce.number().default(120),
AUTH_RATE_LIMIT_MAX: z.coerce.number().default(10),
CONTROL_DB_HOST: z.string(),
CONTROL_DB_PORT: z.coerce.number().default(5432),
CONTROL_DB_NAME: z.string(),
CONTROL_DB_USER: z.string(),
CONTROL_DB_PASSWORD: z.string(),
TARGET_DB_HOST: z.string(),
TARGET_DB_PORT: z.coerce.number().default(5432),
TARGET_DB_NAME: z.string(),
TARGET_DB_USER: z.string(),
TARGET_DB_PASSWORD: z.string()
});
export const env = envSchema.parse(process.env);
export const isProduction = env.NODE_ENV === "production";

11
backend/src/db/control.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Pool } from "pg";
import { env } from "../config/env.js";
export const controlPool = new Pool({
host: env.CONTROL_DB_HOST,
port: env.CONTROL_DB_PORT,
database: env.CONTROL_DB_NAME,
user: env.CONTROL_DB_USER,
password: env.CONTROL_DB_PASSWORD,
max: 10
});

18
backend/src/db/target.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Pool } from "pg";
import { env } from "../config/env.js";
let targetPool: Pool | null = null;
export function getTargetPool() {
if (!targetPool) {
targetPool = new Pool({
host: env.TARGET_DB_HOST,
port: env.TARGET_DB_PORT,
database: env.TARGET_DB_NAME,
user: env.TARGET_DB_USER,
password: env.TARGET_DB_PASSWORD,
max: 10
});
}
return targetPool;
}

12
backend/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from "./app/create-app.js";
import { env } from "./config/env.js";
import { logger } from "./lib/logger.js";
const app = createApp();
app.listen(env.APP_PORT, env.APP_HOST, () => {
logger.info("Backend started", {
host: env.APP_HOST,
port: env.APP_PORT
});
});

View File

@@ -0,0 +1,19 @@
export function ok<T>(data: T, meta: Record<string, unknown> = {}) {
return {
success: true,
data,
meta
};
}
export function fail(code: string, message: string, requestId: string, details?: unknown) {
return {
success: false,
error: {
code,
message,
details,
requestId
}
};
}

16
backend/src/lib/errors.ts Normal file
View File

@@ -0,0 +1,16 @@
export class AppError extends Error {
statusCode: number;
code: string;
details?: unknown;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}

6
backend/src/lib/http.ts Normal file
View File

@@ -0,0 +1,6 @@
export function getSingleParam(value: string | string[] | undefined) {
if (Array.isArray(value)) {
return value[0] ?? "";
}
return value ?? "";
}

View File

@@ -0,0 +1,22 @@
import { AppError } from "./errors.js";
const identifierPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
export function assertSafeIdentifier(value: string, label = "identifier") {
if (!identifierPattern.test(value)) {
throw new AppError(400, "INVALID_IDENTIFIER", `Unsafe ${label}: ${value}`);
}
}
export function quoteIdentifier(identifier: string) {
assertSafeIdentifier(identifier);
return `"${identifier}"`;
}
export function quoteQualifiedName(name: string) {
const parts = name.split(".");
if (parts.length > 2) {
throw new AppError(400, "INVALID_IDENTIFIER", `Unsupported qualified name: ${name}`);
}
return parts.map((part) => quoteIdentifier(part)).join(".");
}

49
backend/src/lib/logger.ts Normal file
View File

@@ -0,0 +1,49 @@
type LogLevel = "debug" | "info" | "warn" | "error";
const order: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40
};
const currentLevel = (process.env.LOG_LEVEL as LogLevel | undefined) ?? "info";
function write(level: LogLevel, message: string, meta?: unknown) {
if (order[level] < order[currentLevel]) {
return;
}
const payload = {
ts: new Date().toISOString(),
level,
message,
meta
};
const line = JSON.stringify(payload);
if (level === "error") {
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
}
export const logger = {
debug(message: string, meta?: unknown) {
write("debug", message, meta);
},
info(message: string, meta?: unknown) {
write("info", message, meta);
},
warn(message: string, meta?: unknown) {
write("warn", message, meta);
},
error(message: string, meta?: unknown) {
write("error", message, meta);
}
};

View File

@@ -0,0 +1,9 @@
export function getPagination(page: number, limit: number) {
const safePage = Math.max(page, 1);
const safeLimit = Math.min(Math.max(limit, 1), 100);
return {
page: safePage,
limit: safeLimit,
offset: (safePage - 1) * safeLimit
};
}

View File

@@ -0,0 +1,64 @@
import { AppError } from "./errors.js";
const blockedPatterns = [
/\bdrop\s+database\b/i,
/\balter\s+system\b/i,
/\bcopy\b[\s\S]*\bprogram\b/i,
/\bcreate\s+role\b/i,
/\balter\s+role\b/i,
/\bdrop\s+role\b/i,
/\bcreate\s+extension\b/i
];
const mutatingKeywords = /\b(insert|update|delete|alter|create|drop|truncate|grant|revoke)\b/i;
const selectOnlyPattern = /^\s*(with\b[\s\S]+?\bselect\b|select\b)/i;
export type SqlGuardOptions = {
allowMultiStatement: boolean;
readOnly: boolean;
allowSchemaChanges: boolean;
};
export function normalizeSql(sql: string) {
return sql.replace(/\s+/g, " ").trim();
}
export function inferStatementType(sql: string) {
const normalized = normalizeSql(sql).toLowerCase();
return normalized.split(/\s+/)[0] ?? "unknown";
}
export function guardSql(sql: string, options: SqlGuardOptions) {
const normalized = normalizeSql(sql);
if (!normalized) {
throw new AppError(400, "SQL_EMPTY", "SQL query cannot be empty");
}
if (!options.allowMultiStatement && normalized.includes(";")) {
const statements = normalized.split(";").filter((part) => part.trim().length > 0);
if (statements.length > 1) {
throw new AppError(403, "SQL_MULTI_STATEMENT_BLOCKED", "Multiple statements are restricted");
}
}
for (const pattern of blockedPatterns) {
if (pattern.test(normalized)) {
throw new AppError(403, "SQL_BLOCKED", "SQL contains a blocked operation");
}
}
if (options.readOnly && !selectOnlyPattern.test(normalized)) {
throw new AppError(403, "SQL_READ_ONLY", "Read-only access allows SELECT statements only");
}
if (!options.allowSchemaChanges && /\b(alter|create|drop|truncate)\b/i.test(normalized)) {
throw new AppError(403, "SQL_SCHEMA_BLOCKED", "Schema-changing statements are not allowed");
}
return {
normalized,
statementType: inferStatementType(normalized),
isMutating: mutatingKeywords.test(normalized)
};
}

View File

@@ -0,0 +1,20 @@
import type { RequestHandler } from "express";
import { AppError } from "../lib/errors.js";
import { resolveSession } from "../modules/auth/auth.service.js";
export const sessionMiddleware: RequestHandler = async (req, _res, next) => {
try {
await resolveSession(req);
next();
} catch (error) {
next(error);
}
};
export const requireAuth: RequestHandler = (req, _res, next) => {
if (!req.user) {
next(new AppError(401, "UNAUTHORIZED", "Authentication is required"));
return;
}
next();
};

View File

@@ -0,0 +1,17 @@
import type { ErrorRequestHandler } from "express";
import { fail } from "../lib/api-response.js";
import { isAppError } from "../lib/errors.js";
import { logger } from "../lib/logger.js";
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
if (isAppError(error)) {
return res.status(error.statusCode).json(fail(error.code, error.message, req.requestId, error.details));
}
logger.error("Unhandled error", {
requestId: req.requestId,
error: error instanceof Error ? error.message : String(error)
});
return res.status(500).json(fail("INTERNAL_ERROR", "Internal server error", req.requestId));
};

View File

@@ -0,0 +1,67 @@
import type { RequestHandler } from "express";
import { AppError } from "../lib/errors.js";
import type { PermissionAction, PermissionResource } from "../types/auth.js";
function extractTableGroup(tableName: string) {
if (tableName.includes("__")) {
return tableName.split("__")[0];
}
return "default";
}
export function hasPermission(
req: Express.Request,
resource: PermissionResource,
action: PermissionAction,
scope?: { type: "group" | "table"; value: string }
) {
if (req.user?.isRoot) {
return true;
}
const permissions = req.user?.permissions ?? [];
return permissions.some((grant) => {
if (grant.resource !== resource || grant.action !== action) {
return false;
}
if (grant.scopeType === "global") {
return true;
}
if (!scope) {
return false;
}
return grant.scopeType === scope.type && grant.scopeValue === scope.value;
});
}
export function requirePermission(resource: PermissionResource, action: PermissionAction): RequestHandler {
return (req, _res, next) => {
if (hasPermission(req, resource, action)) {
next();
return;
}
next(new AppError(403, "FORBIDDEN", "Permission denied"));
};
}
export function requireTableAccess(action: PermissionAction): RequestHandler {
return (req, _res, next) => {
const tableName = String(req.params.table ?? "");
if (!tableName) {
next(new AppError(400, "MISSING_TABLE", "Table parameter is required"));
return;
}
const group = extractTableGroup(tableName);
if (
hasPermission(req, "table", action, { type: "table", value: tableName }) ||
hasPermission(req, "group", action, { type: "group", value: group }) ||
hasPermission(req, "database", action)
) {
next();
return;
}
next(new AppError(403, "FORBIDDEN", "Table access denied"));
};
}

View File

@@ -0,0 +1,7 @@
import type { RequestHandler } from "express";
import crypto from "node:crypto";
export const requestIdMiddleware: RequestHandler = (req, _res, next) => {
req.requestId = crypto.randomUUID();
next();
};

View File

@@ -0,0 +1,27 @@
import type { RequestHandler } from "express";
import type { ZodTypeAny } from "zod";
import { AppError } from "../lib/errors.js";
export function validateBody(schema: ZodTypeAny): RequestHandler {
return (req, _res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
next(new AppError(400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()));
return;
}
req.body = parsed.data;
next();
};
}
export function validateQuery(schema: ZodTypeAny): RequestHandler {
return (req, _res, next) => {
const parsed = schema.safeParse(req.query);
if (!parsed.success) {
next(new AppError(400, "VALIDATION_ERROR", "Invalid query parameters", parsed.error.flatten()));
return;
}
req.query = parsed.data;
next();
};
}

View File

@@ -0,0 +1,67 @@
import { Router } from "express";
import { controlPool } from "../../db/control.js";
import { ok } from "../../lib/api-response.js";
import { getPagination } from "../../lib/pagination.js";
import { requirePermission } from "../../middleware/permission.js";
import { validateQuery } from "../../middleware/validate.js";
import { auditQuerySchema } from "./audit.schemas.js";
export const auditRouter = Router();
auditRouter.get("/", requirePermission("audit", "read"), validateQuery(auditQuerySchema), async (req, res, next) => {
try {
const query = req.query as unknown as {
action?: string;
resourceType?: string;
status?: string;
userId?: string;
page: number;
limit: number;
};
const pagination = getPagination(query.page, query.limit);
const values: unknown[] = [];
const where: string[] = [];
if (query.action) {
values.push(query.action);
where.push(`action = $${values.length}`);
}
if (query.resourceType) {
values.push(query.resourceType);
where.push(`resource_type = $${values.length}`);
}
if (query.status) {
values.push(query.status);
where.push(`status = $${values.length}`);
}
if (query.userId) {
values.push(query.userId);
where.push(`actor_user_id = $${values.length}`);
}
const whereSql = where.length > 0 ? `where ${where.join(" and ")}` : "";
const countResult = await controlPool.query(`select count(*)::int as total from audit_events ${whereSql}`, values);
values.push(pagination.limit, pagination.offset);
const dataResult = await controlPool.query(
`
select *
from audit_events
${whereSql}
order by created_at desc
limit $${values.length - 1}
offset $${values.length}
`,
values
);
res.json(
ok(dataResult.rows, {
page: pagination.page,
limit: pagination.limit,
total: countResult.rows[0]?.total ?? 0
})
);
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const auditQuerySchema = z.object({
action: z.string().optional(),
resourceType: z.string().optional(),
status: z.string().optional(),
userId: z.string().optional(),
page: z.coerce.number().default(1),
limit: z.coerce.number().default(25)
});

View File

@@ -0,0 +1,53 @@
import { controlPool } from "../../db/control.js";
export type AuditInput = {
actorUserId: string | null;
action: string;
resourceType: string;
resourceName: string | null;
groupId?: string | null;
targetConnectionId?: string | null;
sqlTextMasked?: string | null;
payloadBefore?: unknown;
payloadAfter?: unknown;
ip?: string | null;
userAgent?: string | null;
status: "success" | "failure";
};
export async function createAuditEvent(input: AuditInput) {
await controlPool.query(
`
insert into audit_events (
actor_user_id,
action,
resource_type,
resource_name,
group_id,
target_connection_id,
sql_text_masked,
payload_before,
payload_after,
ip,
user_agent,
status
) values (
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $11, $12
)
`,
[
input.actorUserId,
input.action,
input.resourceType,
input.resourceName,
input.groupId ?? null,
input.targetConnectionId ?? null,
input.sqlTextMasked ?? null,
input.payloadBefore ? JSON.stringify(input.payloadBefore) : null,
input.payloadAfter ? JSON.stringify(input.payloadAfter) : null,
input.ip ?? null,
input.userAgent ?? null,
input.status
]
);
}

View File

@@ -0,0 +1,30 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { requireAuth } from "../../middleware/auth.js";
import { validateBody } from "../../middleware/validate.js";
import { loginSchema } from "./auth.schemas.js";
import { login, logout } from "./auth.service.js";
export const authRouter = Router();
authRouter.post("/login", validateBody(loginSchema), async (req, res, next) => {
try {
const user = await login(req, res, req.body.username, req.body.password);
res.json(ok({ user }));
} catch (error) {
next(error);
}
});
authRouter.post("/logout", requireAuth, async (req, res, next) => {
try {
await logout(req, res);
res.json(ok({ loggedOut: true }));
} catch (error) {
next(error);
}
});
authRouter.get("/session", requireAuth, async (req, res) => {
res.json(ok({ user: req.user }));
});

View File

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

View File

@@ -0,0 +1,238 @@
import argon2 from "argon2";
import crypto from "node:crypto";
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
import type { Request, Response } from "express";
import { controlPool } from "../../db/control.js";
import { env, isProduction } from "../../config/env.js";
import type { PermissionGrant, SessionUser } from "../../types/auth.js";
import { AppError } from "../../lib/errors.js";
import { createAuditEvent } from "../audit/audit.service.js";
type UserRow = {
id: string;
username: string;
password_hash: string;
is_active: boolean;
is_locked: boolean;
role_slug: string;
is_root: boolean;
};
function buildCookie(token: string) {
return serializeCookie(env.SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: isProduction,
path: "/",
maxAge: env.SESSION_TTL_HOURS * 60 * 60
});
}
function hashToken(token: string) {
return crypto.createHash("sha256").update(token + env.SESSION_SECRET).digest("hex");
}
async function getPermissions(userId: string): Promise<PermissionGrant[]> {
const result = await controlPool.query(
`
select
p.resource,
p.action,
coalesce(rp.scope_type, 'global') as scope_type,
rp.scope_value
from user_roles ur
join roles r on r.id = ur.role_id
join role_permissions rp on rp.role_id = r.id
join permissions p on p.id = rp.permission_id
where ur.user_id = $1
`,
[userId]
);
return result.rows.map((row) => ({
resource: row.resource,
action: row.action,
scopeType: row.scope_type,
scopeValue: row.scope_value
}));
}
async function verifyPassword(passwordHash: string, password: string) {
if (passwordHash.startsWith("pbkdf2$")) {
const [, digest, iterationsRaw, salt, expectedHash] = passwordHash.split("$");
const derived = crypto
.pbkdf2Sync(password, salt, Number(iterationsRaw), expectedHash.length / 2, digest)
.toString("hex");
return crypto.timingSafeEqual(Buffer.from(derived), Buffer.from(expectedHash));
}
return argon2.verify(passwordHash, password);
}
export async function login(req: Request, res: Response, username: string, password: string) {
const userResult = await controlPool.query<UserRow>(
`
select
u.id,
u.username,
u.password_hash,
u.is_active,
u.is_locked,
coalesce(r.slug, 'user') as role_slug,
coalesce(bool_or(r.slug = 'root'), false) as is_root
from users u
left join user_roles ur on ur.user_id = u.id
left join roles r on r.id = ur.role_id
where u.username = $1
group by u.id
limit 1
`,
[username]
);
const user = userResult.rows[0];
if (!user) {
await createAuditEvent({
actorUserId: null,
action: "auth.login",
resourceType: "session",
resourceName: username,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "failure"
});
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid username or password");
}
if (!user.is_active || user.is_locked) {
throw new AppError(403, "ACCOUNT_DISABLED", "Account is disabled or locked");
}
const valid = await verifyPassword(user.password_hash, password);
if (!valid) {
await createAuditEvent({
actorUserId: user.id,
action: "auth.login",
resourceType: "session",
resourceName: username,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "failure"
});
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid username or password");
}
const permissions = await getPermissions(user.id);
const token = crypto.randomUUID();
const tokenHash = hashToken(token);
const sessionResult = await controlPool.query<{ id: string }>(
`
insert into sessions (user_id, token_hash, expires_at, ip, user_agent)
values ($1, $2, now() + ($3 || ' hour')::interval, $4, $5)
returning id
`,
[user.id, tokenHash, env.SESSION_TTL_HOURS, req.ip, req.headers["user-agent"] ?? null]
);
const sessionUser: SessionUser = {
id: user.id,
username: user.username,
roleSlug: user.role_slug,
isRoot: user.is_root,
permissions
};
res.setHeader("Set-Cookie", buildCookie(token));
req.user = sessionUser;
req.sessionId = sessionResult.rows[0]?.id;
await createAuditEvent({
actorUserId: user.id,
action: "auth.login",
resourceType: "session",
resourceName: user.username,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
return sessionUser;
}
export async function resolveSession(req: Request) {
const cookies = parseCookie(req.headers.cookie ?? "");
const token = cookies[env.SESSION_COOKIE_NAME];
if (!token) {
return null;
}
const sessionResult = await controlPool.query(
`
select
s.id as session_id,
u.id,
u.username,
coalesce(bool_or(r.slug = 'root'), false) as is_root,
coalesce(max(r.slug), 'user') as role_slug
from sessions s
join users u on u.id = s.user_id
left join user_roles ur on ur.user_id = u.id
left join roles r on r.id = ur.role_id
where s.token_hash = $1
and s.expires_at > now()
and u.is_active = true
and u.is_locked = false
group by s.id, u.id
limit 1
`,
[hashToken(token)]
);
const row = sessionResult.rows[0];
if (!row) {
return null;
}
const permissions = await getPermissions(row.id);
const sessionUser: SessionUser = {
id: row.id,
username: row.username,
roleSlug: row.role_slug,
isRoot: row.is_root,
permissions
};
req.user = sessionUser;
req.sessionId = row.session_id;
return sessionUser;
}
export async function logout(req: Request, res: Response) {
if (req.sessionId) {
await controlPool.query(`delete from sessions where id = $1`, [req.sessionId]);
}
res.setHeader(
"Set-Cookie",
serializeCookie(env.SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: isProduction,
path: "/",
expires: new Date(0)
})
);
if (req.user) {
await createAuditEvent({
actorUserId: req.user.id,
action: "auth.logout",
resourceType: "session",
resourceName: req.user.username,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
}
}

View File

@@ -0,0 +1,17 @@
import { Router } from "express";
import { controlPool } from "../../db/control.js";
import { ok } from "../../lib/api-response.js";
import { requirePermission } from "../../middleware/permission.js";
export const connectionsRouter = Router();
connectionsRouter.get("/", requirePermission("database", "read"), async (_req, res, next) => {
try {
const result = await controlPool.query(
`select id, name, host, port, database_name, is_default, created_at from db_connections order by is_default desc, name`
);
res.json(ok(result.rows));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,40 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { getSingleParam } from "../../lib/http.js";
import { requirePermission, requireTableAccess } from "../../middleware/permission.js";
import { validateBody } from "../../middleware/validate.js";
import { createIndexSchema } from "./indexes.schemas.js";
import { createIndex, dropIndex, listIndexes } from "./indexes.service.js";
export const indexesRouter = Router({ mergeParams: true });
export const globalIndexesRouter = Router();
indexesRouter.get("/", requireTableAccess("read"), async (req, res, next) => {
try {
res.json(ok(await listIndexes(getSingleParam(req.params.table))));
} catch (error) {
next(error);
}
});
indexesRouter.post("/", requireTableAccess("schema_change"), validateBody(createIndexSchema), async (req, res, next) => {
try {
await createIndex(getSingleParam(req.params.table), req.body.name, req.body.columns, req.body.unique);
res.status(201).json(ok({ created: true }));
} catch (error) {
next(error);
}
});
globalIndexesRouter.delete(
"/:name",
requirePermission("database", "schema_change"),
async (req, res, next) => {
try {
await dropIndex(getSingleParam(req.params.name));
res.json(ok({ deleted: true }));
} catch (error) {
next(error);
}
}
);

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const createIndexSchema = z.object({
name: z.string().min(1),
columns: z.array(z.string().min(1)).min(1),
unique: z.boolean().default(false)
});

View File

@@ -0,0 +1,45 @@
import { getTargetPool } from "../../db/target.js";
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
export async function listIndexes(table: string) {
assertSafeIdentifier(table, "table");
const pool = getTargetPool();
const result = await pool.query(
`
select
indexname as name,
indexdef as definition
from pg_indexes
where schemaname = 'public'
and tablename = $1
order by indexname
`,
[table]
);
return result.rows.map((row) => ({
name: row.name,
definition: row.definition,
unique: /\bunique\b/i.test(row.definition),
type: row.definition.includes(" using ") ? row.definition.split(" using ")[1].split(" ")[0] : "btree"
}));
}
export async function createIndex(table: string, name: string, columns: string[], unique: boolean) {
assertSafeIdentifier(table, "table");
assertSafeIdentifier(name, "index");
const quotedColumns = columns.map((column) => {
assertSafeIdentifier(column, "column");
return quoteIdentifier(column);
});
const pool = getTargetPool();
await pool.query(
`create ${unique ? "unique " : ""}index ${quoteIdentifier(name)} on ${quoteIdentifier(table)} (${quotedColumns.join(", ")})`
);
}
export async function dropIndex(name: string) {
assertSafeIdentifier(name, "index");
const pool = getTargetPool();
await pool.query(`drop index ${quoteIdentifier(name)}`);
}

View File

@@ -0,0 +1,15 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { requireAuth } from "../../middleware/auth.js";
import { getSidebarTree } from "./navigation.service.js";
export const navigationRouter = Router();
navigationRouter.get("/sidebar", requireAuth, async (_req, res, next) => {
try {
const tree = await getSidebarTree();
res.json(ok(tree));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,79 @@
import { controlPool } from "../../db/control.js";
import { getTargetPool } from "../../db/target.js";
type SidebarTable = {
name: string;
schema: string;
group_slug: string;
display_name: string;
estimated_rows: number;
};
export async function getSidebarTree() {
const groupsResult = await controlPool.query<{
group_id: string;
group_slug: string;
group_name: string;
table_name: string | null;
}>(
`
select
g.id as group_id,
g.slug as group_slug,
g.name as group_name,
rgt.table_name
from resource_groups g
left join resource_group_tables rgt on rgt.group_id = g.id
order by g.name, rgt.table_name
`
);
const targetPool = getTargetPool();
const tablesResult = await targetPool.query<SidebarTable>(
`
select
t.table_name as name,
t.table_schema as schema,
coalesce(g.slug, split_part(t.table_name, '__', 1), 'default') as group_slug,
case
when position('__' in t.table_name) > 0 then split_part(t.table_name, '__', 2)
else t.table_name
end as display_name,
coalesce(s.n_live_tup, 0)::bigint as estimated_rows
from information_schema.tables t
left join pg_stat_user_tables s
on s.relname = t.table_name
left join resource_group_tables rgt
on rgt.table_name = t.table_name
left join resource_groups g
on g.id = rgt.group_id
where t.table_schema = 'public'
and t.table_type = 'BASE TABLE'
order by group_slug, display_name
`
);
const mapped = new Map<string, { slug: string; name: string; tables: SidebarTable[] }>();
for (const row of groupsResult.rows) {
if (!mapped.has(row.group_slug)) {
mapped.set(row.group_slug, {
slug: row.group_slug,
name: row.group_name,
tables: []
});
}
}
for (const table of tablesResult.rows) {
if (!mapped.has(table.group_slug)) {
mapped.set(table.group_slug, {
slug: table.group_slug,
name: table.group_slug === "default" ? "General" : table.group_slug,
tables: []
});
}
mapped.get(table.group_slug)?.tables.push(table);
}
return Array.from(mapped.values());
}

View File

@@ -0,0 +1,15 @@
import { Router } from "express";
import { controlPool } from "../../db/control.js";
import { ok } from "../../lib/api-response.js";
import { requirePermission } from "../../middleware/permission.js";
export const permissionsRouter = Router();
permissionsRouter.get("/", requirePermission("roles", "manage_roles"), async (_req, res, next) => {
try {
const result = await controlPool.query(`select * from permissions order by resource, action`);
res.json(ok(result.rows));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,112 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { getSingleParam } from "../../lib/http.js";
import { requireTableAccess } from "../../middleware/permission.js";
import { validateQuery } from "../../middleware/validate.js";
import { createAuditEvent } from "../audit/audit.service.js";
import { recordsQuerySchema } from "./records.schemas.js";
import { createRecord, deleteRecord, getRecordById, listRecords, updateRecord } from "./records.service.js";
export const recordsRouter = Router({ mergeParams: true });
recordsRouter.get("/", requireTableAccess("read"), validateQuery(recordsQuerySchema), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
const query = req.query as unknown as {
page: number;
limit: number;
search: string;
sortColumn?: string;
sortDirection?: string;
filters: string;
};
const result = await listRecords({
table: tableName,
...query
});
res.json(ok(result.data, {
page: result.page,
limit: result.limit,
total: result.total,
totalPages: result.totalPages,
primaryKey: result.primaryKey
}));
} catch (error) {
next(error);
}
});
recordsRouter.get("/:id", requireTableAccess("read"), async (req, res, next) => {
try {
res.json(ok(await getRecordById(getSingleParam(req.params.table), getSingleParam(req.params.id))));
} catch (error) {
next(error);
}
});
recordsRouter.post("/", requireTableAccess("write"), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
await createRecord(tableName, req.body as Record<string, unknown>);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "record.create",
resourceType: "table",
resourceName: tableName,
payloadAfter: req.body,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.status(201).json(ok({ created: true }));
} catch (error) {
next(error);
}
});
recordsRouter.put("/:id", requireTableAccess("write"), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
const recordId = getSingleParam(req.params.id);
await updateRecord(tableName, recordId, req.body as Record<string, unknown>);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "record.update",
resourceType: "table",
resourceName: tableName,
payloadAfter: {
id: recordId,
changes: req.body
},
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.json(ok({ updated: true }));
} catch (error) {
next(error);
}
});
recordsRouter.delete("/:id", requireTableAccess("delete"), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
const recordId = getSingleParam(req.params.id);
await deleteRecord(tableName, recordId);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "record.delete",
resourceType: "table",
resourceName: tableName,
payloadAfter: {
id: recordId
},
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.json(ok({ deleted: true }));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const recordsQuerySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(25),
search: z.string().optional().default(""),
sortColumn: z.string().optional(),
sortDirection: z.enum(["asc", "desc", "ASC", "DESC"]).optional().default("ASC"),
filters: z.string().optional().default("{}")
});

View File

@@ -0,0 +1,195 @@
import { getTargetPool } from "../../db/target.js";
import { AppError } from "../../lib/errors.js";
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
import { getPagination } from "../../lib/pagination.js";
type Filters = Record<string, string>;
async function getColumns(table: string) {
const pool = getTargetPool();
const result = await pool.query<{ column_name: string }>(
`
select column_name
from information_schema.columns
where table_schema = 'public'
and table_name = $1
order by ordinal_position
`,
[table]
);
return result.rows.map((row) => row.column_name);
}
async function getPrimaryKey(table: string) {
const pool = getTargetPool();
const result = await pool.query<{ column_name: string }>(
`
select kcu.column_name
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on tc.constraint_name = kcu.constraint_name
and tc.table_schema = kcu.table_schema
where tc.constraint_type = 'PRIMARY KEY'
and tc.table_schema = 'public'
and tc.table_name = $1
limit 1
`,
[table]
);
return result.rows[0]?.column_name ?? null;
}
function parseFilters(filters: string): Filters {
try {
const parsed = JSON.parse(filters);
if (!parsed || typeof parsed !== "object") {
return {};
}
return parsed as Filters;
} catch {
return {};
}
}
export async function listRecords(query: {
table: string;
page: number;
limit: number;
search: string;
sortColumn?: string;
sortDirection?: string;
filters: string;
}) {
assertSafeIdentifier(query.table, "table");
const columns = await getColumns(query.table);
const primaryKey = await getPrimaryKey(query.table);
const pagination = getPagination(query.page, query.limit);
const parsedFilters = parseFilters(query.filters);
if (query.sortColumn && !columns.includes(query.sortColumn)) {
throw new AppError(400, "INVALID_SORT", "Invalid sort column");
}
const pool = getTargetPool();
const whereParts: string[] = [];
const values: unknown[] = [];
if (query.search.trim()) {
const searchValue = `%${query.search.trim()}%`;
const conditions = columns.map((column) => {
values.push(searchValue);
return `cast(${quoteIdentifier(column)} as text) ilike $${values.length}`;
});
whereParts.push(`(${conditions.join(" or ")})`);
}
for (const [column, value] of Object.entries(parsedFilters)) {
if (!columns.includes(column)) {
continue;
}
values.push(`%${value}%`);
whereParts.push(`cast(${quoteIdentifier(column)} as text) ilike $${values.length}`);
}
const whereSql = whereParts.length > 0 ? `where ${whereParts.join(" and ")}` : "";
const orderSql = query.sortColumn
? `order by ${quoteIdentifier(query.sortColumn)} ${query.sortDirection?.toUpperCase() === "DESC" ? "DESC" : "ASC"}`
: primaryKey
? `order by ${quoteIdentifier(primaryKey)} desc`
: "";
const countResult = await pool.query(
`select count(*)::int as total from ${quoteIdentifier(query.table)} ${whereSql}`,
values
);
values.push(pagination.limit, pagination.offset);
const dataResult = await pool.query(
`
select *
from ${quoteIdentifier(query.table)}
${whereSql}
${orderSql}
limit $${values.length - 1}
offset $${values.length}
`,
values
);
const total = countResult.rows[0]?.total ?? 0;
return {
data: dataResult.rows,
page: pagination.page,
limit: pagination.limit,
total,
totalPages: Math.max(1, Math.ceil(total / pagination.limit)),
primaryKey
};
}
export async function getRecordById(table: string, id: string) {
assertSafeIdentifier(table, "table");
const primaryKey = await getPrimaryKey(table);
if (!primaryKey) {
throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key");
}
const pool = getTargetPool();
const result = await pool.query(
`select * from ${quoteIdentifier(table)} where ${quoteIdentifier(primaryKey)} = $1 limit 1`,
[id]
);
return result.rows[0] ?? null;
}
export async function createRecord(table: string, payload: Record<string, unknown>) {
assertSafeIdentifier(table, "table");
const entries = Object.entries(payload).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
throw new AppError(400, "EMPTY_PAYLOAD", "Record payload cannot be empty");
}
const columns = entries.map(([key]) => {
assertSafeIdentifier(key, "column");
return quoteIdentifier(key);
});
const values = entries.map(([, value]) => value);
const placeholders = values.map((_, index) => `$${index + 1}`);
const pool = getTargetPool();
await pool.query(
`insert into ${quoteIdentifier(table)} (${columns.join(", ")}) values (${placeholders.join(", ")})`,
values
);
}
export async function updateRecord(table: string, id: string, payload: Record<string, unknown>) {
assertSafeIdentifier(table, "table");
const primaryKey = await getPrimaryKey(table);
if (!primaryKey) {
throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key");
}
const entries = Object.entries(payload).filter(([key, value]) => key !== primaryKey && value !== undefined);
if (entries.length === 0) {
throw new AppError(400, "EMPTY_PAYLOAD", "Record payload cannot be empty");
}
const values: unknown[] = [];
const sets = entries.map(([key, value]) => {
assertSafeIdentifier(key, "column");
values.push(value);
return `${quoteIdentifier(key)} = $${values.length}`;
});
values.push(id);
const pool = getTargetPool();
await pool.query(
`update ${quoteIdentifier(table)} set ${sets.join(", ")} where ${quoteIdentifier(primaryKey)} = $${values.length}`,
values
);
}
export async function deleteRecord(table: string, id: string) {
assertSafeIdentifier(table, "table");
const primaryKey = await getPrimaryKey(table);
if (!primaryKey) {
throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key");
}
const pool = getTargetPool();
await pool.query(`delete from ${quoteIdentifier(table)} where ${quoteIdentifier(primaryKey)} = $1`, [id]);
}

View File

@@ -0,0 +1,35 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { getSingleParam } from "../../lib/http.js";
import { requirePermission } from "../../middleware/permission.js";
import { validateBody } from "../../middleware/validate.js";
import { upsertRoleSchema } from "./roles.schemas.js";
import { createRole, listRoles, updateRole } from "./roles.service.js";
export const rolesRouter = Router();
rolesRouter.get("/", requirePermission("roles", "manage_roles"), async (_req, res, next) => {
try {
res.json(ok(await listRoles()));
} catch (error) {
next(error);
}
});
rolesRouter.post("/", requirePermission("roles", "manage_roles"), validateBody(upsertRoleSchema), async (req, res, next) => {
try {
const roleId = await createRole(req.body);
res.status(201).json(ok({ id: roleId }));
} catch (error) {
next(error);
}
});
rolesRouter.put("/:id", requirePermission("roles", "manage_roles"), validateBody(upsertRoleSchema), async (req, res, next) => {
try {
await updateRole(getSingleParam(req.params.id), req.body);
res.json(ok({ updated: true }));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
export const upsertRoleSchema = z.object({
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional().default(""),
permissions: z
.array(
z.object({
permissionId: z.string(),
scopeType: z.enum(["global", "group", "table"]).default("global"),
scopeValue: z.string().nullable().optional()
})
)
.default([])
});

View File

@@ -0,0 +1,83 @@
import { controlPool } from "../../db/control.js";
export async function listRoles() {
const rolesResult = await controlPool.query(
`
select
r.id,
r.name,
r.slug,
r.description,
coalesce(
json_agg(
json_build_object(
'permissionId', p.id,
'resource', p.resource,
'action', p.action,
'scopeType', rp.scope_type,
'scopeValue', rp.scope_value
)
) 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.name
`
);
return rolesResult.rows;
}
export async function createRole(input: {
name: string;
slug: string;
description: string;
permissions: Array<{ permissionId: string; scopeType: string; scopeValue?: string | null }>;
}) {
const roleResult = await controlPool.query<{ id: string }>(
`
insert into roles (name, slug, description)
values ($1, $2, $3)
returning id
`,
[input.name, input.slug, input.description]
);
const roleId = roleResult.rows[0]?.id;
for (const permission of input.permissions) {
await controlPool.query(
`
insert into role_permissions (role_id, permission_id, scope_type, scope_value)
values ($1, $2, $3, $4)
`,
[roleId, permission.permissionId, permission.scopeType, permission.scopeValue ?? null]
);
}
return roleId;
}
export async function updateRole(
id: string,
input: {
name: string;
slug: string;
description: string;
permissions: Array<{ permissionId: string; scopeType: string; scopeValue?: string | null }>;
}
) {
await controlPool.query(
`update roles set name = $2, slug = $3, description = $4 where id = $1`,
[id, input.name, input.slug, input.description]
);
await controlPool.query(`delete from role_permissions where role_id = $1`, [id]);
for (const permission of input.permissions) {
await controlPool.query(
`
insert into role_permissions (role_id, permission_id, scope_type, scope_value)
values ($1, $2, $3, $4)
`,
[id, permission.permissionId, permission.scopeType, permission.scopeValue ?? null]
);
}
}

View File

@@ -0,0 +1,79 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { getSingleParam } from "../../lib/http.js";
import { requireTableAccess } from "../../middleware/permission.js";
import { validateBody } from "../../middleware/validate.js";
import { createAuditEvent } from "../audit/audit.service.js";
import { addColumn, dropColumn, ensureSchemaMutationsEnabled, updateColumn } from "./schema.service.js";
import { createColumnSchema, updateColumnSchema } from "./schema.schemas.js";
export const schemaRouter = Router({ mergeParams: true });
schemaRouter.post("/", requireTableAccess("schema_change"), validateBody(createColumnSchema), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
await ensureSchemaMutationsEnabled();
await addColumn(tableName, req.body);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "schema.column.create",
resourceType: "table",
resourceName: tableName,
payloadAfter: req.body,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.status(201).json(ok({ created: true }));
} catch (error) {
next(error);
}
});
schemaRouter.put(
"/:column",
requireTableAccess("schema_change"),
validateBody(updateColumnSchema),
async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
const columnName = getSingleParam(req.params.column);
await ensureSchemaMutationsEnabled();
await updateColumn(tableName, columnName, req.body);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "schema.column.update",
resourceType: "table",
resourceName: `${tableName}.${columnName}`,
payloadAfter: req.body,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.json(ok({ updated: true }));
} catch (error) {
next(error);
}
}
);
schemaRouter.delete("/:column", requireTableAccess("schema_change"), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
const columnName = getSingleParam(req.params.column);
await ensureSchemaMutationsEnabled();
await dropColumn(tableName, columnName);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "schema.column.delete",
resourceType: "table",
resourceName: `${tableName}.${columnName}`,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.json(ok({ deleted: true }));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
export const createColumnSchema = z.object({
name: z.string().min(1),
type: z.string().min(1),
nullable: z.boolean().default(true),
defaultValue: z.string().nullable().optional(),
primaryKey: z.boolean().default(false)
});
export const updateColumnSchema = z.object({
type: z.string().optional(),
nullable: z.boolean().optional(),
defaultValue: z.string().nullable().optional()
});

View File

@@ -0,0 +1,82 @@
import { getTargetPool } from "../../db/target.js";
import { AppError } from "../../lib/errors.js";
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
type CreateColumnInput = {
name: string;
type: string;
nullable: boolean;
defaultValue?: string | null;
primaryKey: boolean;
};
type UpdateColumnInput = {
type?: string;
nullable?: boolean;
defaultValue?: string | null;
};
export async function addColumn(table: string, input: CreateColumnInput) {
assertSafeIdentifier(table, "table");
assertSafeIdentifier(input.name, "column");
const pool = getTargetPool();
await pool.query(
`alter table ${quoteIdentifier(table)} add column ${quoteIdentifier(input.name)} ${input.type}`
);
if (!input.nullable) {
await pool.query(
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(input.name)} set not null`
);
}
if (input.defaultValue) {
await pool.query(
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(input.name)} set default ${input.defaultValue}`
);
}
if (input.primaryKey) {
await pool.query(
`alter table ${quoteIdentifier(table)} add primary key (${quoteIdentifier(input.name)})`
);
}
}
export async function updateColumn(table: string, column: string, input: UpdateColumnInput) {
assertSafeIdentifier(table, "table");
assertSafeIdentifier(column, "column");
const pool = getTargetPool();
if (input.type) {
await pool.query(
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} type ${input.type}`
);
}
if (typeof input.nullable === "boolean") {
await pool.query(
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} ${
input.nullable ? "drop not null" : "set not null"
}`
);
}
if (input.defaultValue !== undefined) {
await pool.query(
input.defaultValue === null || input.defaultValue === ""
? `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} drop default`
: `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} set default ${input.defaultValue}`
);
}
}
export async function dropColumn(table: string, column: string) {
assertSafeIdentifier(table, "table");
assertSafeIdentifier(column, "column");
const pool = getTargetPool();
await pool.query(`alter table ${quoteIdentifier(table)} drop column ${quoteIdentifier(column)} cascade`);
}
export async function ensureSchemaMutationsEnabled() {
if (process.env.FEATURE_SCHEMA_MUTATIONS === "false") {
throw new AppError(403, "SCHEMA_MUTATIONS_DISABLED", "Schema mutation feature is disabled");
}
}

View File

@@ -0,0 +1,34 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { AppError } from "../../lib/errors.js";
import { requireAuth } from "../../middleware/auth.js";
import { requirePermission } from "../../middleware/permission.js";
import { validateBody } from "../../middleware/validate.js";
import { executeSqlSchema } from "./sql-console.schemas.js";
import { executeSql } from "./sql-console.service.js";
export const sqlConsoleRouter = Router();
sqlConsoleRouter.post(
"/execute",
requireAuth,
requirePermission("sql_console", "execute_sql"),
validateBody(executeSqlSchema),
async (req, res, next) => {
try {
if (!req.user) {
throw new AppError(401, "UNAUTHORIZED", "Authentication is required");
}
res.json(
ok(
await executeSql(req.body.sql, req.user, {
ip: req.ip,
userAgent: req.headers["user-agent"] as string | undefined
})
)
);
} catch (error) {
next(error);
}
}
);

View File

@@ -0,0 +1,5 @@
import { z } from "zod";
export const executeSqlSchema = z.object({
sql: z.string().min(1)
});

View File

@@ -0,0 +1,58 @@
import { createHash } from "node:crypto";
import { getTargetPool } from "../../db/target.js";
import { AppError } from "../../lib/errors.js";
import { guardSql } from "../../lib/sql-guard.js";
import type { SessionUser } from "../../types/auth.js";
import { createAuditEvent } from "../audit/audit.service.js";
function isReadOnly(user: SessionUser) {
if (user.isRoot) {
return false;
}
const hasWrite = user.permissions.some((grant) => grant.action === "write" || grant.action === "schema_change");
return !hasWrite;
}
export async function executeSql(sql: string, user: SessionUser, context: { ip?: string; userAgent?: string }) {
if (process.env.FEATURE_SQL_CONSOLE === "false") {
throw new AppError(403, "SQL_CONSOLE_DISABLED", "SQL console feature is disabled");
}
const guard = guardSql(sql, {
allowMultiStatement: user.isRoot,
readOnly: isReadOnly(user),
allowSchemaChanges: user.isRoot || user.permissions.some((grant) => grant.action === "schema_change")
});
const pool = getTargetPool();
const startedAt = Date.now();
const result = await pool.query(guard.normalized);
const durationMs = Date.now() - startedAt;
const maskedSql = guard.normalized.slice(0, 4000);
await createAuditEvent({
actorUserId: user.id,
action: "sql.execute",
resourceType: "sql_console",
resourceName: guard.statementType,
sqlTextMasked: maskedSql,
payloadAfter: {
rowCount: result.rowCount,
durationMs,
statementHash: createHash("sha256").update(guard.normalized).digest("hex")
},
ip: context.ip ?? null,
userAgent: context.userAgent ?? null,
status: "success"
});
return {
rows: result.rows,
fields: result.fields.map((field) => ({ name: field.name, dataTypeId: field.dataTypeID })),
rowCount: result.rowCount ?? 0,
durationMs,
statementType: guard.statementType,
notice: guard.isMutating ? "Mutation executed" : "Query executed"
};
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const createTableSchema = z.object({
name: z.string().min(1),
columns: z
.array(
z.object({
name: z.string().min(1),
type: z.string().min(1),
nullable: z.boolean().default(true),
primaryKey: z.boolean().default(false),
defaultValue: z.string().nullable().optional()
})
)
.min(1),
groupSlug: z.string().min(1).optional()
});

View File

@@ -0,0 +1,77 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { getSingleParam } from "../../lib/http.js";
import { requirePermission, requireTableAccess } from "../../middleware/permission.js";
import { validateBody } from "../../middleware/validate.js";
import { createAuditEvent } from "../audit/audit.service.js";
import { createTableSchema } from "./table.schemas.js";
import { createTable, dropTable, getTableRelations, getTableStructure, listTables } from "./tables.service.js";
export const tablesRouter = Router();
tablesRouter.get("/", async (_req, res, next) => {
try {
res.json(ok(await listTables()));
} catch (error) {
next(error);
}
});
tablesRouter.post(
"/",
requirePermission("database", "schema_change"),
validateBody(createTableSchema),
async (req, res, next) => {
try {
await createTable(req.body);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "schema.table.create",
resourceType: "table",
resourceName: req.body.name,
payloadAfter: req.body,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.status(201).json(ok({ created: true }));
} catch (error) {
next(error);
}
}
);
tablesRouter.delete("/:table", requireTableAccess("schema_change"), async (req, res, next) => {
try {
const tableName = getSingleParam(req.params.table);
await dropTable(tableName);
await createAuditEvent({
actorUserId: req.user?.id ?? null,
action: "schema.table.delete",
resourceType: "table",
resourceName: tableName,
ip: req.ip,
userAgent: req.headers["user-agent"] ?? null,
status: "success"
});
res.json(ok({ deleted: true }));
} catch (error) {
next(error);
}
});
tablesRouter.get("/:table/structure", requireTableAccess("read"), async (req, res, next) => {
try {
res.json(ok(await getTableStructure(getSingleParam(req.params.table))));
} catch (error) {
next(error);
}
});
tablesRouter.get("/:table/relations", requireTableAccess("read"), async (req, res, next) => {
try {
res.json(ok(await getTableRelations(getSingleParam(req.params.table))));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,148 @@
import { getTargetPool } from "../../db/target.js";
import { controlPool } from "../../db/control.js";
import { AppError } from "../../lib/errors.js";
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
type CreateTableInput = {
name: string;
columns: Array<{
name: string;
type: string;
nullable: boolean;
primaryKey: boolean;
defaultValue?: string | null;
}>;
groupSlug?: string;
};
export async function listTables() {
const pool = getTargetPool();
const result = await pool.query(
`
select
t.table_name as name,
t.table_schema as schema,
coalesce(s.n_live_tup, 0)::bigint as rows,
coalesce(g.slug, split_part(t.table_name, '__', 1), 'default') as group_slug
from information_schema.tables t
left join pg_stat_user_tables s
on s.relname = t.table_name
left join resource_group_tables rgt
on rgt.table_name = t.table_name
left join resource_groups g
on g.id = rgt.group_id
where t.table_schema = 'public'
and t.table_type = 'BASE TABLE'
order by t.table_name
`
);
return result.rows;
}
export async function getTableStructure(table: string) {
assertSafeIdentifier(table, "table");
const pool = getTargetPool();
const result = await pool.query(
`
select
c.column_name as name,
c.data_type as type,
c.is_nullable = 'YES' as nullable,
c.column_default as default_value,
exists (
select 1
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on tc.constraint_name = kcu.constraint_name
and tc.table_schema = kcu.table_schema
where tc.constraint_type = 'PRIMARY KEY'
and tc.table_schema = c.table_schema
and tc.table_name = c.table_name
and kcu.column_name = c.column_name
) as is_primary
from information_schema.columns c
where c.table_schema = 'public'
and c.table_name = $1
order by c.ordinal_position
`,
[table]
);
return result.rows;
}
export async function getTableRelations(table: string) {
assertSafeIdentifier(table, "table");
const pool = getTargetPool();
const result = 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_schema = 'public'
and tc.table_name = $1
order by tc.constraint_name
`,
[table]
);
return result.rows;
}
export async function createTable(input: CreateTableInput) {
assertSafeIdentifier(input.name, "table");
const pool = getTargetPool();
const primaryKeys = input.columns.filter((column) => column.primaryKey);
if (primaryKeys.length > 1) {
throw new AppError(400, "INVALID_PRIMARY_KEY", "Composite primary keys are not supported in v1");
}
const definitions = input.columns.map((column) => {
assertSafeIdentifier(column.name, "column");
const pieces = [`${quoteIdentifier(column.name)} ${column.type}`];
if (!column.nullable) {
pieces.push("NOT NULL");
}
if (column.defaultValue) {
pieces.push(`DEFAULT ${column.defaultValue}`);
}
if (column.primaryKey) {
pieces.push("PRIMARY KEY");
}
return pieces.join(" ");
});
await pool.query(`create table ${quoteIdentifier(input.name)} (${definitions.join(", ")})`);
if (input.groupSlug) {
const groupResult = await controlPool.query<{ id: string }>(
`select id from resource_groups where slug = $1 limit 1`,
[input.groupSlug]
);
if (groupResult.rows[0]) {
await controlPool.query(
`
insert into resource_group_tables (group_id, table_name)
values ($1, $2)
on conflict (group_id, table_name) do nothing
`,
[groupResult.rows[0].id, input.name]
);
}
}
}
export async function dropTable(table: string) {
assertSafeIdentifier(table, "table");
const pool = getTargetPool();
await pool.query(`drop table ${quoteIdentifier(table)} cascade`);
}

View File

@@ -0,0 +1,40 @@
import { Router } from "express";
import { ok } from "../../lib/api-response.js";
import { getSingleParam } from "../../lib/http.js";
import { requirePermission } from "../../middleware/permission.js";
import { validateBody } from "../../middleware/validate.js";
import { createUserSchema, updateUserSchema } from "./users.schemas.js";
import { createUser, listUsers, updateUser } from "./users.service.js";
export const usersRouter = Router();
usersRouter.get("/", requirePermission("users", "manage_users"), async (_req, res, next) => {
try {
res.json(ok(await listUsers()));
} catch (error) {
next(error);
}
});
usersRouter.post("/", requirePermission("users", "manage_users"), validateBody(createUserSchema), async (req, res, next) => {
try {
const userId = await createUser(req.body);
res.status(201).json(ok({ id: userId }));
} catch (error) {
next(error);
}
});
usersRouter.put(
"/:id",
requirePermission("users", "manage_users"),
validateBody(updateUserSchema),
async (req, res, next) => {
try {
await updateUser(getSingleParam(req.params.id), req.body);
res.json(ok({ updated: true }));
} catch (error) {
next(error);
}
}
);

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const createUserSchema = z.object({
username: z.string().min(1),
password: z.string().min(8),
roleIds: z.array(z.string()).default([])
});
export const updateUserSchema = z.object({
isActive: z.boolean().optional(),
isLocked: z.boolean().optional(),
roleIds: z.array(z.string()).optional()
});

View File

@@ -0,0 +1,63 @@
import argon2 from "argon2";
import { controlPool } from "../../db/control.js";
export async function listUsers() {
const result = await controlPool.query(
`
select
u.id,
u.username,
u.is_active,
u.is_locked,
u.created_at,
coalesce(json_agg(json_build_object('id', r.id, 'slug', r.slug, 'name', r.name))
filter (where r.id is not null), '[]'::json) as roles
from 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.created_at desc
`
);
return result.rows;
}
export async function createUser(input: { username: string; password: string; roleIds: string[] }) {
const passwordHash = await argon2.hash(input.password);
const userResult = await controlPool.query<{ id: string }>(
`
insert into users (username, password_hash, is_active, is_locked)
values ($1, $2, true, false)
returning id
`,
[input.username, passwordHash]
);
const userId = userResult.rows[0]?.id;
for (const roleId of input.roleIds) {
await controlPool.query(
`insert into user_roles (user_id, role_id) values ($1, $2) on conflict do nothing`,
[userId, roleId]
);
}
return userId;
}
export async function updateUser(id: string, input: { isActive?: boolean; isLocked?: boolean; roleIds?: string[] }) {
await controlPool.query(
`
update users
set
is_active = coalesce($2, is_active),
is_locked = coalesce($3, is_locked)
where id = $1
`,
[id, input.isActive ?? null, input.isLocked ?? null]
);
if (input.roleIds) {
await controlPool.query(`delete from user_roles where user_id = $1`, [id]);
for (const roleId of input.roleIds) {
await controlPool.query(`insert into user_roles (user_id, role_id) values ($1, $2)`, [id, roleId]);
}
}
}

34
backend/src/types/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
export type PermissionAction =
| "read"
| "write"
| "delete"
| "schema_change"
| "execute_sql"
| "view_logs"
| "manage_users"
| "manage_roles";
export type PermissionResource =
| "database"
| "group"
| "table"
| "sql_console"
| "logs"
| "users"
| "roles"
| "audit";
export type PermissionGrant = {
resource: PermissionResource;
action: PermissionAction;
scopeType: "global" | "group" | "table";
scopeValue: string | null;
};
export type SessionUser = {
id: string;
username: string;
roleSlug: string;
isRoot: boolean;
permissions: PermissionGrant[];
};

13
backend/src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import type { SessionUser } from "./auth.js";
declare global {
namespace Express {
interface Request {
requestId: string;
user?: SessionUser;
sessionId?: string;
}
}
}
export {};

View File

@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { assertSafeIdentifier, quoteQualifiedName } from "../src/lib/identifiers.js";
test("safe identifier accepts public_table", () => {
assert.doesNotThrow(() => assertSafeIdentifier("finance_table"));
});
test("safe identifier rejects SQL injection attempts", () => {
assert.throws(() => assertSafeIdentifier("users; drop table users"));
});
test("quoteQualifiedName supports schema-qualified names", () => {
assert.equal(quoteQualifiedName("public.users"), "\"public\".\"users\"");
});

View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { guardSql } from "../src/lib/sql-guard.js";
test("guardSql blocks DROP DATABASE", () => {
assert.throws(
() =>
guardSql("DROP DATABASE appdb", {
allowMultiStatement: false,
readOnly: false,
allowSchemaChanges: true
}),
/blocked/i
);
});
test("guardSql blocks writes for read-only users", () => {
assert.throws(
() =>
guardSql("update users set name = 'x'", {
allowMultiStatement: false,
readOnly: true,
allowSchemaChanges: false
}),
/Read-only/i
);
});

20
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": [
"node"
]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts"
]
}