From 39b0358b0840327d4dd81a25a5b1abf36bacce73 Mon Sep 17 00:00:00 2001 From: Verum Date: Thu, 19 Mar 2026 16:07:35 +0700 Subject: [PATCH] 123 --- .dockerignore | 144 +++++++ .editorconfig | 65 ++++ .gitattributes | 83 +++++ .gitignore | 166 +++++++++ .pre-commit-config.yaml | 55 +++ README.md | 42 +++ backend/.env.example | 8 + backend/Dockerfile | 22 ++ backend/package.json | 34 ++ backend/src/app.ts | 55 +++ backend/src/config/env.ts | 20 + backend/src/constants/permissions.ts | 16 + backend/src/controllers/admin.controller.ts | 26 ++ backend/src/controllers/auth.controller.ts | 22 ++ .../src/controllers/metadata.controller.ts | 78 ++++ backend/src/controllers/sql.controller.ts | 10 + backend/src/db/pool.ts | 8 + backend/src/db/session-store.ts | 11 + backend/src/middleware/admin.ts | 17 + backend/src/middleware/auth.ts | 10 + backend/src/middleware/error-handler.ts | 10 + backend/src/middleware/request-context.ts | 6 + backend/src/middleware/validate.ts | 18 + backend/src/repositories/audit.repository.ts | 57 +++ backend/src/repositories/auth.repository.ts | 30 ++ .../src/repositories/metadata.repository.ts | 63 ++++ backend/src/repositories/rbac.repository.ts | 94 +++++ backend/src/routes/admin.routes.ts | 18 + backend/src/routes/auth.routes.ts | 14 + backend/src/routes/index.ts | 14 + backend/src/routes/metadata.routes.ts | 36 ++ backend/src/routes/sql.routes.ts | 24 ++ backend/src/server.ts | 8 + backend/src/services/audit.service.ts | 14 + backend/src/services/auth.service.ts | 52 +++ backend/src/services/logs.service.ts | 26 ++ backend/src/services/metadata.service.ts | 208 +++++++++++ backend/src/services/rbac.service.ts | 35 ++ backend/src/services/sql-console.service.ts | 43 +++ backend/src/types/api.ts | 7 + backend/src/types/express-session.d.ts | 11 + backend/src/utils/async-handler.ts | 9 + backend/src/utils/errors.ts | 20 + backend/src/utils/identifiers.ts | 15 + backend/src/utils/sql-safety.ts | 40 ++ backend/src/validators/auth.validators.ts | 6 + backend/src/validators/metadata.validators.ts | 34 ++ backend/tsconfig.json | 20 + docker-compose.yml | 62 ++++ docs/architecture.md | 102 +++++ frontend/Dockerfile | 18 + frontend/index.html | 12 + frontend/package.json | 15 + frontend/src/api/client.js | 42 +++ frontend/src/components/shell.js | 311 ++++++++++++++++ frontend/src/main.js | 176 +++++++++ frontend/src/pages/login.js | 27 ++ frontend/src/styles/main.css | 351 ++++++++++++++++++ frontend/vite.config.js | 8 + infra/postgres/init.sql | 156 ++++++++ infra/postgres/pg_hba.conf | 3 + infra/postgres/postgresql.conf | 7 + package.json | 14 + 63 files changed, 3128 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/app.ts create mode 100644 backend/src/config/env.ts create mode 100644 backend/src/constants/permissions.ts create mode 100644 backend/src/controllers/admin.controller.ts create mode 100644 backend/src/controllers/auth.controller.ts create mode 100644 backend/src/controllers/metadata.controller.ts create mode 100644 backend/src/controllers/sql.controller.ts create mode 100644 backend/src/db/pool.ts create mode 100644 backend/src/db/session-store.ts create mode 100644 backend/src/middleware/admin.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/error-handler.ts create mode 100644 backend/src/middleware/request-context.ts create mode 100644 backend/src/middleware/validate.ts create mode 100644 backend/src/repositories/audit.repository.ts create mode 100644 backend/src/repositories/auth.repository.ts create mode 100644 backend/src/repositories/metadata.repository.ts create mode 100644 backend/src/repositories/rbac.repository.ts create mode 100644 backend/src/routes/admin.routes.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/index.ts create mode 100644 backend/src/routes/metadata.routes.ts create mode 100644 backend/src/routes/sql.routes.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/services/audit.service.ts create mode 100644 backend/src/services/auth.service.ts create mode 100644 backend/src/services/logs.service.ts create mode 100644 backend/src/services/metadata.service.ts create mode 100644 backend/src/services/rbac.service.ts create mode 100644 backend/src/services/sql-console.service.ts create mode 100644 backend/src/types/api.ts create mode 100644 backend/src/types/express-session.d.ts create mode 100644 backend/src/utils/async-handler.ts create mode 100644 backend/src/utils/errors.ts create mode 100644 backend/src/utils/identifiers.ts create mode 100644 backend/src/utils/sql-safety.ts create mode 100644 backend/src/validators/auth.validators.ts create mode 100644 backend/src/validators/metadata.validators.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/components/shell.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/pages/login.js create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/vite.config.js create mode 100644 infra/postgres/init.sql create mode 100644 infra/postgres/pg_hba.conf create mode 100644 infra/postgres/postgresql.conf create mode 100644 package.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca735ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,144 @@ +# ============================================================================ +# Git & Version Control +# ============================================================================ +.git +.gitea +.github +.gitlab +.gitattributes +.gitignore +.pre-commit-config.yaml + +# ============================================================================ +# IDE & Editor +# ============================================================================ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +*.sublime-project +*.sublime-workspace +.editorconfig + +# ============================================================================ +# Documentation & Configuration +# ============================================================================ +README.md +CHANGELOG.md +ARCHITECTURE.md +DEVELOPMENT.md +LICENSE +LICENSE.md +CONTRIBUTING.md +.prettierrc* +.eslintrc* +.stylelintrc* + +# ============================================================================ +# Build & Distribution +# ============================================================================ +dist/ +build/ +out/ +coverage/ +.next/ +.nuxt/ +*.tsbuildinfo + +# ============================================================================ +# Testing +# ============================================================================ +__tests__/ +__test__/ +test/ +tests/ +*.test.js +*.spec.js +.coverage +.nyc_output/ +jest.config.js +karma.conf.js + +# ============================================================================ +# Environment & Secrets +# ============================================================================ +.env +.env.local +.env.*.local +.env.test +.env.production +.envrc +.env-cmdrc.json +.secrets/ +*.pem +*.key +*.crt +*.p12 +*.pfx + +# ============================================================================ +# Temporary & Cache Files +# ============================================================================ +tmp/ +temp/ +.tmp/ +.cache/ +*.tmp +*.temp +.DS_Store +Thumbs.db +ehthumbs.db + +# ============================================================================ +# Logs +# ============================================================================ +*.log +logs/ +log/ + +# ============================================================================ +# Database & Data +# ============================================================================ +postgres_data/ +database_backups/ +*.db +*.sqlite +*.sqlite3 +*.sql +*.sql.gz + +# ============================================================================ +# Docker & Container Files (these files should not be in the container) +# ============================================================================ +Dockerfile +docker-compose*.yml +.dockerignore +.docker/ + +# ============================================================================ +# CI/CD +# ============================================================================ +.github/ +.gitlab-ci.yml +.circleci/ +Jenkinsfile +.travis.yml +.appveyor.yml +azure-pipelines.yml +.drone.yml + +# ============================================================================ +# Misc +# ============================================================================ +tsconfig.json +babel.config.js +webpack.config.js +rollup.config.js +gulpfile.js +Makefile +.nvmrc +.node-version +.npmrc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d30f5f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,65 @@ +root = true + +# ============================================================================= +# Global settings +# ============================================================================= +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +tab_width = 4 + +# ============================================================================= +# Python +# ============================================================================= +[*.py] +max_line_length = 88 + +# ============================================================================= +# YAML (Docker, CI, compose) +# ============================================================================= +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 + +# ============================================================================= +# JSON +# ============================================================================= +[*.json] +indent_size = 2 + +# ============================================================================= +# TOML (pyproject.toml, poetry) +# ============================================================================= +[*.toml] +indent_size = 2 + +# ============================================================================= +# Markdown +# ============================================================================= +[*.md] +trim_trailing_whitespace = false +indent_size = 2 + +# ============================================================================= +# Shell scripts +# ============================================================================= +[*.sh] +indent_size = 2 + +# ============================================================================= +# Makefile (tabs required) +# ============================================================================= +[Makefile] +indent_style = tab + +# ============================================================================= +# INI / config files +# ============================================================================= +[*.ini] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5e5113f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,83 @@ +# ============================================================================= +# Global text normalization +# ============================================================================= +* text=auto eol=lf + +# ============================================================================= +# Shell scripts (must stay LF) +# ============================================================================= +*.sh text eol=lf +*.bash text eol=lf +*.zsh text eol=lf + +# ============================================================================= +# Windows scripts +# ============================================================================= +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# ============================================================================= +# Binary images +# ============================================================================= +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bmp binary +*.webp binary +*.ico binary + +# SVG is text +*.svg text + +# ============================================================================= +# Media +# ============================================================================= +*.mp3 binary +*.wav binary +*.ogg binary +*.mp4 binary +*.mov binary +*.avi binary +*.mkv binary + +# ============================================================================= +# Fonts +# ============================================================================= +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.otf binary + +# ============================================================================= +# Documents +# ============================================================================= +*.pdf binary + +# ============================================================================= +# WebAssembly +# ============================================================================= +*.wasm binary + +# ============================================================================= +# Jupyter +# ============================================================================= +*.ipynb binary + +# ============================================================================= +# Git LFS (ML / large artifacts) +# ============================================================================= +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text + +# ============================================================================= +# GitHub linguist hints +# ============================================================================= +docs/** linguist-documentation +generated/** linguist-generated +vendor/** linguist-vendored diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41bfca2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,166 @@ +# ============================================================================= +# OS +# ============================================================================= +.DS_Store +Thumbs.db +Desktop.ini + +# ============================================================================= +# IDE / Editors +# ============================================================================= +.idea/ +.vscode/ +*.swp +*.swo +*~ +*.sublime-* +*.code-workspace + +# ============================================================================= +# Logs +# ============================================================================= +*.log +*.logs +*.logs.* +*.log.* +logs/ +log/ + +# ============================================================================= +# Environment / Secrets +# ============================================================================= +.env +.env.* +!.env.example +!.env.sample +!.env.template + +# ============================================================================= +# Security keys +# ============================================================================= +*.pem +*.key +*.crt +*.p12 +*.pfx +secrets/ + +# ============================================================================= +# Python +# ============================================================================= +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Packaging +build/ +dist/ +.eggs/ +*.egg-info/ +pip-wheel-metadata/ + +# Testing / coverage +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ + +# Tool caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.pytype/ +.pyright/ + +# Jupyter +.ipynb_checkpoints/ + +# ============================================================================= +# Node / Frontend +# ============================================================================= +node_modules/ +.next/ +.nuxt/ +coverage/ +*.tsbuildinfo + +# ============================================================================= +# Java / Kotlin +# ============================================================================= +.gradle/ +out/ +*.class + +# ============================================================================= +# Go +# ============================================================================= +bin/ +*.test + +# ============================================================================= +# Rust +# ============================================================================= +target/ + +# ============================================================================= +# C / C++ / CMake +# ============================================================================= +cmake-build-*/ +CMakeFiles/ +CMakeCache.txt +compile_commands.json + +# ============================================================================= +# Docker +# ============================================================================= +docker-compose.override.yml +*.tar + +# ============================================================================= +# Databases +# ============================================================================= +*.sqlite +*.sqlite3 +*.db + +# ============================================================================= +# ML / Data artifacts +# ============================================================================= +*.pt +*.pth +*.onnx +*.h5 +*.ckpt +*.safetensors +*.npy +*.npz +*.parquet +*.joblib +*.pkl +*.pickle + +# ============================================================================= +# Archives +# ============================================================================= +*.zip +*.tar.* +*.gz +*.7z +*.rar + +# ============================================================================= +# Temporary +# ============================================================================= +tmp/ +temp/ +*.tmp +.cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7fc6da8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,55 @@ +repos: + # ============================================================================= + # Ruff (lint + import sorting + formatting) + # ============================================================================= + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + + # ============================================================================= + # Base repository hygiene + # ============================================================================= + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-json + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict + - id: detect-private-key + - id: check-added-large-files + - id: debug-statements + - id: check-executables-have-shebangs + - id: requirements-txt-fixer + + # ============================================================================= + # Static typing + # ============================================================================= + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + args: [--ignore-missing-imports] + + # ============================================================================= + # Security checks + # ============================================================================= + - repo: https://github.com/PyCQA/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ["-r", "src"] + + # ============================================================================= + # Secret detection + # ============================================================================= + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets diff --git a/README.md b/README.md new file mode 100644 index 0000000..a67004d --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# PostgreSQL Control Center + +Production-oriented admin panel for PostgreSQL with custom RBAC, audit logging, schema management, SQL console controls, and a separated backend/frontend architecture. + +## Monorepo layout + +- `backend` - Express + TypeScript API, RBAC, audit, PostgreSQL metadata/data access +- `frontend` - Vite + Vanilla JS SPA with modular components +- `infra` - Docker, PostgreSQL bootstrap SQL, container helpers +- `docs` - architecture and operational notes + +## Quick start + +```bash +docker compose up --build +``` + +Services: + +- Frontend: `http://localhost:5173` +- Backend API: `http://localhost:4000/api` +- PostgreSQL: `localhost:5432` + +Seeded root user: + +- login: `root` +- password: `ChangeMe123!` + +## Delivery scope + +Implemented foundation: + +- Separated backend/frontend architecture +- Session-based auth +- RBAC with group-scoped permissions +- Audit logging +- Table/data/schema APIs +- SQL console with safety guardrails +- PostgreSQL log viewing from Docker container +- Dockerized local production-like setup + +See [docs/architecture.md](C:\Users\admin\Desktop\TEst\docs\architecture.md) for details. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c0446e8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +NODE_ENV=development +PORT=4000 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_admin +SESSION_SECRET=change-me +SESSION_NAME=pgcc.sid +ALLOWED_ORIGIN=http://localhost:5173 +POSTGRES_CONTAINER_NAME=pg-control-postgres +COOKIE_SECURE=false diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..165c7f2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json package.json +COPY tsconfig.json tsconfig.json +RUN npm install + +COPY src src +RUN npm run build + +FROM node:22-alpine + +WORKDIR /app + +COPY --from=build /app/package.json package.json +COPY --from=build /app/node_modules node_modules +COPY --from=build /app/dist dist + +EXPOSE 4000 + +CMD ["npm", "run", "start"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..ca715c4 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "@pg-control/backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-pg-simple": "^10.0.0", + "cors": "^2.8.5", + "dockerode": "^4.0.7", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "express-session": "^1.18.0", + "helmet": "^7.1.0", + "pg": "^8.11.5", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", + "@types/node": "^22.10.1", + "@types/pg": "^8.11.6", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..c0ccbc2 --- /dev/null +++ b/backend/src/app.ts @@ -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; +} diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..bbb63f8 --- /dev/null +++ b/backend/src/config/env.ts @@ -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); diff --git a/backend/src/constants/permissions.ts b/backend/src/constants/permissions.ts new file mode 100644 index 0000000..bdb74fd --- /dev/null +++ b/backend/src/constants/permissions.ts @@ -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; diff --git a/backend/src/controllers/admin.controller.ts b/backend/src/controllers/admin.controller.ts new file mode 100644 index 0000000..833faa4 --- /dev/null +++ b/backend/src/controllers/admin.controller.ts @@ -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()) }); + } +} diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts new file mode 100644 index 0000000..01b2eae --- /dev/null +++ b/backend/src/controllers/auth.controller.ts @@ -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 }); + } +} diff --git a/backend/src/controllers/metadata.controller.ts b/backend/src/controllers/metadata.controller.ts new file mode 100644 index 0000000..6248837 --- /dev/null +++ b/backend/src/controllers/metadata.controller.ts @@ -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(); + } +} diff --git a/backend/src/controllers/sql.controller.ts b/backend/src/controllers/sql.controller.ts new file mode 100644 index 0000000..cb5f58a --- /dev/null +++ b/backend/src/controllers/sql.controller.ts @@ -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)); + } +} diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts new file mode 100644 index 0000000..84fa245 --- /dev/null +++ b/backend/src/db/pool.ts @@ -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 +}); diff --git a/backend/src/db/session-store.ts b/backend/src/db/session-store.ts new file mode 100644 index 0000000..3d3cb75 --- /dev/null +++ b/backend/src/db/session-store.ts @@ -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 +}); diff --git a/backend/src/middleware/admin.ts b/backend/src/middleware/admin.ts new file mode 100644 index 0000000..a023b7b --- /dev/null +++ b/backend/src/middleware/admin.ts @@ -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); + } +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..a4038a1 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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(); +} diff --git a/backend/src/middleware/error-handler.ts b/backend/src/middleware/error-handler.ts new file mode 100644 index 0000000..d5e79bd --- /dev/null +++ b/backend/src/middleware/error-handler.ts @@ -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" }); +} diff --git a/backend/src/middleware/request-context.ts b/backend/src/middleware/request-context.ts new file mode 100644 index 0000000..34f5012 --- /dev/null +++ b/backend/src/middleware/request-context.ts @@ -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(); +} diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts new file mode 100644 index 0000000..4c078f9 --- /dev/null +++ b/backend/src/middleware/validate.ts @@ -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(); + }; +} diff --git a/backend/src/repositories/audit.repository.ts b/backend/src/repositories/audit.repository.ts new file mode 100644 index 0000000..3f002dd --- /dev/null +++ b/backend/src/repositories/audit.repository.ts @@ -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; + } +} diff --git a/backend/src/repositories/auth.repository.ts b/backend/src/repositories/auth.repository.ts new file mode 100644 index 0000000..88508ab --- /dev/null +++ b/backend/src/repositories/auth.repository.ts @@ -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 { + 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); + } +} diff --git a/backend/src/repositories/metadata.repository.ts b/backend/src/repositories/metadata.repository.ts new file mode 100644 index 0000000..16257c0 --- /dev/null +++ b/backend/src/repositories/metadata.repository.ts @@ -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; + } +} diff --git a/backend/src/repositories/rbac.repository.ts b/backend/src/repositories/rbac.repository.ts new file mode 100644 index 0000000..6548dc3 --- /dev/null +++ b/backend/src/repositories/rbac.repository.ts @@ -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; + } +} diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..66917e4 --- /dev/null +++ b/backend/src/routes/admin.routes.ts @@ -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; diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..fa88169 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -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; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts new file mode 100644 index 0000000..8291240 --- /dev/null +++ b/backend/src/routes/index.ts @@ -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; diff --git a/backend/src/routes/metadata.routes.ts b/backend/src/routes/metadata.routes.ts new file mode 100644 index 0000000..b79590a --- /dev/null +++ b/backend/src/routes/metadata.routes.ts @@ -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; diff --git a/backend/src/routes/sql.routes.ts b/backend/src/routes/sql.routes.ts new file mode 100644 index 0000000..92bb669 --- /dev/null +++ b/backend/src/routes/sql.routes.ts @@ -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; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..e8bf1f4 --- /dev/null +++ b/backend/src/server.ts @@ -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}`); +}); diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts new file mode 100644 index 0000000..d38ff77 --- /dev/null +++ b/backend/src/services/audit.service.ts @@ -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); + } +} diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts new file mode 100644 index 0000000..1f05942 --- /dev/null +++ b/backend/src/services/auth.service.ts @@ -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 + }; + } +} diff --git a/backend/src/services/logs.service.ts b/backend/src/services/logs.service.ts new file mode 100644 index 0000000..1d67d37 --- /dev/null +++ b/backend/src/services/logs.service.ts @@ -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); + } + } +} diff --git a/backend/src/services/metadata.service.ts b/backend/src/services/metadata.service.ts new file mode 100644 index 0000000..a16b6d6 --- /dev/null +++ b/backend/src/services/metadata.service.ts @@ -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) { + await rbacService.assertPermission(userId, tableName, "write"); + await this.mutateRow(userId, tableName, "insert", data); + } + + async updateRow(userId: string, tableName: string, id: string | number, data: Record) { + 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, + 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 + }); + } +} diff --git a/backend/src/services/rbac.service.ts b/backend/src/services/rbac.service.ts new file mode 100644 index 0000000..528d1f9 --- /dev/null +++ b/backend/src/services/rbac.service.ts @@ -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(); + } +} diff --git a/backend/src/services/sql-console.service.ts b/backend/src/services/sql-console.service.ts new file mode 100644 index 0000000..4f40be5 --- /dev/null +++ b/backend/src/services/sql-console.service.ts @@ -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 + }; + } +} diff --git a/backend/src/types/api.ts b/backend/src/types/api.ts new file mode 100644 index 0000000..6900aa8 --- /dev/null +++ b/backend/src/types/api.ts @@ -0,0 +1,7 @@ +export type PermissionAction = "read" | "write" | "delete" | "schema" | "execute"; + +export interface SessionUser { + id: string; + username: string; + roleCodes: string[]; +} diff --git a/backend/src/types/express-session.d.ts b/backend/src/types/express-session.d.ts new file mode 100644 index 0000000..84cd255 --- /dev/null +++ b/backend/src/types/express-session.d.ts @@ -0,0 +1,11 @@ +import "express-session"; + +declare module "express-session" { + interface SessionData { + user?: { + id: string; + username: string; + roleCodes: string[]; + }; + } +} diff --git a/backend/src/utils/async-handler.ts b/backend/src/utils/async-handler.ts new file mode 100644 index 0000000..bc20d99 --- /dev/null +++ b/backend/src/utils/async-handler.ts @@ -0,0 +1,9 @@ +import type { NextFunction, Request, Response } from "express"; + +export function asyncHandler( + handler: (request: Request, response: Response, next: NextFunction) => Promise +) { + return (request: Request, response: Response, next: NextFunction) => { + void handler(request, response, next).catch(next); + }; +} diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts new file mode 100644 index 0000000..335e0fb --- /dev/null +++ b/backend/src/utils/errors.ts @@ -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); + } +} diff --git a/backend/src/utils/identifiers.ts b/backend/src/utils/identifiers.ts new file mode 100644 index 0000000..e47f81a --- /dev/null +++ b/backend/src/utils/identifiers.ts @@ -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, "\"\"")}"`; +} diff --git a/backend/src/utils/sql-safety.ts b/backend/src/utils/sql-safety.ts new file mode 100644 index 0000000..53c288e --- /dev/null +++ b/backend/src/utils/sql-safety.ts @@ -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"; +} diff --git a/backend/src/validators/auth.validators.ts b/backend/src/validators/auth.validators.ts new file mode 100644 index 0000000..4411591 --- /dev/null +++ b/backend/src/validators/auth.validators.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + username: z.string().min(3), + password: z.string().min(8) +}); diff --git a/backend/src/validators/metadata.validators.ts b/backend/src/validators/metadata.validators.ts new file mode 100644 index 0000000..9d1a904 --- /dev/null +++ b/backend/src/validators/metadata.validators.ts @@ -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) +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..31d74ba --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": [ + "node" + ] + }, + "include": [ + "src" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ea7d4f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + postgres: + image: postgres:16 + container_name: pg-control-postgres + restart: unless-stopped + environment: + POSTGRES_DB: app_admin + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro + - ./infra/postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro + - ./infra/postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro + - postgres_logs:/var/log/postgresql + command: [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "hba_file=/etc/postgresql/pg_hba.conf" + ] + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: pg-control-backend + restart: unless-stopped + depends_on: + - postgres + env_file: + - ./backend/.env.example + environment: + NODE_ENV: production + PORT: 4000 + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app_admin + SESSION_SECRET: super-secret-change-me + POSTGRES_CONTAINER_NAME: pg-control-postgres + ALLOWED_ORIGIN: http://localhost:5173 + ports: + - "4000:4000" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: pg-control-frontend + restart: unless-stopped + depends_on: + - backend + environment: + VITE_API_BASE_URL: http://localhost:4000/api + ports: + - "5173:80" + +volumes: + postgres_data: + postgres_logs: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..aff5955 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,102 @@ +# Architecture + +## Goals + +- Production-oriented structure instead of a demo-only MVP +- Strict backend authorization for every data/schema action +- Safe SQL execution with explicit policy checks +- Clean separation between UI, API, and persistence concerns +- Deployable through Docker Compose and extensible toward Kubernetes + +## High-level design + +### Backend + +Layered Node.js/Express application: + +- `routes` expose HTTP endpoints +- `controllers` translate HTTP into service calls +- `services` contain business logic and authorization-aware workflows +- `repositories` talk to PostgreSQL metadata/business tables +- `middleware` handles auth, sessions, RBAC context, validation, errors +- `db` contains application pool and SQL bootstrap migrations + +Two PostgreSQL access modes are used: + +1. Application database access for auth, RBAC, audit, and metadata. +2. Controlled administrative SQL access for schema/data operations against managed tables. + +### Frontend + +SPA with modular vanilla JavaScript: + +- `pages` assemble route-level screens +- `components` provide reusable UI blocks +- `api` isolates HTTP communication +- `styles` keeps tokens/layout/components separated + +This keeps the UI light while preserving clean boundaries. It can later be migrated to React/Vue without backend changes. + +## Request flow + +1. Session-authenticated user calls API. +2. Auth middleware loads session user. +3. Permission middleware resolves table group and required action. +4. Service validates identifiers and allowed SQL patterns. +5. Repository or admin-query utility executes parameterized SQL. +6. Audit service stores action metadata and outcome. +7. Structured response is returned to frontend. + +## RBAC model + +Core entities: + +- `users` +- `roles` +- `permissions` +- `role_permissions` +- `user_roles` +- `table_groups` +- `table_group_tables` + +Permission key shape: + +- resource: `group`, `table`, `sql_console`, `logs`, `users`, `roles`, `audit` +- action: `read`, `write`, `delete`, `schema`, `execute` + +Built-in model: + +- `root`: unrestricted +- `group_admin`: scoped by assigned table groups, can manage schema/data per granted actions +- `viewer` / `editor`: least-privilege table access + +## Security model + +- `express-session` with secure cookie settings +- password hashing with `bcrypt` +- `helmet`, CORS allowlist, request size limits +- `zod` validation for request payloads +- identifier allowlisting and quoting for schema/table/column names +- parameterized queries for data paths +- SQL console denylist for dangerous statements and optional read-only mode by role +- audit log for auth, SQL, DML, DDL +- rate limiting for auth and console routes + +## Scalability + +- Stateless API except for shared session store abstraction +- Service/repository boundaries allow splitting modules later +- Docker-ready and twelve-factor env configuration +- Easy switch from in-memory session store to Redis/Postgres-backed store +- API can be horizontally scaled behind a reverse proxy + +## Production improvements to add next + +- Redis session store +- background jobs for heavy exports/imports +- row-level policies / policy engine +- WebSocket query progress / tailing logs +- metrics (`/metrics`) with Prometheus +- OpenTelemetry tracing +- optimistic UI with saved query tabs +- soft approvals for risky DDL actions diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e37429f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json package.json +COPY vite.config.js vite.config.js +RUN npm install + +COPY public public +COPY src src +COPY index.html index.html +RUN npm run build + +FROM nginx:1.27-alpine + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7a97482 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PostgreSQL Control Center + + + +
+ + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..65636ed --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pg-control/frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": {}, + "devDependencies": { + "vite": "^6.0.1" + } +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..3d0ab7f --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,42 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api"; + +async function request(path, options = {}) { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(options.headers || {}) + }, + ...options + }); + + if (response.status === 204) { + return null; + } + + const payload = await response.json(); + + if (!response.ok) { + throw new Error(payload.error || "Request failed"); + } + + return payload; +} + +export const api = { + me: () => request("/auth/me"), + login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }), + logout: () => request("/auth/logout", { method: "POST" }), + tables: () => request("/db/tables"), + tableDetails: (tableName) => request(`/db/tables/${tableName}/details`), + rows: (tableName, query = {}) => { + const params = new URLSearchParams(query).toString(); + return request(`/db/tables/${tableName}/rows${params ? `?${params}` : ""}`); + }, + executeSql: (sql) => request("/sql/execute", { method: "POST", body: JSON.stringify({ sql }) }), + users: () => request("/admin/users"), + roles: () => request("/admin/roles"), + audit: (search = "") => request(`/admin/audit${search ? `?search=${encodeURIComponent(search)}` : ""}`), + postgresLogs: (search = "") => + request(`/admin/postgres-logs${search ? `?search=${encodeURIComponent(search)}` : ""}`) +}; diff --git a/frontend/src/components/shell.js b/frontend/src/components/shell.js new file mode 100644 index 0000000..d9e622a --- /dev/null +++ b/frontend/src/components/shell.js @@ -0,0 +1,311 @@ +export function renderAppShell(state) { + const groups = groupTables(state.tables); + const activeTable = state.activeTable ? state.tables.find((table) => table.table_name === state.activeTable) : null; + + return ` +
+ +
+
+
+
Сессия
+
${state.user.username} · ${state.user.roleCodes.join(", ")}
+
+
+ ${activeTable ? `
${activeTable.table_group} / ${activeTable.table_name}
` : ""} + +
+
+
+ ${renderPageContent(state)} +
+
+
+ `; +} + +function groupTables(tables) { + const map = new Map(); + tables.forEach((table) => { + if (!map.has(table.table_group)) { + map.set(table.table_group, { + name: table.table_group, + tables: [] + }); + } + + map.get(table.table_group).tables.push(table); + }); + + return [...map.values()]; +} + +function renderPageContent(state) { + if (state.page === "users") { + return renderUsers(state.users); + } + + if (state.page === "roles") { + return renderRoles(state.roles); + } + + if (state.page === "audit") { + return renderAudit(state.auditLogs); + } + + if (state.page === "logs") { + return renderLogViewer(state.postgresLogs); + } + + return renderOverview(state); +} + +function renderOverview(state) { + return ` +
+
+ Production-oriented panel +

Управление PostgreSQL с RBAC, аудитом и SQL console.

+
+
+
${state.tables.length}Таблиц
+
${state.auditLogs.length}Записей аудита
+
${state.postgresLogs.length}Лог-строк
+
+
+
+ ${[ + ["data", "Данные"], + ["structure", "Структура"], + ["sql", "SQL Console"], + ["indexes", "Индексы"] + ] + .map( + ([tab, label]) => ` + + ` + ) + .join("")} +
+
+
+ ${renderActiveTab(state)} +
+
+

Быстрые действия

+ + + +
+
+ `; +} + +function renderActiveTab(state) { + if (!state.activeTable) { + return `
Выберите таблицу в левом меню, чтобы открыть данные и структуру.
`; + } + + if (state.activeTab === "structure") { + const columns = state.tableDetails.columns || []; + const fks = state.tableDetails.foreignKeys || []; + return ` +

Структура: ${state.activeTable}

+
+
+

Колонки

+
+ ${columns + .map( + (column) => ` +
+ ${column.column_name} + ${column.data_type} + ${column.is_nullable === "YES" ? "nullable" : "required"} +
+ ` + ) + .join("")} +
+
+
+

Foreign Keys

+
+ ${fks.length + ? fks + .map( + (fk) => ` +
+ ${fk.column_name} + ${fk.foreign_table_name}.${fk.foreign_column_name} +
+ ` + ) + .join("") + : `
Связи не найдены
`} +
+
+
+ `; + } + + if (state.activeTab === "sql") { + return ` +

SQL Console

+ +
+ +
+
+ ${state.sqlResult ? renderRowsTable(state.sqlResult.rows) : `
Результат появится после выполнения запроса.
`} +
+ `; + } + + if (state.activeTab === "indexes") { + return ` +

Индексы и оптимизация

+

+ В backend уже подготовлены endpoints для создания индексов. Следующий шаг для production: + рекомендации по индексам, explain plans и heatmaps по slow queries. +

+
+ Текущая реализация хранит индексные операции через backend и логирует их в аудит. +
+ `; + } + + return ` +
+

Данные: ${state.activeTable}

+ +
+ ${renderRowsTable(state.rows.rows || [])} + `; +} + +function renderRowsTable(rows) { + if (!rows.length) { + return `
Нет данных для отображения.
`; + } + + const columns = Object.keys(rows[0]); + return ` +
+ + + ${columns.map((column) => ``).join("")} + + + ${rows + .map( + (row) => ` + ${columns.map((column) => ``).join("")} + ` + ) + .join("")} + +
${column}
${formatCell(row[column])}
+
+ `; +} + +function formatCell(value) { + if (value === null || value === undefined) { + return `null`; + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); +} + +function renderUsers(users) { + return ` +
+

Управление пользователями

+

Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.

+
+ ${renderRowsTable(users)} + `; +} + +function renderRoles(roles) { + return ` +
+

Роли и доступы

+

RBAC хранится в PostgreSQL и поддерживает групповые права на таблицы.

+
+ ${renderRowsTable(roles)} + `; +} + +function renderAudit(logs) { + return ` +
+

Аудит

+

Логируются входы, SQL, изменения данных и схемы.

+
+ ${renderRowsTable(logs)} + `; +} + +function renderLogViewer(logs) { + return ` +
+

Логи PostgreSQL

+

Чтение контейнерных логов для диагностики и мониторинга.

+
+
+ ${logs.map((line) => `
${line}
`).join("")} +
+ `; +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..a1ddcd5 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,176 @@ +import { api } from "./api/client.js"; +import { renderAppShell } from "./components/shell.js"; +import { renderLoginPage } from "./pages/login.js"; +import "./styles/main.css"; + +const state = { + user: null, + error: "", + tables: [], + activeTable: "", + activeTab: "data", + page: "overview", + rows: { rows: [], total: 0 }, + tableDetails: { columns: [], foreignKeys: [] }, + users: [], + roles: [], + auditLogs: [], + postgresLogs: [], + sqlDraft: "", + sqlResult: null, + search: "" +}; + +const app = document.querySelector("#app"); + +bootstrap(); + +async function bootstrap() { + try { + const { user } = await api.me(); + state.user = user; + + if (user) { + await hydrateDashboard(); + } + } catch { + state.user = null; + } + + render(); +} + +async function hydrateDashboard() { + const [tablesPayload, usersPayload, rolesPayload, auditPayload, logsPayload] = await Promise.all([ + api.tables(), + api.users(), + api.roles(), + api.audit(), + api.postgresLogs() + ]); + + state.tables = tablesPayload.tables; + state.users = usersPayload.users; + state.roles = rolesPayload.roles; + state.auditLogs = auditPayload.logs; + state.postgresLogs = logsPayload.logs; + + if (!state.activeTable && state.tables[0]) { + state.activeTable = state.tables[0].table_name; + } + + if (state.activeTable) { + await Promise.all([loadRows(), loadTableDetails()]); + } +} + +async function loadRows() { + if (!state.activeTable) { + return; + } + + state.rows = await api.rows(state.activeTable, { + page: 1, + pageSize: 25, + search: state.search + }); +} + +async function loadTableDetails() { + if (!state.activeTable) { + return; + } + + state.tableDetails = await api.tableDetails(state.activeTable); +} + +function render() { + app.innerHTML = state.user ? renderAppShell(state) : renderLoginPage(state); + bindEvents(); +} + +function bindEvents() { + const loginForm = document.querySelector("#loginForm"); + if (loginForm) { + loginForm.addEventListener("submit", handleLogin); + } + + const logoutButton = document.querySelector("#logoutButton"); + if (logoutButton) { + logoutButton.addEventListener("click", handleLogout); + } + + document.querySelectorAll("[data-page]").forEach((element) => { + element.addEventListener("click", async (event) => { + state.page = event.currentTarget.dataset.page; + render(); + }); + }); + + document.querySelectorAll("[data-tab]").forEach((element) => { + element.addEventListener("click", async (event) => { + state.activeTab = event.currentTarget.dataset.tab; + render(); + }); + }); + + document.querySelectorAll("[data-table]").forEach((element) => { + element.addEventListener("click", async (event) => { + state.activeTable = event.currentTarget.dataset.table; + state.page = "overview"; + state.sqlDraft = `select * from ${state.activeTable} limit 20;`; + await Promise.all([loadRows(), loadTableDetails()]); + render(); + }); + }); + + document.querySelectorAll("[data-action]").forEach((element) => { + element.addEventListener("click", handleAction); + }); +} + +async function handleLogin(event) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + state.error = ""; + + try { + const payload = await api.login({ + username: formData.get("username"), + password: formData.get("password") + }); + state.user = payload.user; + await hydrateDashboard(); + } catch (error) { + state.error = error.message; + } + + render(); +} + +async function handleLogout() { + await api.logout(); + state.user = null; + state.error = ""; + render(); +} + +async function handleAction(event) { + const action = event.currentTarget.dataset.action; + + if (action === "refresh") { + await hydrateDashboard(); + } + + if (action === "search") { + state.search = document.querySelector("#tableSearchInput")?.value || ""; + await loadRows(); + } + + if (action === "run-sql") { + state.sqlDraft = document.querySelector("#sqlEditor")?.value || ""; + state.sqlResult = await api.executeSql(state.sqlDraft); + } + + render(); +} diff --git a/frontend/src/pages/login.js b/frontend/src/pages/login.js new file mode 100644 index 0000000..6649d1e --- /dev/null +++ b/frontend/src/pages/login.js @@ -0,0 +1,27 @@ +export function renderLoginPage(state) { + return ` + + `; +} diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..81a1c41 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,351 @@ +:root { + --bg: #f3efe6; + --bg-strong: #e1d6c4; + --panel: rgba(255, 250, 242, 0.86); + --panel-strong: #fff8ec; + --ink: #1f2a24; + --ink-muted: #5b665e; + --accent: #0c6a5b; + --accent-strong: #12453d; + --accent-soft: #c7e3da; + --border: rgba(31, 42, 36, 0.12); + --danger: #8e3b35; + --shadow: 0 20px 50px rgba(35, 34, 28, 0.12); + --radius: 24px; + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(12, 106, 91, 0.18), transparent 32%), + linear-gradient(135deg, #f6f2e8, #e8efe5 58%, #e4ddd0); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +button, +input, +textarea { + font: inherit; +} + +#app { + min-height: 100vh; +} + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.login-panel { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 24px; + width: min(1100px, 100%); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 32px; + padding: 32px; + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.layout-shell { + display: grid; + grid-template-columns: 320px 1fr; + min-height: 100vh; +} + +.sidebar { + padding: 28px; + background: rgba(25, 42, 35, 0.95); + color: #f8efe0; +} + +.brand-block h1, +.hero-card h2, +.login-copy h1 { + margin: 8px 0 12px; + font-family: Georgia, "Times New Roman", serif; + line-height: 1.05; +} + +.brand-kicker, +.hero-kicker, +.eyebrow, +.sidebar-caption { + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + color: var(--ink-muted); +} + +.sidebar-caption, +.brand-kicker { + color: rgba(248, 239, 224, 0.72); +} + +.sidebar-section { + margin-top: 28px; +} + +.sidebar-group { + margin-top: 16px; +} + +.group-title { + font-size: 13px; + color: rgba(248, 239, 224, 0.72); + margin-bottom: 8px; +} + +.nav-table, +.nav-link, +.tab-button, +.primary-button, +.secondary-button, +.ghost-button { + border: 0; + cursor: pointer; + transition: 180ms ease; +} + +.nav-table, +.nav-link { + width: 100%; + text-align: left; + color: inherit; + background: transparent; + border-radius: 14px; + padding: 11px 14px; + margin-bottom: 6px; +} + +.nav-table:hover, +.nav-link:hover, +.nav-table.is-active, +.nav-link.is-active { + background: rgba(255, 248, 236, 0.12); +} + +.main-panel { + padding: 24px; +} + +.topbar, +.hero-card, +.workspace-card, +.login-form, +.login-copy { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.topbar { + padding: 20px 22px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.topbar-actions, +.form-actions, +.search-box { + display: flex; + align-items: center; + gap: 12px; +} + +.content-panel { + padding-top: 22px; +} + +.hero-card { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 26px; +} + +.hero-stats { + display: flex; + gap: 20px; +} + +.hero-stats div { + display: grid; + gap: 6px; +} + +.hero-stats strong { + font-size: 32px; + font-family: Georgia, "Times New Roman", serif; +} + +.tab-strip { + display: flex; + gap: 10px; + margin: 18px 0; +} + +.tab-button { + padding: 10px 16px; + border-radius: 999px; + background: rgba(255, 248, 236, 0.7); +} + +.tab-button.is-active, +.primary-button { + background: var(--accent); + color: white; +} + +.secondary-button, +.ghost-button { + background: transparent; + border: 1px solid var(--border); +} + +.workspace-grid { + display: grid; + grid-template-columns: 1.9fr 0.8fr; + gap: 20px; +} + +.workspace-card, +.login-copy, +.login-form { + padding: 24px; +} + +.section-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-bottom: 18px; +} + +.mini-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} + +.data-list, +.log-viewer { + display: grid; + gap: 10px; +} + +.data-list-item, +.log-line, +.info-panel, +.table-chip, +.user-badge { + padding: 12px 14px; + border-radius: 16px; + background: var(--panel-strong); + border: 1px solid var(--border); +} + +.data-list-item { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.table-wrapper { + overflow: auto; + border-radius: 18px; + border: 1px solid var(--border); +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +th { + background: rgba(12, 106, 91, 0.08); +} + +.sql-editor, +.login-form input, +.search-box input { + width: 100%; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid var(--border); + background: #fffdf8; +} + +.sql-editor { + min-height: 220px; + resize: vertical; + margin-bottom: 14px; +} + +.login-form { + display: grid; + gap: 18px; +} + +.login-form label { + display: grid; + gap: 8px; +} + +.muted, +.empty-inline, +.cell-null { + color: var(--ink-muted); +} + +.empty-state { + padding: 28px; + border-radius: 20px; + background: rgba(255, 248, 236, 0.5); + border: 1px dashed var(--border); +} + +.error-banner { + padding: 12px 14px; + border-radius: 14px; + background: rgba(142, 59, 53, 0.12); + color: var(--danger); +} + +@media (max-width: 1100px) { + .layout-shell, + .login-panel, + .workspace-grid, + .mini-grid { + grid-template-columns: 1fr; + } + + .hero-card, + .section-header, + .topbar { + flex-direction: column; + align-items: stretch; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..9c17e9b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + host: "0.0.0.0", + port: 5173 + } +}); diff --git a/infra/postgres/init.sql b/infra/postgres/init.sql new file mode 100644 index 0000000..0c88b77 --- /dev/null +++ b/infra/postgres/init.sql @@ -0,0 +1,156 @@ +create extension if not exists "pgcrypto"; + +create table if not exists app_users ( + id uuid primary key default gen_random_uuid(), + username varchar(100) unique not null, + password_hash text not null, + is_active boolean not null default true, + created_at timestamptz not null default now() +); + +create table if not exists roles ( + id uuid primary key default gen_random_uuid(), + code varchar(100) unique not null, + name varchar(120) not null, + description text, + created_at timestamptz not null default now() +); + +create table if not exists permissions ( + id uuid primary key default gen_random_uuid(), + resource varchar(100) not null, + action varchar(50) not null, + unique(resource, action) +); + +create table if not exists user_roles ( + user_id uuid not null references app_users(id) on delete cascade, + role_id uuid not null references roles(id) on delete cascade, + primary key(user_id, role_id) +); + +create table if not exists table_groups ( + id uuid primary key default gen_random_uuid(), + code varchar(100) unique not null, + name varchar(120) not null, + description text +); + +create table if not exists table_group_tables ( + id uuid primary key default gen_random_uuid(), + table_group_id uuid not null references table_groups(id) on delete cascade, + table_name varchar(120) unique not null +); + +create table if not exists role_permissions ( + role_id uuid not null references roles(id) on delete cascade, + permission_id uuid not null references permissions(id) on delete cascade, + table_group_id uuid references table_groups(id) on delete cascade, + primary key(role_id, permission_id, table_group_id) +); + +create table if not exists audit_logs ( + id bigserial primary key, + user_id uuid references app_users(id) on delete set null, + action varchar(100) not null, + resource_type varchar(100) not null, + resource_name varchar(150), + sql_text text, + details jsonb not null default '{}'::jsonb, + success boolean not null default true, + created_at timestamptz not null default now() +); + +create table if not exists finance_entries ( + id bigserial primary key, + title varchar(120) not null, + amount numeric(12, 2) not null, + currency varchar(10) not null default 'USD', + created_at timestamptz not null default now() +); + +create table if not exists user_profiles ( + id bigserial primary key, + email varchar(190) not null unique, + full_name varchar(190) not null, + status varchar(50) not null default 'active', + created_at timestamptz not null default now() +); + +create table if not exists app_logs ( + id bigserial primary key, + level varchar(20) not null, + message text not null, + created_at timestamptz not null default now() +); + +insert into table_groups (code, name, description) +values + ('finance', 'Finance', 'Financial tables'), + ('users', 'Users', 'User domain tables'), + ('logs', 'Logs', 'Log and audit related tables') +on conflict (code) do nothing; + +insert into table_group_tables (table_group_id, table_name) +select tg.id, mapping.table_name +from table_groups tg +join ( + values + ('finance', 'finance_entries'), + ('users', 'user_profiles'), + ('logs', 'app_logs') +) as mapping(group_code, table_name) + on mapping.group_code = tg.code +on conflict (table_name) do nothing; + +insert into permissions (resource, action) +values + ('global', 'read'), + ('global', 'write'), + ('global', 'delete'), + ('global', 'schema'), + ('global', 'execute') +on conflict (resource, action) do nothing; + +insert into roles (code, name, description) +values + ('root', 'Root', 'Full access'), + ('group_admin', 'Group Admin', 'Admin of assigned table groups'), + ('editor', 'Editor', 'Read and write access'), + ('viewer', 'Viewer', 'Read-only access') +on conflict (code) do nothing; + +insert into app_users (username, password_hash) +values + ('root', crypt('ChangeMe123!', gen_salt('bf'))) +on conflict (username) do nothing; + +insert into user_roles (user_id, role_id) +select u.id, r.id +from app_users u +join roles r on r.code = 'root' +where u.username = 'root' +on conflict do nothing; + +insert into role_permissions (role_id, permission_id, table_group_id) +select r.id, p.id, null +from roles r +join permissions p on p.resource = 'global' +where r.code = 'root' +on conflict do nothing; + +insert into finance_entries (title, amount, currency) +values + ('Subscription revenue', 1500.00, 'USD'), + ('Cloud hosting', -420.00, 'USD'); + +insert into user_profiles (email, full_name, status) +values + ('root@example.com', 'Root Administrator', 'active'), + ('analyst@example.com', 'Finance Analyst', 'active') +on conflict (email) do nothing; + +insert into app_logs (level, message) +values + ('info', 'System initialized'), + ('warn', 'Background job delayed'); diff --git a/infra/postgres/pg_hba.conf b/infra/postgres/pg_hba.conf new file mode 100644 index 0000000..350e193 --- /dev/null +++ b/infra/postgres/pg_hba.conf @@ -0,0 +1,3 @@ +local all all trust +host all all 0.0.0.0/0 md5 +host all all ::/0 md5 diff --git a/infra/postgres/postgresql.conf b/infra/postgres/postgresql.conf new file mode 100644 index 0000000..84d8a96 --- /dev/null +++ b/infra/postgres/postgresql.conf @@ -0,0 +1,7 @@ +listen_addresses = '*' +logging_collector = on +log_directory = '/var/log/postgresql' +log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' +log_statement = 'ddl' +log_min_duration_statement = 500 +log_line_prefix = '%m [%p] %u@%d ' diff --git a/package.json b/package.json new file mode 100644 index 0000000..76eaadf --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "postgres-control-center", + "private": true, + "version": "1.0.0", + "workspaces": [ + "backend", + "frontend" + ], + "scripts": { + "dev": "npm run dev -w backend & npm run dev -w frontend", + "build": "npm run build -w backend && npm run build -w frontend", + "start": "npm run start -w backend" + } +}