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

144
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,55 @@
import cors from "cors";
import express from "express";
import session from "express-session";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { env } from "./config/env.js";
import { sessionStore } from "./db/session-store.js";
import { attachRequestContext } from "./middleware/request-context.js";
import { errorHandler } from "./middleware/error-handler.js";
import routes from "./routes/index.js";
export function createApp() {
const app = express();
app.use(helmet());
app.use(
cors({
origin: env.ALLOWED_ORIGIN,
credentials: true
})
);
app.use(
rateLimit({
windowMs: 60_000,
max: 200,
standardHeaders: true,
legacyHeaders: false
})
);
app.use(express.json({ limit: "1mb" }));
app.use(
session({
store: sessionStore,
secret: env.SESSION_SECRET,
name: env.SESSION_NAME,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: env.COOKIE_SECURE || env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 8
}
})
);
app.use(attachRequestContext);
app.get("/health", (_request, response) => {
response.json({ status: "ok" });
});
app.use("/api", routes);
app.use(errorHandler);
return app;
}

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

@@ -0,0 +1,20 @@
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().default(4000),
DATABASE_URL: z.string().min(1),
SESSION_SECRET: z.string().min(12),
SESSION_NAME: z.string().default("pgcc.sid"),
ALLOWED_ORIGIN: z.string().default("http://localhost:5173"),
POSTGRES_CONTAINER_NAME: z.string().default("pg-control-postgres"),
COOKIE_SECURE: z
.string()
.optional()
.transform((value) => value === "true")
});
export const env = envSchema.parse(process.env);

View File

@@ -0,0 +1,16 @@
export const SYSTEM_ROLE_CODES = {
ROOT: "root",
GROUP_ADMIN: "group_admin",
EDITOR: "editor",
VIEWER: "viewer"
} as const;
export const AUDIT_ACTIONS = {
LOGIN_SUCCESS: "login_success",
LOGIN_FAILURE: "login_failure",
SQL_EXECUTE: "sql_execute",
DATA_MUTATION: "data_mutation",
SCHEMA_CHANGE: "schema_change",
USER_MANAGEMENT: "user_management",
ROLE_MANAGEMENT: "role_management"
} as const;

View File

@@ -0,0 +1,26 @@
import type { Request, Response } from "express";
import { AuditService } from "../services/audit.service.js";
import { LogsService } from "../services/logs.service.js";
import { RbacService } from "../services/rbac.service.js";
const rbacService = new RbacService();
const auditService = new AuditService();
const logsService = new LogsService();
export class AdminController {
async users(_request: Request, response: Response) {
response.json({ users: await rbacService.listUsers() });
}
async roles(_request: Request, response: Response) {
response.json({ roles: await rbacService.listRoles() });
}
async audit(request: Request, response: Response) {
response.json({ logs: await auditService.list(request.query.search?.toString()) });
}
async postgresLogs(request: Request, response: Response) {
response.json({ logs: await logsService.readLogs(request.query.search?.toString()) });
}
}

View File

@@ -0,0 +1,22 @@
import type { Request, Response } from "express";
import { AuthService } from "../services/auth.service.js";
const authService = new AuthService();
export class AuthController {
async login(request: Request, response: Response) {
const user = await authService.login(request.body.username, request.body.password);
request.session.user = user;
response.json({ user });
}
async logout(request: Request, response: Response) {
request.session.destroy(() => {
response.status(204).send();
});
}
async me(request: Request, response: Response) {
response.json({ user: request.session.user ?? null });
}
}

View File

