123
This commit is contained in:
144
.dockerignore
Normal file
144
.dockerignore
Normal file
@@ -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
|
||||
65
.editorconfig
Normal file
65
.editorconfig
Normal file
@@ -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
|
||||
83
.gitattributes
vendored
Normal file
83
.gitattributes
vendored
Normal file
@@ -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
|
||||
166
.gitignore
vendored
Normal file
166
.gitignore
vendored
Normal file
@@ -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/
|
||||
55
.pre-commit-config.yaml
Normal file
55
.pre-commit-config.yaml
Normal file
@@ -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
|
||||
42
README.md
Normal file
42
README.md
Normal file
@@ -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.
|
||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@@ -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
|
||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -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"]
|
||||
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
55
backend/src/app.ts
Normal file
55
backend/src/app.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { env } from "./config/env.js";
|
||||
import { sessionStore } from "./db/session-store.js";
|
||||
import { attachRequestContext } from "./middleware/request-context.js";
|
||||
import { errorHandler } from "./middleware/error-handler.js";
|
||||
import routes from "./routes/index.js";
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.ALLOWED_ORIGIN,
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(
|
||||
session({
|
||||
store: sessionStore,
|
||||
secret: env.SESSION_SECRET,
|
||||
name: env.SESSION_NAME,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.COOKIE_SECURE || env.NODE_ENV === "production",
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
app.use(attachRequestContext);
|
||||
app.get("/health", (_request, response) => {
|
||||
response.json({ status: "ok" });
|
||||
});
|
||||
app.use("/api", routes);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
20
backend/src/config/env.ts
Normal file
20
backend/src/config/env.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().default(4000),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
SESSION_SECRET: z.string().min(12),
|
||||
SESSION_NAME: z.string().default("pgcc.sid"),
|
||||
ALLOWED_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
POSTGRES_CONTAINER_NAME: z.string().default("pg-control-postgres"),
|
||||
COOKIE_SECURE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === "true")
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
16
backend/src/constants/permissions.ts
Normal file
16
backend/src/constants/permissions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const SYSTEM_ROLE_CODES = {
|
||||
ROOT: "root",
|
||||
GROUP_ADMIN: "group_admin",
|
||||
EDITOR: "editor",
|
||||
VIEWER: "viewer"
|
||||
} as const;
|
||||
|
||||
export const AUDIT_ACTIONS = {
|
||||
LOGIN_SUCCESS: "login_success",
|
||||
LOGIN_FAILURE: "login_failure",
|
||||
SQL_EXECUTE: "sql_execute",
|
||||
DATA_MUTATION: "data_mutation",
|
||||
SCHEMA_CHANGE: "schema_change",
|
||||
USER_MANAGEMENT: "user_management",
|
||||
ROLE_MANAGEMENT: "role_management"
|
||||
} as const;
|
||||
26
backend/src/controllers/admin.controller.ts
Normal file
26
backend/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { AuditService } from "../services/audit.service.js";
|
||||
import { LogsService } from "../services/logs.service.js";
|
||||
import { RbacService } from "../services/rbac.service.js";
|
||||
|
||||
const rbacService = new RbacService();
|
||||
const auditService = new AuditService();
|
||||
const logsService = new LogsService();
|
||||
|
||||
export class AdminController {
|
||||
async users(_request: Request, response: Response) {
|
||||
response.json({ users: await rbacService.listUsers() });
|
||||
}
|
||||
|
||||
async roles(_request: Request, response: Response) {
|
||||
response.json({ roles: await rbacService.listRoles() });
|
||||
}
|
||||
|
||||
async audit(request: Request, response: Response) {
|
||||
response.json({ logs: await auditService.list(request.query.search?.toString()) });
|
||||
}
|
||||
|
||||
async postgresLogs(request: Request, response: Response) {
|
||||
response.json({ logs: await logsService.readLogs(request.query.search?.toString()) });
|
||||
}
|
||||
}
|
||||
22
backend/src/controllers/auth.controller.ts
Normal file
22
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthService } from "../services/auth.service.js";
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export class AuthController {
|
||||
async login(request: Request, response: Response) {
|
||||
const user = await authService.login(request.body.username, request.body.password);
|
||||
request.session.user = user;
|
||||
response.json({ user });
|
||||
}
|
||||
|
||||
async logout(request: Request, response: Response) {
|
||||
request.session.destroy(() => {
|
||||
response.status(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
async me(request: Request, response: Response) {
|
||||
response.json({ user: request.session.user ?? null });
|
||||
}
|
||||
}
|
||||
78
backend/src/controllers/metadata.controller.ts
Normal file
78
backend/src/controllers/metadata.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { MetadataService } from "../services/metadata.service.js";
|
||||
|
||||
const service = new MetadataService();
|
||||
|
||||
export class MetadataController {
|
||||
async listTables(_request: Request, response: Response) {
|
||||
response.json({ tables: await service.listTables() });
|
||||
}
|
||||
|
||||
async getTableDetails(request: Request, response: Response) {
|
||||
response.json(await service.getTableDetails(request.params.tableName));
|
||||
}
|
||||
|
||||
async listRows(request: Request, response: Response) {
|
||||
response.json(
|
||||
await service.listRows({
|
||||
userId: request.session.user!.id,
|
||||
tableName: request.params.tableName,
|
||||
page: Number(request.query.page ?? 1),
|
||||
pageSize: Number(request.query.pageSize ?? 25),
|
||||
search: request.query.search?.toString(),
|
||||
sortBy: request.query.sortBy?.toString(),
|
||||
sortDirection: request.query.sortDirection === "desc" ? "desc" : "asc"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async createTable(request: Request, response: Response) {
|
||||
await service.createTable(request.session.user!.id, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async deleteTable(request: Request, response: Response) {
|
||||
await service.deleteTable(request.session.user!.id, request.params.tableName);
|
||||
response.status(204).send();
|
||||
}
|
||||
|
||||
async addColumn(request: Request, response: Response) {
|
||||
await service.addColumn(request.session.user!.id, request.params.tableName, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async alterColumn(request: Request, response: Response) {
|
||||
await service.alterColumnType(
|
||||
request.session.user!.id,
|
||||
request.params.tableName,
|
||||
request.params.columnName,
|
||||
request.body.dataType
|
||||
);
|
||||
response.json({ success: true });
|
||||
}
|
||||
|
||||
async dropColumn(request: Request, response: Response) {
|
||||
await service.dropColumn(request.session.user!.id, request.params.tableName, request.params.columnName);
|
||||
response.status(204).send();
|
||||
}
|
||||
|
||||
async createIndex(request: Request, response: Response) {
|
||||
await service.createIndex(request.session.user!.id, request.params.tableName, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async createRow(request: Request, response: Response) {
|
||||
await service.createRow(request.session.user!.id, request.params.tableName, request.body);
|
||||
response.status(201).json({ success: true });
|
||||
}
|
||||
|
||||
async updateRow(request: Request, response: Response) {
|
||||
await service.updateRow(request.session.user!.id, request.params.tableName, request.params.id, request.body);
|
||||
response.json({ success: true });
|
||||
}
|
||||
|
||||
async deleteRow(request: Request, response: Response) {
|
||||
await service.deleteRow(request.session.user!.id, request.params.tableName, request.params.id);
|
||||
response.status(204).send();
|
||||
}
|
||||
}
|
||||
10
backend/src/controllers/sql.controller.ts
Normal file
10
backend/src/controllers/sql.controller.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { SqlConsoleService } from "../services/sql-console.service.js";
|
||||
|
||||
const service = new SqlConsoleService();
|
||||
|
||||
export class SqlController {
|
||||
async execute(request: Request, response: Response) {
|
||||
response.json(await service.execute(request.session.user!.id, request.body.sql));
|
||||
}
|
||||
}
|
||||
8
backend/src/db/pool.ts
Normal file
8
backend/src/db/pool.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import pg from "pg";
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: env.DATABASE_URL
|
||||
});
|
||||
11
backend/src/db/session-store.ts
Normal file
11
backend/src/db/session-store.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import session from "express-session";
|
||||
import connectPgSimple from "connect-pg-simple";
|
||||
import { pool } from "./pool.js";
|
||||
|
||||
const PgStore = connectPgSimple(session);
|
||||
|
||||
export const sessionStore = new PgStore({
|
||||
pool,
|
||||
tableName: "user_sessions",
|
||||
createTableIfMissing: true
|
||||
});
|
||||
17
backend/src/middleware/admin.ts
Normal file
17
backend/src/middleware/admin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { RbacService } from "../services/rbac.service.js";
|
||||
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
|
||||
|
||||
const rbacService = new RbacService();
|
||||
|
||||
export async function requireAdmin(request: Request, _response: Response, next: NextFunction) {
|
||||
try {
|
||||
await rbacService.assertAnyRole(request.session.user!.id, [
|
||||
SYSTEM_ROLE_CODES.ROOT,
|
||||
SYSTEM_ROLE_CODES.GROUP_ADMIN
|
||||
]);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
10
backend/src/middleware/auth.ts
Normal file
10
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { UnauthorizedError } from "../utils/errors.js";
|
||||
|
||||
export function requireAuth(request: Request, _response: Response, next: NextFunction) {
|
||||
if (!request.session.user) {
|
||||
return next(new UnauthorizedError());
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
10
backend/src/middleware/error-handler.ts
Normal file
10
backend/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { AppError } from "../utils/errors.js";
|
||||
|
||||
export function errorHandler(error: Error, _request: Request, response: Response, _next: NextFunction) {
|
||||
if (error instanceof AppError) {
|
||||
return response.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
|
||||
return response.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
6
backend/src/middleware/request-context.ts
Normal file
6
backend/src/middleware/request-context.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export function attachRequestContext(request: Request, _response: Response, next: NextFunction) {
|
||||
request.app.locals.requestStartedAt = new Date();
|
||||
next();
|
||||
}
|
||||
18
backend/src/middleware/validate.ts
Normal file
18
backend/src/middleware/validate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { ZodSchema } from "zod";
|
||||
import { AppError } from "../utils/errors.js";
|
||||
|
||||
export function validateBody(schema: ZodSchema) {
|
||||
return (request: Request, _response: Response, next: NextFunction) => {
|
||||
const result = schema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return next(
|
||||
new AppError(result.error.issues.map((issue: { message: string }) => issue.message).join(", "), 400)
|
||||
);
|
||||
}
|
||||
|
||||
request.body = result.data;
|
||||
return next();
|
||||
};
|
||||
}
|
||||
57
backend/src/repositories/audit.repository.ts
Normal file
57
backend/src/repositories/audit.repository.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PoolClient } from "pg";
|
||||
import { pool } from "../db/pool.js";
|
||||
|
||||
export interface AuditLogInput {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceName?: string | null;
|
||||
sqlText?: string | null;
|
||||
details?: unknown;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export class AuditRepository {
|
||||
async create(entry: AuditLogInput, client?: PoolClient) {
|
||||
const executor = client ?? pool;
|
||||
await executor.query(
|
||||
`
|
||||
insert into audit_logs (
|
||||
user_id,
|
||||
action,
|
||||
resource_type,
|
||||
resource_name,
|
||||
sql_text,
|
||||
details,
|
||||
success
|
||||
) values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
entry.userId ?? null,
|
||||
entry.action,
|
||||
entry.resourceType,
|
||||
entry.resourceName ?? null,
|
||||
entry.sqlText ?? null,
|
||||
JSON.stringify(entry.details ?? {}),
|
||||
entry.success
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async list(search?: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select a.id, a.action, a.resource_type, a.resource_name, a.sql_text, a.details, a.success, a.created_at,
|
||||
u.username
|
||||
from audit_logs a
|
||||
left join app_users u on u.id = a.user_id
|
||||
where ($1::text is null or a.action ilike $1 or a.resource_name ilike $1 or coalesce(u.username, '') ilike $1)
|
||||
order by a.created_at desc
|
||||
limit 200
|
||||
`,
|
||||
[search ? `%${search}%` : null]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
30
backend/src/repositories/auth.repository.ts
Normal file
30
backend/src/repositories/auth.repository.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
|
||||
export class AuthRepository {
|
||||
async findUserByUsername(username: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select id, username, password_hash, is_active
|
||||
from app_users
|
||||
where username = $1
|
||||
`,
|
||||
[username]
|
||||
);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async loadUserRoles(userId: string): Promise<string[]> {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select r.code
|
||||
from user_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return rows.map((row: { code: string }) => row.code);
|
||||
}
|
||||
}
|
||||
63
backend/src/repositories/metadata.repository.ts
Normal file
63
backend/src/repositories/metadata.repository.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
|
||||
export class MetadataRepository {
|
||||
async listTables() {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select
|
||||
t.table_name,
|
||||
coalesce(tgt.table_group_id::text, '') as table_group_id,
|
||||
coalesce(tg.name, 'ungrouped') as table_group,
|
||||
obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass) as description
|
||||
from information_schema.tables t
|
||||
left join table_group_tables tgt on tgt.table_name = t.table_name
|
||||
left join table_groups tg on tg.id = tgt.table_group_id
|
||||
where t.table_schema = 'public'
|
||||
and t.table_type = 'BASE TABLE'
|
||||
and t.table_name not in (
|
||||
'audit_logs', 'user_sessions', 'app_users', 'roles', 'permissions',
|
||||
'role_permissions', 'user_roles', 'table_groups', 'table_group_tables'
|
||||
)
|
||||
order by tg.name nulls last, t.table_name
|
||||
`
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listTableColumns(tableName: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select column_name, data_type, is_nullable, column_default
|
||||
from information_schema.columns
|
||||
where table_schema = 'public' and table_name = $1
|
||||
order by ordinal_position
|
||||
`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listForeignKeys(tableName: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select
|
||||
tc.constraint_name,
|
||||
kcu.column_name,
|
||||
ccu.table_name as foreign_table_name,
|
||||
ccu.column_name as foreign_column_name
|
||||
from information_schema.table_constraints tc
|
||||
join information_schema.key_column_usage kcu
|
||||
on tc.constraint_name = kcu.constraint_name
|
||||
join information_schema.constraint_column_usage ccu
|
||||
on ccu.constraint_name = tc.constraint_name
|
||||
where tc.constraint_type = 'FOREIGN KEY'
|
||||
and tc.table_name = $1
|
||||
`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
94
backend/src/repositories/rbac.repository.ts
Normal file
94
backend/src/repositories/rbac.repository.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
import type { PermissionAction } from "../types/api.js";
|
||||
|
||||
export class RbacRepository {
|
||||
async hasSystemRole(userId: string, roleCode: string) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from user_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = $1 and r.code = $2
|
||||
limit 1
|
||||
`,
|
||||
[userId, roleCode]
|
||||
);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async hasAnyRole(userId: string, roleCodes: string[]) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from user_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = $1 and r.code = any($2::text[])
|
||||
limit 1
|
||||
`,
|
||||
[userId, roleCodes]
|
||||
);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async hasTablePermission(userId: string, tableName: string, action: PermissionAction) {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from user_roles ur
|
||||
join role_permissions rp on rp.role_id = ur.role_id
|
||||
join permissions p on p.id = rp.permission_id
|
||||
left join table_groups tg on tg.id = rp.table_group_id
|
||||
left join table_group_tables tgt on tgt.table_group_id = tg.id
|
||||
where ur.user_id = $1
|
||||
and p.action = $2
|
||||
and (tgt.table_name = $3 or p.resource = 'global')
|
||||
limit 1
|
||||
`,
|
||||
[userId, action, tableName]
|
||||
);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select u.id, u.username, u.is_active, coalesce(array_agg(r.code) filter (where r.code is not null), '{}') as roles
|
||||
from app_users u
|
||||
left join user_roles ur on ur.user_id = u.id
|
||||
left join roles r on r.id = ur.role_id
|
||||
group by u.id
|
||||
order by u.username
|
||||
`
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
select r.id, r.code, r.name, r.description,
|
||||
coalesce(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'resource', p.resource,
|
||||
'action', p.action,
|
||||
'tableGroupId', rp.table_group_id
|
||||
)
|
||||
) filter (where p.id is not null),
|
||||
'[]'::json
|
||||
) as permissions
|
||||
from roles r
|
||||
left join role_permissions rp on rp.role_id = r.id
|
||||
left join permissions p on p.id = rp.permission_id
|
||||
group by r.id
|
||||
order by r.code
|
||||
`
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
18
backend/src/routes/admin.routes.ts
Normal file
18
backend/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from "express";
|
||||
import { AdminController } from "../controllers/admin.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { requireAdmin } from "../middleware/admin.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new AdminController();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.get("/users", asyncHandler(controller.users.bind(controller)));
|
||||
router.get("/roles", asyncHandler(controller.roles.bind(controller)));
|
||||
router.get("/audit", asyncHandler(controller.audit.bind(controller)));
|
||||
router.get("/postgres-logs", asyncHandler(controller.postgresLogs.bind(controller)));
|
||||
|
||||
export default router;
|
||||
14
backend/src/routes/auth.routes.ts
Normal file
14
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import { AuthController } from "../controllers/auth.controller.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
import { validateBody } from "../middleware/validate.js";
|
||||
import { loginSchema } from "../validators/auth.validators.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new AuthController();
|
||||
|
||||
router.post("/login", validateBody(loginSchema), asyncHandler(controller.login.bind(controller)));
|
||||
router.post("/logout", asyncHandler(controller.logout.bind(controller)));
|
||||
router.get("/me", asyncHandler(controller.me.bind(controller)));
|
||||
|
||||
export default router;
|
||||
14
backend/src/routes/index.ts
Normal file
14
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import authRoutes from "./auth.routes.js";
|
||||
import metadataRoutes from "./metadata.routes.js";
|
||||
import sqlRoutes from "./sql.routes.js";
|
||||
import adminRoutes from "./admin.routes.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use("/auth", authRoutes);
|
||||
router.use("/db", metadataRoutes);
|
||||
router.use("/sql", sqlRoutes);
|
||||
router.use("/admin", adminRoutes);
|
||||
|
||||
export default router;
|
||||
36
backend/src/routes/metadata.routes.ts
Normal file
36
backend/src/routes/metadata.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from "express";
|
||||
import { MetadataController } from "../controllers/metadata.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { validateBody } from "../middleware/validate.js";
|
||||
import {
|
||||
addColumnSchema,
|
||||
alterColumnSchema,
|
||||
createIndexSchema,
|
||||
createTableSchema,
|
||||
rowSchema
|
||||
} from "../validators/metadata.validators.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new MetadataController();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get("/tables", asyncHandler(controller.listTables.bind(controller)));
|
||||
router.get("/tables/:tableName/details", asyncHandler(controller.getTableDetails.bind(controller)));
|
||||
router.get("/tables/:tableName/rows", asyncHandler(controller.listRows.bind(controller)));
|
||||
router.post("/tables", validateBody(createTableSchema), asyncHandler(controller.createTable.bind(controller)));
|
||||
router.delete("/tables/:tableName", asyncHandler(controller.deleteTable.bind(controller)));
|
||||
router.post("/tables/:tableName/columns", validateBody(addColumnSchema), asyncHandler(controller.addColumn.bind(controller)));
|
||||
router.patch(
|
||||
"/tables/:tableName/columns/:columnName",
|
||||
validateBody(alterColumnSchema),
|
||||
asyncHandler(controller.alterColumn.bind(controller))
|
||||
);
|
||||
router.delete("/tables/:tableName/columns/:columnName", asyncHandler(controller.dropColumn.bind(controller)));
|
||||
router.post("/tables/:tableName/indexes", validateBody(createIndexSchema), asyncHandler(controller.createIndex.bind(controller)));
|
||||
router.post("/tables/:tableName/rows", validateBody(rowSchema), asyncHandler(controller.createRow.bind(controller)));
|
||||
router.put("/tables/:tableName/rows/:id", validateBody(rowSchema), asyncHandler(controller.updateRow.bind(controller)));
|
||||
router.delete("/tables/:tableName/rows/:id", asyncHandler(controller.deleteRow.bind(controller)));
|
||||
|
||||
export default router;
|
||||
24
backend/src/routes/sql.routes.ts
Normal file
24
backend/src/routes/sql.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { SqlController } from "../controllers/sql.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { validateBody } from "../middleware/validate.js";
|
||||
import { asyncHandler } from "../utils/async-handler.js";
|
||||
import { sqlExecuteSchema } from "../validators/metadata.validators.js";
|
||||
|
||||
const router = Router();
|
||||
const controller = new SqlController();
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
);
|
||||
|
||||
router.post("/execute", validateBody(sqlExecuteSchema), asyncHandler(controller.execute.bind(controller)));
|
||||
|
||||
export default router;
|
||||
8
backend/src/server.ts
Normal file
8
backend/src/server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "./app.js";
|
||||
import { env } from "./config/env.js";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
app.listen(env.PORT, () => {
|
||||
console.log(`Backend listening on port ${env.PORT}`);
|
||||
});
|
||||
14
backend/src/services/audit.service.ts
Normal file
14
backend/src/services/audit.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { AuditLogInput } from "../repositories/audit.repository.js";
|
||||
import { AuditRepository } from "../repositories/audit.repository.js";
|
||||
|
||||
const repository = new AuditRepository();
|
||||
|
||||
export class AuditService {
|
||||
async log(params: AuditLogInput) {
|
||||
await repository.create(params);
|
||||
}
|
||||
|
||||
async list(search?: string) {
|
||||
return repository.list(search);
|
||||
}
|
||||
}
|
||||
52
backend/src/services/auth.service.ts
Normal file
52
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||
import { AuthRepository } from "../repositories/auth.repository.js";
|
||||
import { AuditService } from "./audit.service.js";
|
||||
import { UnauthorizedError } from "../utils/errors.js";
|
||||
|
||||
const authRepository = new AuthRepository();
|
||||
const auditService = new AuditService();
|
||||
|
||||
export class AuthService {
|
||||
async login(username: string, password: string) {
|
||||
const user = await authRepository.findUserByUsername(username);
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
await auditService.log({
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILURE,
|
||||
resourceType: "auth",
|
||||
resourceName: username,
|
||||
success: false
|
||||
});
|
||||
throw new UnauthorizedError("Invalid credentials");
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
await auditService.log({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILURE,
|
||||
resourceType: "auth",
|
||||
resourceName: username,
|
||||
success: false
|
||||
});
|
||||
throw new UnauthorizedError("Invalid credentials");
|
||||
}
|
||||
|
||||
const roleCodes = await authRepository.loadUserRoles(user.id);
|
||||
|
||||
await auditService.log({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.LOGIN_SUCCESS,
|
||||
resourceType: "auth",
|
||||
resourceName: username,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roleCodes
|
||||
};
|
||||
}
|
||||
}
|
||||
26
backend/src/services/logs.service.ts
Normal file
26
backend/src/services/logs.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Docker from "dockerode";
|
||||
import { env } from "../config/env.js";
|
||||
import { AppError } from "../utils/errors.js";
|
||||
|
||||
const docker = new Docker({ socketPath: "//./pipe/docker_engine" });
|
||||
|
||||
export class LogsService {
|
||||
async readLogs(search?: string) {
|
||||
try {
|
||||
const container = docker.getContainer(env.POSTGRES_CONTAINER_NAME);
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: 300
|
||||
});
|
||||
|
||||
const content = logs.toString("utf-8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.filter((line: string) => (search ? line.toLowerCase().includes(search.toLowerCase()) : true));
|
||||
} catch {
|
||||
throw new AppError("Unable to read PostgreSQL container logs. Ensure Docker is available.", 503);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
backend/src/services/metadata.service.ts
Normal file
208
backend/src/services/metadata.service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||
import { MetadataRepository } from "../repositories/metadata.repository.js";
|
||||
import { assertIdentifier, quoteIdentifier } from "../utils/identifiers.js";
|
||||
import { AuditService } from "./audit.service.js";
|
||||
import { RbacService } from "./rbac.service.js";
|
||||
|
||||
const repository = new MetadataRepository();
|
||||
const auditService = new AuditService();
|
||||
const rbacService = new RbacService();
|
||||
|
||||
export class MetadataService {
|
||||
async listTables() {
|
||||
return repository.listTables();
|
||||
}
|
||||
|
||||
async getTableDetails(tableName: string) {
|
||||
assertIdentifier(tableName, "table name");
|
||||
const [columns, foreignKeys] = await Promise.all([
|
||||
repository.listTableColumns(tableName),
|
||||
repository.listForeignKeys(tableName)
|
||||
]);
|
||||
|
||||
return { columns, foreignKeys };
|
||||
}
|
||||
|
||||
async listRows(params: {
|
||||
userId: string;
|
||||
tableName: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: "asc" | "desc";
|
||||
}) {
|
||||
await rbacService.assertPermission(params.userId, params.tableName, "read");
|
||||
assertIdentifier(params.tableName, "table name");
|
||||
|
||||
const columns = await repository.listTableColumns(params.tableName);
|
||||
const searchableColumns = columns.map((column: { column_name: string }) => column.column_name);
|
||||
const offset = (params.page - 1) * params.pageSize;
|
||||
const orderColumn = params.sortBy ? quoteIdentifier(params.sortBy) : "\"id\"";
|
||||
const direction = params.sortDirection === "desc" ? "DESC" : "ASC";
|
||||
|
||||
const filterValues: unknown[] = [];
|
||||
let whereSql = "";
|
||||
|
||||
if (params.search && searchableColumns.length > 0) {
|
||||
filterValues.push(`%${params.search}%`);
|
||||
whereSql = `where ${searchableColumns
|
||||
.map((column: string) => `cast(${quoteIdentifier(column)} as text) ilike $1`)
|
||||
.join(" or ")}`;
|
||||
}
|
||||
|
||||
const rowValues = [...filterValues, params.pageSize, offset];
|
||||
|
||||
const rowsQuery = `
|
||||
select *
|
||||
from ${quoteIdentifier(params.tableName)}
|
||||
${whereSql}
|
||||
order by ${orderColumn} ${direction}
|
||||
limit $${rowValues.length - 1} offset $${rowValues.length}
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
select count(*)::int as total
|
||||
from ${quoteIdentifier(params.tableName)}
|
||||
${whereSql}
|
||||
`;
|
||||
|
||||
const [rowsResult, countResult] = await Promise.all([
|
||||
pool.query(rowsQuery, rowValues),
|
||||
pool.query(countQuery, filterValues)
|
||||
]);
|
||||
|
||||
return {
|
||||
rows: rowsResult.rows,
|
||||
total: countResult.rows[0]?.total ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) {
|
||||
await this.assertSchemaPermission(userId, payload.tableName);
|
||||
|
||||
const columnSql = payload.columns
|
||||
.map(
|
||||
(column: { name: string; type: string; nullable?: boolean }) =>
|
||||
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
await pool.query(`create table ${quoteIdentifier(payload.tableName)} (${columnSql})`);
|
||||
await this.logSchema(userId, payload.tableName, "create_table", payload);
|
||||
}
|
||||
|
||||
async deleteTable(userId: string, tableName: string) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(`drop table if exists ${quoteIdentifier(tableName)} cascade`);
|
||||
await this.logSchema(userId, tableName, "delete_table", {});
|
||||
}
|
||||
|
||||
async addColumn(userId: string, tableName: string, column: { name: string; type: string; nullable?: boolean }) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(
|
||||
`alter table ${quoteIdentifier(tableName)} add column ${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "add_column", column);
|
||||
}
|
||||
|
||||
async alterColumnType(userId: string, tableName: string, columnName: string, dataType: string) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(
|
||||
`alter table ${quoteIdentifier(tableName)} alter column ${quoteIdentifier(columnName)} type ${dataType}`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "alter_column_type", { columnName, dataType });
|
||||
}
|
||||
|
||||
async dropColumn(userId: string, tableName: string, columnName: string) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
await pool.query(
|
||||
`alter table ${quoteIdentifier(tableName)} drop column if exists ${quoteIdentifier(columnName)}`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "drop_column", { columnName });
|
||||
}
|
||||
|
||||
async createIndex(userId: string, tableName: string, payload: { indexName: string; columns: string[]; unique?: boolean }) {
|
||||
await this.assertSchemaPermission(userId, tableName);
|
||||
const columnsSql = payload.columns.map((column: string) => quoteIdentifier(column)).join(", ");
|
||||
await pool.query(
|
||||
`create ${payload.unique ? "unique " : ""}index ${quoteIdentifier(payload.indexName)} on ${quoteIdentifier(tableName)} (${columnsSql})`
|
||||
);
|
||||
await this.logSchema(userId, tableName, "create_index", payload);
|
||||
}
|
||||
|
||||
async createRow(userId: string, tableName: string, data: Record<string, unknown>) {
|
||||
await rbacService.assertPermission(userId, tableName, "write");
|
||||
await this.mutateRow(userId, tableName, "insert", data);
|
||||
}
|
||||
|
||||
async updateRow(userId: string, tableName: string, id: string | number, data: Record<string, unknown>) {
|
||||
await rbacService.assertPermission(userId, tableName, "write");
|
||||
await this.mutateRow(userId, tableName, "update", data, id);
|
||||
}
|
||||
|
||||
async deleteRow(userId: string, tableName: string, id: string | number) {
|
||||
await rbacService.assertPermission(userId, tableName, "delete");
|
||||
await pool.query(`delete from ${quoteIdentifier(tableName)} where id = $1`, [id]);
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.DATA_MUTATION,
|
||||
resourceType: "row",
|
||||
resourceName: `${tableName}:${id}`,
|
||||
details: { id, operation: "delete" },
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
private async mutateRow(
|
||||
userId: string,
|
||||
tableName: string,
|
||||
operation: "insert" | "update",
|
||||
data: Record<string, unknown>,
|
||||
id?: string | number
|
||||
) {
|
||||
assertIdentifier(tableName, "table name");
|
||||
const entries = Object.entries(data);
|
||||
const columns = entries.map(([column]: [string, unknown]) => quoteIdentifier(column));
|
||||
const values = entries.map(([, value]: [string, unknown]) => value);
|
||||
|
||||
if (operation === "insert") {
|
||||
const placeholders = values.map((_value: unknown, index: number) => `$${index + 1}`).join(", ");
|
||||
await pool.query(
|
||||
`insert into ${quoteIdentifier(tableName)} (${columns.join(", ")}) values (${placeholders})`,
|
||||
values
|
||||
);
|
||||
} else {
|
||||
const setSql = columns.map((column: string, index: number) => `${column} = $${index + 1}`).join(", ");
|
||||
await pool.query(
|
||||
`update ${quoteIdentifier(tableName)} set ${setSql} where id = $${values.length + 1}`,
|
||||
[...values, id]
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.DATA_MUTATION,
|
||||
resourceType: "row",
|
||||
resourceName: `${tableName}:${id ?? "new"}`,
|
||||
details: { operation, data },
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
private async assertSchemaPermission(userId: string, tableName: string) {
|
||||
await rbacService.assertPermission(userId, tableName, "schema");
|
||||
}
|
||||
|
||||
private async logSchema(userId: string, tableName: string, operation: string, details: unknown) {
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.SCHEMA_CHANGE,
|
||||
resourceType: "table",
|
||||
resourceName: tableName,
|
||||
details: { operation, ...((details as object) ?? {}) },
|
||||
success: true
|
||||
});
|
||||
}
|
||||
}
|
||||
35
backend/src/services/rbac.service.ts
Normal file
35
backend/src/services/rbac.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
|
||||
import { RbacRepository } from "../repositories/rbac.repository.js";
|
||||
import type { PermissionAction } from "../types/api.js";
|
||||
import { ForbiddenError } from "../utils/errors.js";
|
||||
|
||||
const repository = new RbacRepository();
|
||||
|
||||
export class RbacService {
|
||||
async assertPermission(userId: string, tableName: string, action: PermissionAction) {
|
||||
const isRoot = await repository.hasSystemRole(userId, SYSTEM_ROLE_CODES.ROOT);
|
||||
if (isRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await repository.hasTablePermission(userId, tableName, action);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenError(`Missing ${action} access to table ${tableName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async assertAnyRole(userId: string, roleCodes: string[]) {
|
||||
const allowed = await repository.hasAnyRole(userId, roleCodes);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenError("Administrative role required");
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
return repository.listUsers();
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
return repository.listRoles();
|
||||
}
|
||||
}
|
||||
43
backend/src/services/sql-console.service.ts
Normal file
43
backend/src/services/sql-console.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { pool } from "../db/pool.js";
|
||||
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||
import { AuditService } from "./audit.service.js";
|
||||
import { RbacService } from "./rbac.service.js";
|
||||
import { assertSafeSql, inferQueryAction } from "../utils/sql-safety.js";
|
||||
|
||||
const auditService = new AuditService();
|
||||
const rbacService = new RbacService();
|
||||
|
||||
export class SqlConsoleService {
|
||||
async execute(userId: string, sql: string) {
|
||||
assertSafeSql(sql);
|
||||
const action = inferQueryAction(sql);
|
||||
|
||||
if (action === "schema" || action === "execute") {
|
||||
await rbacService.assertPermission(userId, "audit_logs", "schema");
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await pool.query(sql);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
await auditService.log({
|
||||
userId,
|
||||
action: AUDIT_ACTIONS.SQL_EXECUTE,
|
||||
resourceType: "sql_console",
|
||||
sqlText: sql,
|
||||
details: { rowCount: result.rowCount, durationMs, action },
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
command: result.command,
|
||||
rowCount: result.rowCount,
|
||||
durationMs,
|
||||
fields: result.fields.map((field: { name: string; dataTypeID: number }) => ({
|
||||
name: field.name,
|
||||
dataTypeId: field.dataTypeID
|
||||
})),
|
||||
rows: result.rows
|
||||
};
|
||||
}
|
||||
}
|
||||
7
backend/src/types/api.ts
Normal file
7
backend/src/types/api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type PermissionAction = "read" | "write" | "delete" | "schema" | "execute";
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
roleCodes: string[];
|
||||
}
|
||||
11
backend/src/types/express-session.d.ts
vendored
Normal file
11
backend/src/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleCodes: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
9
backend/src/utils/async-handler.ts
Normal file
9
backend/src/utils/async-handler.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (request: Request, response: Response, next: NextFunction) => Promise<unknown>
|
||||
) {
|
||||
return (request: Request, response: Response, next: NextFunction) => {
|
||||
void handler(request, response, next).catch(next);
|
||||
};
|
||||
}
|
||||
20
backend/src/utils/errors.ts
Normal file
20
backend/src/utils/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class AppError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = "Forbidden") {
|
||||
super(message, 403);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = "Unauthorized") {
|
||||
super(message, 401);
|
||||
}
|
||||
}
|
||||
15
backend/src/utils/identifiers.ts
Normal file
15
backend/src/utils/identifiers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AppError } from "./errors.js";
|
||||
|
||||
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
export function assertIdentifier(value: string, label = "identifier"): string {
|
||||
if (!SAFE_IDENTIFIER.test(value)) {
|
||||
throw new AppError(`Invalid ${label}`, 400);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function quoteIdentifier(value: string): string {
|
||||
return `"${assertIdentifier(value).replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
40
backend/src/utils/sql-safety.ts
Normal file
40
backend/src/utils/sql-safety.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AppError } from "./errors.js";
|
||||
|
||||
const BLOCKED_PATTERNS = [
|
||||
/\bdrop\s+database\b/i,
|
||||
/\bdrop\s+role\b/i,
|
||||
/\balter\s+system\b/i,
|
||||
/\bcopy\s+.+\s+to\s+program\b/i,
|
||||
/\bgrant\s+superuser\b/i,
|
||||
/\btruncate\b/i
|
||||
];
|
||||
|
||||
export function assertSafeSql(sql: string) {
|
||||
for (const pattern of BLOCKED_PATTERNS) {
|
||||
if (pattern.test(sql)) {
|
||||
throw new AppError("Query contains a blocked operation", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function inferQueryAction(sql: string): "read" | "write" | "delete" | "schema" | "execute" {
|
||||
const normalized = sql.trim().toLowerCase();
|
||||
|
||||
if (normalized.startsWith("select") || normalized.startsWith("with")) {
|
||||
return "read";
|
||||
}
|
||||
|
||||
if (normalized.startsWith("insert") || normalized.startsWith("update")) {
|
||||
return "write";
|
||||
}
|
||||
|
||||
if (normalized.startsWith("delete")) {
|
||||
return "delete";
|
||||
}
|
||||
|
||||
if (normalized.startsWith("create") || normalized.startsWith("alter") || normalized.startsWith("drop")) {
|
||||
return "schema";
|
||||
}
|
||||
|
||||
return "execute";
|
||||
}
|
||||
6
backend/src/validators/auth.validators.ts
Normal file
6
backend/src/validators/auth.validators.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
password: z.string().min(8)
|
||||
});
|
||||
34
backend/src/validators/metadata.validators.ts
Normal file
34
backend/src/validators/metadata.validators.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createTableSchema = z.object({
|
||||
tableName: z.string().min(1),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
nullable: z.boolean().optional()
|
||||
})
|
||||
).min(1)
|
||||
});
|
||||
|
||||
export const addColumnSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
nullable: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const alterColumnSchema = z.object({
|
||||
dataType: z.string().min(1)
|
||||
});
|
||||
|
||||
export const createIndexSchema = z.object({
|
||||
indexName: z.string().min(1),
|
||||
columns: z.array(z.string().min(1)).min(1),
|
||||
unique: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const rowSchema = z.record(z.any());
|
||||
|
||||
export const sqlExecuteSchema = z.object({
|
||||
sql: z.string().min(1)
|
||||
});
|
||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -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:
|
||||
102
docs/architecture.md
Normal file
102
docs/architecture.md
Normal file
@@ -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
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -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
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PostgreSQL Control Center</title>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
42
frontend/src/api/client.js
Normal file
42
frontend/src/api/client.js
Normal file
@@ -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)}` : ""}`)
|
||||
};
|
||||
311
frontend/src/components/shell.js
Normal file
311
frontend/src/components/shell.js
Normal file
@@ -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 `
|
||||
<div class="layout-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<span class="brand-kicker">PostgreSQL Control Center</span>
|
||||
<h1>Database Admin</h1>
|
||||
<p>Управление схемой, данными, SQL и аудитом.</p>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-caption">Группы таблиц</div>
|
||||
${groups
|
||||
.map(
|
||||
(group) => `
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">${group.name}</div>
|
||||
${group.tables
|
||||
.map(
|
||||
(table) => `
|
||||
<button class="nav-table ${state.activeTable === table.table_name ? "is-active" : ""}" data-table="${table.table_name}">
|
||||
<span>${table.table_name}</span>
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</section>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-caption">Навигация</div>
|
||||
${[
|
||||
["overview", "Обзор"],
|
||||
["users", "Пользователи"],
|
||||
["roles", "Роли"],
|
||||
["audit", "Аудит"],
|
||||
["logs", "Логи PostgreSQL"]
|
||||
]
|
||||
.map(
|
||||
([id, label]) => `
|
||||
<button class="nav-link ${state.page === id ? "is-active" : ""}" data-page="${id}">${label}</button>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main-panel">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">Сессия</div>
|
||||
<div class="user-badge">${state.user.username} · ${state.user.roleCodes.join(", ")}</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
${activeTable ? `<div class="table-chip">${activeTable.table_group} / ${activeTable.table_name}</div>` : ""}
|
||||
<button id="logoutButton" class="ghost-button">Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="content-panel">
|
||||
${renderPageContent(state)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<span class="hero-kicker">Production-oriented panel</span>
|
||||
<h2>Управление PostgreSQL с RBAC, аудитом и SQL console.</h2>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div><strong>${state.tables.length}</strong><span>Таблиц</span></div>
|
||||
<div><strong>${state.auditLogs.length}</strong><span>Записей аудита</span></div>
|
||||
<div><strong>${state.postgresLogs.length}</strong><span>Лог-строк</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-strip">
|
||||
${[
|
||||
["data", "Данные"],
|
||||
["structure", "Структура"],
|
||||
["sql", "SQL Console"],
|
||||
["indexes", "Индексы"]
|
||||
]
|
||||
.map(
|
||||
([tab, label]) => `
|
||||
<button class="tab-button ${state.activeTab === tab ? "is-active" : ""}" data-tab="${tab}">${label}</button>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="workspace-grid">
|
||||
<section class="workspace-card">
|
||||
${renderActiveTab(state)}
|
||||
</section>
|
||||
<section class="workspace-card side-actions">
|
||||
<h3>Быстрые действия</h3>
|
||||
<button class="primary-button" data-action="refresh">Обновить данные</button>
|
||||
<button class="secondary-button" data-page="audit">Открыть аудит</button>
|
||||
<button class="secondary-button" data-page="logs">Открыть логи PostgreSQL</button>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActiveTab(state) {
|
||||
if (!state.activeTable) {
|
||||
return `<div class="empty-state">Выберите таблицу в левом меню, чтобы открыть данные и структуру.</div>`;
|
||||
}
|
||||
|
||||
if (state.activeTab === "structure") {
|
||||
const columns = state.tableDetails.columns || [];
|
||||
const fks = state.tableDetails.foreignKeys || [];
|
||||
return `
|
||||
<h3>Структура: ${state.activeTable}</h3>
|
||||
<div class="mini-grid">
|
||||
<div>
|
||||
<h4>Колонки</h4>
|
||||
<div class="data-list">
|
||||
${columns
|
||||
.map(
|
||||
(column) => `
|
||||
<div class="data-list-item">
|
||||
<strong>${column.column_name}</strong>
|
||||
<span>${column.data_type}</span>
|
||||
<span>${column.is_nullable === "YES" ? "nullable" : "required"}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Foreign Keys</h4>
|
||||
<div class="data-list">
|
||||
${fks.length
|
||||
? fks
|
||||
.map(
|
||||
(fk) => `
|
||||
<div class="data-list-item">
|
||||
<strong>${fk.column_name}</strong>
|
||||
<span>${fk.foreign_table_name}.${fk.foreign_column_name}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")
|
||||
: `<div class="empty-inline">Связи не найдены</div>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.activeTab === "sql") {
|
||||
return `
|
||||
<h3>SQL Console</h3>
|
||||
<textarea id="sqlEditor" class="sql-editor" placeholder="select * from ${state.activeTable} limit 20;">${state.sqlDraft}</textarea>
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" data-action="run-sql">Выполнить</button>
|
||||
</div>
|
||||
<div class="sql-result">
|
||||
${state.sqlResult ? renderRowsTable(state.sqlResult.rows) : `<div class="empty-inline">Результат появится после выполнения запроса.</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.activeTab === "indexes") {
|
||||
return `
|
||||
<h3>Индексы и оптимизация</h3>
|
||||
<p class="muted">
|
||||
В backend уже подготовлены endpoints для создания индексов. Следующий шаг для production:
|
||||
рекомендации по индексам, explain plans и heatmaps по slow queries.
|
||||
</p>
|
||||
<div class="info-panel">
|
||||
<span>Текущая реализация хранит индексные операции через backend и логирует их в аудит.</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h3>Данные: ${state.activeTable}</h3>
|
||||
<div class="search-box">
|
||||
<input id="tableSearchInput" value="${state.search || ""}" placeholder="Поиск по всем колонкам" />
|
||||
<button class="secondary-button" data-action="search">Искать</button>
|
||||
</div>
|
||||
</div>
|
||||
${renderRowsTable(state.rows.rows || [])}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRowsTable(rows) {
|
||||
if (!rows.length) {
|
||||
return `<div class="empty-state">Нет данных для отображения.</div>`;
|
||||
}
|
||||
|
||||
const columns = Object.keys(rows[0]);
|
||||
return `
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>${columns.map((column) => `<th>${column}</th>`).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}</tr>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatCell(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return `<span class="cell-null">null</span>`;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Управление пользователями</h2>
|
||||
<p class="muted">Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.</p>
|
||||
</div>
|
||||
${renderRowsTable(users)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRoles(roles) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Роли и доступы</h2>
|
||||
<p class="muted">RBAC хранится в PostgreSQL и поддерживает групповые права на таблицы.</p>
|
||||
</div>
|
||||
${renderRowsTable(roles)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAudit(logs) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Аудит</h2>
|
||||
<p class="muted">Логируются входы, SQL, изменения данных и схемы.</p>
|
||||
</div>
|
||||
${renderRowsTable(logs)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLogViewer(logs) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Логи PostgreSQL</h2>
|
||||
<p class="muted">Чтение контейнерных логов для диагностики и мониторинга.</p>
|
||||
</div>
|
||||
<div class="log-viewer">
|
||||
${logs.map((line) => `<div class="log-line">${line}</div>`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
176
frontend/src/main.js
Normal file
176
frontend/src/main.js
Normal file
@@ -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();
|
||||
}
|
||||
27
frontend/src/pages/login.js
Normal file
27
frontend/src/pages/login.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export function renderLoginPage(state) {
|
||||
return `
|
||||
<div class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-copy">
|
||||
<span class="hero-kicker">Secure Admin Access</span>
|
||||
<h1>Production-grade PostgreSQL admin panel</h1>
|
||||
<p>
|
||||
Session-based auth, RBAC по группам таблиц, аудит действий, безопасный SQL console и контейнерные логи.
|
||||
</p>
|
||||
</div>
|
||||
<form id="loginForm" class="login-form">
|
||||
<label>
|
||||
<span>Логин</span>
|
||||
<input name="username" value="root" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Пароль</span>
|
||||
<input name="password" type="password" value="ChangeMe123!" required />
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Войти</button>
|
||||
${state.error ? `<div class="error-banner">${state.error}</div>` : ""}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
351
frontend/src/styles/main.css
Normal file
351
frontend/src/styles/main.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
8
frontend/vite.config.js
Normal file
8
frontend/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
156
infra/postgres/init.sql
Normal file
156
infra/postgres/init.sql
Normal file
@@ -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');
|
||||
3
infra/postgres/pg_hba.conf
Normal file
3
infra/postgres/pg_hba.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
local all all trust
|
||||
host all all 0.0.0.0/0 md5
|
||||
host all all ::/0 md5
|
||||
7
infra/postgres/postgresql.conf
Normal file
7
infra/postgres/postgresql.conf
Normal file
@@ -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 '
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user