@@ -0,0 +1,78 @@
import type { Request, Response } from "express";
import { MetadataService } from "../services/metadata.service.js";
const service = new MetadataService();
export class MetadataController {
async listTables(_request: Request, response: Response) {
response.json({ tables: await service.listTables() });
}
async getTableDetails(request: Request, response: Response) {
response.json(await service.getTableDetails(request.params.tableName));
}
async listRows(request: Request, response: Response) {
response.json(
await service.listRows({
userId: request.session.user!.id,
tableName: request.params.tableName,
page: Number(request.query.page ?? 1),
pageSize: Number(request.query.pageSize ?? 25),
search: request.query.search?.toString(),
sortBy: request.query.sortBy?.toString(),
sortDirection: request.query.sortDirection === "desc" ? "desc" : "asc"
})
);
}
async createTable(request: Request, response: Response) {
await service.createTable(request.session.user!.id, request.body);
response.status(201).json({ success: true });
}
async deleteTable(request: Request, response: Response) {
await service.deleteTable(request.session.user!.id, request.params.tableName);
response.status(204).send();
}
async addColumn(request: Request, response: Response) {
await service.addColumn(request.session.user!.id, request.params.tableName, request.body);
response.status(201).json({ success: true });
}
async alterColumn(request: Request, response: Response) {
await service.alterColumnType(
request.session.user!.id,
request.params.tableName,
request.params.columnName,
request.body.dataType
);
response.json({ success: true });
}
async dropColumn(request: Request, response: Response) {
await service.dropColumn(request.session.user!.id, request.params.tableName, request.params.columnName);
response.status(204).send();
}
async createIndex(request: Request, response: Response) {
await service.createIndex(request.session.user!.id, request.params.tableName, request.body);
response.status(201).json({ success: true });
}
async createRow(request: Request, response: Response) {
await service.createRow(request.session.user!.id, request.params.tableName, request.body);
response.status(201).json({ success: true });
}
async updateRow(request: Request, response: Response) {
await service.updateRow(request.session.user!.id, request.params.tableName, request.params.id, request.body);
response.json({ success: true });
}
async deleteRow(request: Request, response: Response) {
await service.deleteRow(request.session.user!.id, request.params.tableName, request.params.id);
response.status(204).send();
}
}

View File

@@ -0,0 +1,10 @@
import type { Request, Response } from "express";
import { SqlConsoleService } from "../services/sql-console.service.js";
const service = new SqlConsoleService();
export class SqlController {
async execute(request: Request, response: Response) {
response.json(await service.execute(request.session.user!.id, request.body.sql));
}
}

8
backend/src/db/pool.ts Normal file
View File

@@ -0,0 +1,8 @@
import pg from "pg";
import { env } from "../config/env.js";
const { Pool } = pg;
export const pool = new Pool({
connectionString: env.DATABASE_URL
});

View File

@@ -0,0 +1,11 @@
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import { pool } from "./pool.js";
const PgStore = connectPgSimple(session);
export const sessionStore = new PgStore({
pool,
tableName: "user_sessions",
createTableIfMissing: true
});

View File

@@ -0,0 +1,17 @@
import type { NextFunction, Request, Response } from "express";
import { RbacService } from "../services/rbac.service.js";
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
const rbacService = new RbacService();
export async function requireAdmin(request: Request, _response: Response, next: NextFunction) {
try {
await rbacService.assertAnyRole(request.session.user!.id, [
SYSTEM_ROLE_CODES.ROOT,
SYSTEM_ROLE_CODES.GROUP_ADMIN
]);
next();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,10 @@
import type { NextFunction, Request, Response } from "express";
import { UnauthorizedError } from "../utils/errors.js";
export function requireAuth(request: Request, _response: Response, next: NextFunction) {
if (!request.session.user) {
return next(new UnauthorizedError());
}
return next();
}

View File

@@ -0,0 +1,10 @@
import type { NextFunction, Request, Response } from "express";
import { AppError } from "../utils/errors.js";
export function errorHandler(error: Error, _request: Request, response: Response, _next: NextFunction) {
if (error instanceof AppError) {
return response.status(error.statusCode).json({ error: error.message });
}
return response.status(500).json({ error: "Internal server error" });
}

View File

@@ -0,0 +1,6 @@
import type { NextFunction, Request, Response } from "express";
export function attachRequestContext(request: Request, _response: Response, next: NextFunction) {
request.app.locals.requestStartedAt = new Date();
next();
}

View File

@@ -0,0 +1,18 @@
import type { NextFunction, Request, Response } from "express";
import type { ZodSchema } from "zod";
import { AppError } from "../utils/errors.js";
export function validateBody(schema: ZodSchema) {
return (request: Request, _response: Response, next: NextFunction) => {
const result = schema.safeParse(request.body);
if (!result.success) {
return next(
new AppError(result.error.issues.map((issue: { message: string }) => issue.message).join(", "), 400)
);
}
request.body = result.data;
return next();
};
}

View File

@@ -0,0 +1,57 @@
import type { PoolClient } from "pg";
import { pool } from "../db/pool.js";
export interface AuditLogInput {
userId?: string | null;
action: string;
resourceType: string;
resourceName?: string | null;
sqlText?: string | null;
details?: unknown;
success: boolean;
}
export class AuditRepository {
async create(entry: AuditLogInput, client?: PoolClient) {
const executor = client ?? pool;
await executor.query(
`
insert into audit_logs (
user_id,
action,
resource_type,
resource_name,
sql_text,
details,
success
) values ($1, $2, $3, $4, $5, $6::jsonb, $7)
`,
[
entry.userId ?? null,
entry.action,
entry.resourceType,
entry.resourceName ?? null,
entry.sqlText ?? null,
JSON.stringify(entry.details ?? {}),
entry.success
]
);
}
async list(search?: string) {
const { rows } = await pool.query(
`
select a.id, a.action, a.resource_type, a.resource_name, a.sql_text, a.details, a.success, a.created_at,
u.username
from audit_logs a
left join app_users u on u.id = a.user_id
where ($1::text is null or a.action ilike $1 or a.resource_name ilike $1 or coalesce(u.username, '') ilike $1)
order by a.created_at desc
limit 200
`,
[search ? `%${search}%` : null]
);
return rows;
}
}

View File

@@ -0,0 +1,30 @@
import { pool } from "../db/pool.js";
export class AuthRepository {
async findUserByUsername(username: string) {
const { rows } = await pool.query(
`
select id, username, password_hash, is_active
from app_users
where username = $1
`,
[username]
);
return rows[0] ?? null;
}
async loadUserRoles(userId: string): Promise<string[]> {
const { rows } = await pool.query(
`
select r.code
from user_roles ur
join roles r on r.id = ur.role_id
where ur.user_id = $1
`,
[userId]
);
return rows.map((row: { code: string }) => row.code);
}
}

View File

@@ -0,0 +1,63 @@
import { pool } from "../db/pool.js";
export class MetadataRepository {
async listTables() {
const { rows } = await pool.query(
`
select
t.table_name,
coalesce(tgt.table_group_id::text, '') as table_group_id,
coalesce(tg.name, 'ungrouped') as table_group,
obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass) as description
from information_schema.tables t
left join table_group_tables tgt on tgt.table_name = t.table_name
left join table_groups tg on tg.id = tgt.table_group_id
where t.table_schema = 'public'
and t.table_type = 'BASE TABLE'
and t.table_name not in (
'audit_logs', 'user_sessions', 'app_users', 'roles', 'permissions',
'role_permissions', 'user_roles', 'table_groups', 'table_group_tables'
)
order by tg.name nulls last, t.table_name
`
);
return rows;
}
async listTableColumns(tableName: string) {
const { rows } = await pool.query(
`
select column_name, data_type, is_nullable, column_default
from information_schema.columns
where table_schema = 'public' and table_name = $1
order by ordinal_position
`,
[tableName]
);
return rows;
}
async listForeignKeys(tableName: string) {
const { rows } = await pool.query(
`
select
tc.constraint_name,
kcu.column_name,
ccu.table_name as foreign_table_name,
ccu.column_name as foreign_column_name
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on tc.constraint_name = kcu.constraint_name
join information_schema.constraint_column_usage ccu
on ccu.constraint_name = tc.constraint_name
where tc.constraint_type = 'FOREIGN KEY'
and tc.table_name = $1
`,
[tableName]
);
return rows;
}
}

View File

@@ -0,0 +1,94 @@
import { pool } from "../db/pool.js";
import type { PermissionAction } from "../types/api.js";
export class RbacRepository {
async hasSystemRole(userId: string, roleCode: string) {
const { rows } = await pool.query(
`
select 1
from user_roles ur
join roles r on r.id = ur.role_id
where ur.user_id = $1 and r.code = $2
limit 1
`,
[userId, roleCode]
);
return rows.length > 0;
}
async hasAnyRole(userId: string, roleCodes: string[]) {
const { rows } = await pool.query(
`
select 1
from user_roles ur
join roles r on r.id = ur.role_id
where ur.user_id = $1 and r.code = any($2::text[])
limit 1
`,
[userId, roleCodes]
);
return rows.length > 0;
}
async hasTablePermission(userId: string, tableName: string, action: PermissionAction) {
const { rows } = await pool.query(
`
select 1
from user_roles ur
join role_permissions rp on rp.role_id = ur.role_id
join permissions p on p.id = rp.permission_id
left join table_groups tg on tg.id = rp.table_group_id
left join table_group_tables tgt on tgt.table_group_id = tg.id
where ur.user_id = $1
and p.action = $2
and (tgt.table_name = $3 or p.resource = 'global')
limit 1
`,
[userId, action, tableName]
);
return rows.length > 0;
}
async listUsers() {
const { rows } = await pool.query(
`
select u.id, u.username, u.is_active, coalesce(array_agg(r.code) filter (where r.code is not null), '{}') as roles
from app_users u
left join user_roles ur on ur.user_id = u.id
left join roles r on r.id = ur.role_id
group by u.id
order by u.username
`
);
return rows;
}
async listRoles() {
const { rows } = await pool.query(
`
select r.id, r.code, r.name, r.description,
coalesce(
json_agg(
json_build_object(
'resource', p.resource,
'action', p.action,
'tableGroupId', rp.table_group_id
)
) filter (where p.id is not null),
'[]'::json
) as permissions
from roles r
left join role_permissions rp on rp.role_id = r.id
left join permissions p on p.id = rp.permission_id
group by r.id
order by r.code
`
);
return rows;
}
}

View File

@@ -0,0 +1,18 @@
import { Router } from "express";
import { AdminController } from "../controllers/admin.controller.js";
import { requireAuth } from "../middleware/auth.js";
import { requireAdmin } from "../middleware/admin.js";
import { asyncHandler } from "../utils/async-handler.js";
const router = Router();
const controller = new AdminController();
router.use(requireAuth);
router.use(requireAdmin);
router.get("/users", asyncHandler(controller.users.bind(controller)));
router.get("/roles", asyncHandler(controller.roles.bind(controller)));
router.get("/audit", asyncHandler(controller.audit.bind(controller)));
router.get("/postgres-logs", asyncHandler(controller.postgresLogs.bind(controller)));
export default router;

View File

@@ -0,0 +1,14 @@
import { Router } from "express";
import { AuthController } from "../controllers/auth.controller.js";
import { asyncHandler } from "../utils/async-handler.js";
import { validateBody } from "../middleware/validate.js";
import { loginSchema } from "../validators/auth.validators.js";
const router = Router();
const controller = new AuthController();
router.post("/login", validateBody(loginSchema), asyncHandler(controller.login.bind(controller)));
router.post("/logout", asyncHandler(controller.logout.bind(controller)));
router.get("/me", asyncHandler(controller.me.bind(controller)));
export default router;

View File

@@ -0,0 +1,14 @@
import { Router } from "express";
import authRoutes from "./auth.routes.js";
import metadataRoutes from "./metadata.routes.js";
import sqlRoutes from "./sql.routes.js";
import adminRoutes from "./admin.routes.js";
const router = Router();
router.use("/auth", authRoutes);
router.use("/db", metadataRoutes);
router.use("/sql", sqlRoutes);
router.use("/admin", adminRoutes);
export default router;

View File

@@ -0,0 +1,36 @@
import { Router } from "express";
import { MetadataController } from "../controllers/metadata.controller.js";
import { requireAuth } from "../middleware/auth.js";
import { validateBody } from "../middleware/validate.js";
import {
addColumnSchema,
alterColumnSchema,
createIndexSchema,
createTableSchema,
rowSchema
} from "../validators/metadata.validators.js";
import { asyncHandler } from "../utils/async-handler.js";
const router = Router();
const controller = new MetadataController();
router.use(requireAuth);
router.get("/tables", asyncHandler(controller.listTables.bind(controller)));
router.get("/tables/:tableName/details", asyncHandler(controller.getTableDetails.bind(controller)));
router.get("/tables/:tableName/rows", asyncHandler(controller.listRows.bind(controller)));
router.post("/tables", validateBody(createTableSchema), asyncHandler(controller.createTable.bind(controller)));
router.delete("/tables/:tableName", asyncHandler(controller.deleteTable.bind(controller)));
router.post("/tables/:tableName/columns", validateBody(addColumnSchema), asyncHandler(controller.addColumn.bind(controller)));
router.patch(
"/tables/:tableName/columns/:columnName",
validateBody(alterColumnSchema),
asyncHandler(controller.alterColumn.bind(controller))
);
router.delete("/tables/:tableName/columns/:columnName", asyncHandler(controller.dropColumn.bind(controller)));
router.post("/tables/:tableName/indexes", validateBody(createIndexSchema), asyncHandler(controller.createIndex.bind(controller)));
router.post("/tables/:tableName/rows", validateBody(rowSchema), asyncHandler(controller.createRow.bind(controller)));
router.put("/tables/:tableName/rows/:id", validateBody(rowSchema), asyncHandler(controller.updateRow.bind(controller)));
router.delete("/tables/:tableName/rows/:id", asyncHandler(controller.deleteRow.bind(controller)));
export default router;

View File

@@ -0,0 +1,24 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { SqlController } from "../controllers/sql.controller.js";
import { requireAuth } from "../middleware/auth.js";
import { validateBody } from "../middleware/validate.js";
import { asyncHandler } from "../utils/async-handler.js";
import { sqlExecuteSchema } from "../validators/metadata.validators.js";
const router = Router();
const controller = new SqlController();
router.use(requireAuth);
router.use(
rateLimit({
windowMs: 60_000,
max: 20,
standardHeaders: true,
legacyHeaders: false
})
);
router.post("/execute", validateBody(sqlExecuteSchema), asyncHandler(controller.execute.bind(controller)));
export default router;

8
backend/src/server.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from "./app.js";
import { env } from "./config/env.js";
const app = createApp();
app.listen(env.PORT, () => {
console.log(`Backend listening on port ${env.PORT}`);
});

View File

@@ -0,0 +1,14 @@
import type { AuditLogInput } from "../repositories/audit.repository.js";
import { AuditRepository } from "../repositories/audit.repository.js";
const repository = new AuditRepository();
export class AuditService {
async log(params: AuditLogInput) {
await repository.create(params);
}
async list(search?: string) {
return repository.list(search);
}
}

View File

@@ -0,0 +1,52 @@
import bcrypt from "bcryptjs";
import { AUDIT_ACTIONS } from "../constants/permissions.js";
import { AuthRepository } from "../repositories/auth.repository.js";
import { AuditService } from "./audit.service.js";
import { UnauthorizedError } from "../utils/errors.js";
const authRepository = new AuthRepository();
const auditService = new AuditService();
export class AuthService {
async login(username: string, password: string) {
const user = await authRepository.findUserByUsername(username);
if (!user || !user.is_active) {
await auditService.log({
action: AUDIT_ACTIONS.LOGIN_FAILURE,
resourceType: "auth",
resourceName: username,
success: false
});
throw new UnauthorizedError("Invalid credentials");
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
await auditService.log({
userId: user.id,
action: AUDIT_ACTIONS.LOGIN_FAILURE,
resourceType: "auth",
resourceName: username,
success: false
});
throw new UnauthorizedError("Invalid credentials");
}
const roleCodes = await authRepository.loadUserRoles(user.id);
await auditService.log({
userId: user.id,
action: AUDIT_ACTIONS.LOGIN_SUCCESS,
resourceType: "auth",
resourceName: username,
success: true
});
return {
id: user.id,
username: user.username,
roleCodes
};
}
}

View File

@@ -0,0 +1,26 @@
import Docker from "dockerode";
import { env } from "../config/env.js";
import { AppError } from "../utils/errors.js";
const docker = new Docker({ socketPath: "//./pipe/docker_engine" });
export class LogsService {
async readLogs(search?: string) {
try {
const container = docker.getContainer(env.POSTGRES_CONTAINER_NAME);
const logs = await container.logs({
stdout: true,
stderr: true,
tail: 300
});
const content = logs.toString("utf-8");
return content
.split(/\r?\n/)
.filter(Boolean)
.filter((line: string) => (search ? line.toLowerCase().includes(search.toLowerCase()) : true));
} catch {
throw new AppError("Unable to read PostgreSQL container logs. Ensure Docker is available.", 503);
}
}
}

View File

@@ -0,0 +1,208 @@
import { pool } from "../db/pool.js";
import { AUDIT_ACTIONS } from "../constants/permissions.js";
import { MetadataRepository } from "../repositories/metadata.repository.js";
import { assertIdentifier, quoteIdentifier } from "../utils/identifiers.js";
import { AuditService } from "./audit.service.js";
import { RbacService } from "./rbac.service.js";
const repository = new MetadataRepository();
const auditService = new AuditService();
const rbacService = new RbacService();
export class MetadataService {
async listTables() {
return repository.listTables();
}
async getTableDetails(tableName: string) {
assertIdentifier(tableName, "table name");
const [columns, foreignKeys] = await Promise.all([
repository.listTableColumns(tableName),
repository.listForeignKeys(tableName)
]);
return { columns, foreignKeys };
}
async listRows(params: {
userId: string;
tableName: string;
page: number;
pageSize: number;
search?: string;
sortBy?: string;
sortDirection?: "asc" | "desc";
}) {
await rbacService.assertPermission(params.userId, params.tableName, "read");
assertIdentifier(params.tableName, "table name");
const columns = await repository.listTableColumns(params.tableName);
const searchableColumns = columns.map((column: { column_name: string }) => column.column_name);
const offset = (params.page - 1) * params.pageSize;
const orderColumn = params.sortBy ? quoteIdentifier(params.sortBy) : "\"id\"";
const direction = params.sortDirection === "desc" ? "DESC" : "ASC";
const filterValues: unknown[] = [];
let whereSql = "";
if (params.search && searchableColumns.length > 0) {
filterValues.push(`%${params.search}%`);
whereSql = `where ${searchableColumns
.map((column: string) => `cast(${quoteIdentifier(column)} as text) ilike $1`)
.join(" or ")}`;
}
const rowValues = [...filterValues, params.pageSize, offset];
const rowsQuery = `
select *
from ${quoteIdentifier(params.tableName)}
${whereSql}
order by ${orderColumn} ${direction}
limit $${rowValues.length - 1} offset $${rowValues.length}
`;
const countQuery = `
select count(*)::int as total
from ${quoteIdentifier(params.tableName)}
${whereSql}
`;
const [rowsResult, countResult] = await Promise.all([
pool.query(rowsQuery, rowValues),
pool.query(countQuery, filterValues)
]);
return {
rows: rowsResult.rows,
total: countResult.rows[0]?.total ?? 0
};
}
async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) {
await this.assertSchemaPermission(userId, payload.tableName);
const columnSql = payload.columns
.map(
(column: { name: string; type: string; nullable?: boolean }) =>
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
)
.join(", ");
await pool.query(`create table ${quoteIdentifier(payload.tableName)} (${columnSql})`);
await this.logSchema(userId, payload.tableName, "create_table", payload);
}
async deleteTable(userId: string, tableName: string) {
await this.assertSchemaPermission(userId, tableName);
await pool.query(`drop table if exists ${quoteIdentifier(tableName)} cascade`);
await this.logSchema(userId, tableName, "delete_table", {});
}
async addColumn(userId: string, tableName: string, column: { name: string; type: string; nullable?: boolean }) {
await this.assertSchemaPermission(userId, tableName);
await pool.query(
`alter table ${quoteIdentifier(tableName)} add column ${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
);
await this.logSchema(userId, tableName, "add_column", column);
}
async alterColumnType(userId: string, tableName: string, columnName: string, dataType: string) {
await this.assertSchemaPermission(userId, tableName);
await pool.query(
`alter table ${quoteIdentifier(tableName)} alter column ${quoteIdentifier(columnName)} type ${dataType}`
);
await this.logSchema(userId, tableName, "alter_column_type", { columnName, dataType });
}
async dropColumn(userId: string, tableName: string, columnName: string) {
await this.assertSchemaPermission(userId, tableName);
await pool.query(
`alter table ${quoteIdentifier(tableName)} drop column if exists ${quoteIdentifier(columnName)}`
);
await this.logSchema(userId, tableName, "drop_column", { columnName });
}
async createIndex(userId: string, tableName: string, payload: { indexName: string; columns: string[]; unique?: boolean }) {
await this.assertSchemaPermission(userId, tableName);
const columnsSql = payload.columns.map((column: string) => quoteIdentifier(column)).join(", ");
await pool.query(
`create ${payload.unique ? "unique " : ""}index ${quoteIdentifier(payload.indexName)} on ${quoteIdentifier(tableName)} (${columnsSql})`
);
await this.logSchema(userId, tableName, "create_index", payload);
}
async createRow(userId: string, tableName: string, data: Record<string, unknown>) {
await rbacService.assertPermission(userId, tableName, "write");
await this.mutateRow(userId, tableName, "insert", data);
}
async updateRow(userId: string, tableName: string, id: string | number, data: Record<string, unknown>) {
await rbacService.assertPermission(userId, tableName, "write");
await this.mutateRow(userId, tableName, "update", data, id);
}
async deleteRow(userId: string, tableName: string, id: string | number) {
await rbacService.assertPermission(userId, tableName, "delete");
await pool.query(`delete from ${quoteIdentifier(tableName)} where id = $1`, [id]);
await auditService.log({
userId,
action: AUDIT_ACTIONS.DATA_MUTATION,
resourceType: "row",
resourceName: `${tableName}:${id}`,
details: { id, operation: "delete" },
success: true
});
}
private async mutateRow(
userId: string,
tableName: string,
operation: "insert" | "update",
data: Record<string, unknown>,
id?: string | number
) {
assertIdentifier(tableName, "table name");
const entries = Object.entries(data);
const columns = entries.map(([column]: [string, unknown]) => quoteIdentifier(column));
const values = entries.map(([, value]: [string, unknown]) => value);
if (operation === "insert") {
const placeholders = values.map((_value: unknown, index: number) => `$${index + 1}`).join(", ");
await pool.query(
`insert into ${quoteIdentifier(tableName)} (${columns.join(", ")}) values (${placeholders})`,
values
);
} else {
const setSql = columns.map((column: string, index: number) => `${column} = $${index + 1}`).join(", ");
await pool.query(
`update ${quoteIdentifier(tableName)} set ${setSql} where id = $${values.length + 1}`,
[...values, id]
);
}
await auditService.log({
userId,
action: AUDIT_ACTIONS.DATA_MUTATION,
resourceType: "row",
resourceName: `${tableName}:${id ?? "new"}`,
details: { operation, data },
success: true
});
}
private async assertSchemaPermission(userId: string, tableName: string) {
await rbacService.assertPermission(userId, tableName, "schema");
}
private async logSchema(userId: string, tableName: string, operation: string, details: unknown) {
await auditService.log({
userId,
action: AUDIT_ACTIONS.SCHEMA_CHANGE,
resourceType: "table",
resourceName: tableName,
details: { operation, ...((details as object) ?? {}) },
success: true
});
}
}

View File

@@ -0,0 +1,35 @@
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
import { RbacRepository } from "../repositories/rbac.repository.js";
import type { PermissionAction } from "../types/api.js";
import { ForbiddenError } from "../utils/errors.js";
const repository = new RbacRepository();
export class RbacService {
async assertPermission(userId: string, tableName: string, action: PermissionAction) {
const isRoot = await repository.hasSystemRole(userId, SYSTEM_ROLE_CODES.ROOT);
if (isRoot) {
return;
}
const allowed = await repository.hasTablePermission(userId, tableName, action);
if (!allowed) {
throw new ForbiddenError(`Missing ${action} access to table ${tableName}`);
}
}
async assertAnyRole(userId: string, roleCodes: string[]) {
const allowed = await repository.hasAnyRole(userId, roleCodes);
if (!allowed) {
throw new ForbiddenError("Administrative role required");
}
}
async listUsers() {
return repository.listUsers();
}
async listRoles() {
return repository.listRoles();
}
}

View File

@@ -0,0 +1,43 @@
import { pool } from "../db/pool.js";
import { AUDIT_ACTIONS } from "../constants/permissions.js";
import { AuditService } from "./audit.service.js";
import { RbacService } from "./rbac.service.js";
import { assertSafeSql, inferQueryAction } from "../utils/sql-safety.js";
const auditService = new AuditService();
const rbacService = new RbacService();
export class SqlConsoleService {
async execute(userId: string, sql: string) {
assertSafeSql(sql);
const action = inferQueryAction(sql);
if (action === "schema" || action === "execute") {
await rbacService.assertPermission(userId, "audit_logs", "schema");
}
const startedAt = Date.now();
const result = await pool.query(sql);
const durationMs = Date.now() - startedAt;
await auditService.log({
userId,
action: AUDIT_ACTIONS.SQL_EXECUTE,
resourceType: "sql_console",
sqlText: sql,
details: { rowCount: result.rowCount, durationMs, action },
success: true
});
return {
command: result.command,
rowCount: result.rowCount,
durationMs,
fields: result.fields.map((field: { name: string; dataTypeID: number }) => ({
name: field.name,
dataTypeId: field.dataTypeID
})),
rows: result.rows
};
}
}

7
backend/src/types/api.ts Normal file
View File

@@ -0,0 +1,7 @@
export type PermissionAction = "read" | "write" | "delete" | "schema" | "execute";
export interface SessionUser {
id: string;
username: string;
roleCodes: string[];
}

11
backend/src/types/express-session.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,9 @@
import type { NextFunction, Request, Response } from "express";
export function asyncHandler(
handler: (request: Request, response: Response, next: NextFunction) => Promise<unknown>
) {
return (request: Request, response: Response, next: NextFunction) => {
void handler(request, response, next).catch(next);
};
}

View File

@@ -0,0 +1,20 @@
export class AppError extends Error {
statusCode: number;
constructor(message: string, statusCode = 400) {
super(message);
this.statusCode = statusCode;
}
}
export class ForbiddenError extends AppError {
constructor(message = "Forbidden") {
super(message, 403);
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Unauthorized") {
super(message, 401);
}
}

View File

@@ -0,0 +1,15 @@
import { AppError } from "./errors.js";
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
export function assertIdentifier(value: string, label = "identifier"): string {
if (!SAFE_IDENTIFIER.test(value)) {
throw new AppError(`Invalid ${label}`, 400);
}
return value;
}
export function quoteIdentifier(value: string): string {
return `"${assertIdentifier(value).replace(/"/g, "\"\"")}"`;
}

View File

@@ -0,0 +1,40 @@
import { AppError } from "./errors.js";
const BLOCKED_PATTERNS = [
/\bdrop\s+database\b/i,
/\bdrop\s+role\b/i,
/\balter\s+system\b/i,
/\bcopy\s+.+\s+to\s+program\b/i,
/\bgrant\s+superuser\b/i,
/\btruncate\b/i
];
export function assertSafeSql(sql: string) {
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(sql)) {
throw new AppError("Query contains a blocked operation", 400);
}
}
}
export function inferQueryAction(sql: string): "read" | "write" | "delete" | "schema" | "execute" {
const normalized = sql.trim().toLowerCase();
if (normalized.startsWith("select") || normalized.startsWith("with")) {
return "read";
}
if (normalized.startsWith("insert") || normalized.startsWith("update")) {
return "write";
}
if (normalized.startsWith("delete")) {
return "delete";
}
if (normalized.startsWith("create") || normalized.startsWith("alter") || normalized.startsWith("drop")) {
return "schema";
}
return "execute";
}

View File

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

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
export const createTableSchema = z.object({
tableName: z.string().min(1),
columns: z.array(
z.object({
name: z.string().min(1),
type: z.string().min(1),
nullable: z.boolean().optional()
})
).min(1)
});
export const addColumnSchema = z.object({
name: z.string().min(1),
type: z.string().min(1),
nullable: z.boolean().optional()
});
export const alterColumnSchema = z.object({
dataType: z.string().min(1)
});
export const createIndexSchema = z.object({
indexName: z.string().min(1),
columns: z.array(z.string().min(1)).min(1),
unique: z.boolean().optional()
});
export const rowSchema = z.record(z.any());
export const sqlExecuteSchema = z.object({
sql: z.string().min(1)
});

20
backend/tsconfig.json Normal file
View File

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

62
docker-compose.yml Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

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

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

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

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

View File

@@ -0,0 +1,3 @@
local all all trust
host all all 0.0.0.0/0 md5
host all all ::/0 md5

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