fix(sql): fix sql script
This commit is contained in:
99
scripts/db/README.md
Normal file
99
scripts/db/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 数据库脚本(版本化迁移)
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `migrations/`:按版本顺序的升级脚本(只增不改)
|
||||
- `rollback/`:与 migration 一一对应的回滚脚本(按版本号匹配)
|
||||
- `migrate.sh`:按顺序执行未应用的 migrations
|
||||
- `rollback.sh`:按版本回滚(需要显式确认)
|
||||
- `verify.sh`:执行校验脚本,确认表结构与种子数据满足要求
|
||||
- `reset.sh`:开发/测试用清库重置(会删除所有 IAM 表与迁移记录,需显式确认)
|
||||
- `rebuild_iam_db.sh`:开发环境一键重建(会清库重建,不适合生产)
|
||||
|
||||
## 版本与执行顺序
|
||||
|
||||
| 版本 | 脚本 | 说明 |
|
||||
|---|---|---|
|
||||
| 0001 | `migrations/0001_core.sql` | IAM 核心表(tenant/user/role/permission 等)与基础种子数据 |
|
||||
| 0002 | `migrations/0002_enabled_apps.sql` | enabled_apps(租户应用开通)、平台租户与平台权限(SuperAdmin) |
|
||||
|
||||
校验脚本映射(与 migrations 一一对应):
|
||||
|
||||
| 版本 | 校验脚本 | 说明 |
|
||||
|---|---|---|
|
||||
| 0001 | `scripts/db/verify/0001_core.sql` | 校验核心表、索引与基础种子 |
|
||||
| 0002 | `scripts/db/verify/0002_enabled_apps.sql` | 校验 enabled_apps 相关表与平台种子 |
|
||||
|
||||
## 执行方式
|
||||
|
||||
环境变量:
|
||||
- `DATABASE_URL`:必填(也可放在项目根目录 `.env` 中)
|
||||
依赖:
|
||||
- 需要安装 PostgreSQL 客户端工具(`psql`);可选 `pg_dump`(仅当你要做备份)。
|
||||
|
||||
执行迁移:
|
||||
|
||||
```bash
|
||||
./scripts/db/migrate.sh
|
||||
./scripts/db/verify.sh
|
||||
```
|
||||
|
||||
开发/测试环境清库重置(危险):
|
||||
|
||||
```bash
|
||||
CONFIRM=1 ./scripts/db/reset.sh
|
||||
```
|
||||
|
||||
回滚到指定版本(示例:回滚到 0001):
|
||||
|
||||
```bash
|
||||
CONFIRM=1 TARGET_VERSION=0001 ./scripts/db/rollback.sh
|
||||
./scripts/db/verify.sh
|
||||
```
|
||||
|
||||
验证步骤
|
||||
|
||||
- 执行 `./scripts/db/verify.sh`,应无错误退出
|
||||
- 启动服务后通过 Scalar 访问 `/scalar`,检查 OpenAPI 中包含:
|
||||
- `/platform/tenants/{tenant_id}/enabled-apps`
|
||||
- `/users/{id}/roles`
|
||||
|
||||
## 首次环境准备(DB/User)
|
||||
|
||||
在具有足够权限的数据库账号下执行(示例):
|
||||
|
||||
```sql
|
||||
CREATE USER iam_service_user WITH PASSWORD 'iam_service_password';
|
||||
CREATE DATABASE iam_service_db OWNER iam_service_user;
|
||||
GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user;
|
||||
```
|
||||
|
||||
说明:
|
||||
- 生产环境不要在仓库内硬编码密码;应由密钥管理系统注入并轮换。
|
||||
- 如需启用扩展(如 `uuid-ossp`),请确认应用账号是否有权限,或由 DBA 预先安装。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `.env` 配了 DATABASE_URL 但脚本报 “DATABASE_URL is required”
|
||||
|
||||
`.env` 只是文件,不会自动变成进程环境变量。Rust 程序会通过 `dotenvy` 读取 `.env`,但 bash 脚本默认不会。
|
||||
|
||||
解决方式:
|
||||
- 直接 `export DATABASE_URL=...` 后再执行脚本;或
|
||||
- 保持项目根目录存在 `.env`,脚本会尝试读取其中的 `DATABASE_URL`。
|
||||
|
||||
### 执行脚本报 “psql not found”
|
||||
|
||||
原因:系统未安装 PostgreSQL 客户端工具(`psql`/`pg_dump`),或不在 `PATH` 中。
|
||||
|
||||
安装方式:
|
||||
- Ubuntu/Debian:`sudo apt-get update && sudo apt-get install -y postgresql-client`
|
||||
- RHEL/CentOS/Fedora:`sudo dnf install -y postgresql`
|
||||
- Alpine:`sudo apk add postgresql-client`
|
||||
- macOS(Homebrew):`brew install libpq && brew link --force libpq`
|
||||
|
||||
验证方式:
|
||||
|
||||
```bash
|
||||
psql --version
|
||||
```
|
||||
74
scripts/db/migrate.sh
Normal file
74
scripts/db/migrate.sh
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
TARGET_VERSION="${TARGET_VERSION:-}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS iam_schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())"
|
||||
|
||||
shopt -s nullglob
|
||||
migrations=( "${SCRIPT_DIR}/migrations/"*.sql )
|
||||
if [[ ${#migrations[@]} -eq 0 ]]; then
|
||||
echo "No migration files found: ${SCRIPT_DIR}/migrations/*.sql"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${migrations[@]}"; do
|
||||
base="$(basename "${file}")"
|
||||
version="${base%%_*}"
|
||||
if [[ -n "${TARGET_VERSION}" && "${version}" > "${TARGET_VERSION}" ]]; then
|
||||
continue
|
||||
fi
|
||||
applied="$(psql "${DATABASE_URL}" -At -c "SELECT 1 FROM iam_schema_migrations WHERE version='${version}' LIMIT 1" || true)"
|
||||
if [[ "${applied}" == "1" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Applying ${version} (${base})"
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
continue
|
||||
fi
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO iam_schema_migrations(version) VALUES ('${version}') ON CONFLICT DO NOTHING"
|
||||
done
|
||||
|
||||
echo "Migrations completed"
|
||||
|
||||
117
scripts/db/migrations/0001_core.sql
Normal file
117
scripts/db/migrations/0001_core.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'active',
|
||||
config JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(100),
|
||||
phone_number VARCHAR(20),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
mfa_enabled BOOLEAN DEFAULT FALSE,
|
||||
mfa_secret VARCHAR(255),
|
||||
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_email ON users(tenant_id, email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
is_system BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
resource VARCHAR(50),
|
||||
action VARCHAR(50),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
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,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
is_revoked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
replaced_by_token_hash VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID REFERENCES tenants(id),
|
||||
user_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(100),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO tenants (id, name)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111', 'Default Corp')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO permissions (code, description, resource, action) VALUES
|
||||
('user:read', 'View users', 'user', 'read'),
|
||||
('user:write', 'Create/Update/Delete users', 'user', 'write'),
|
||||
('role:read', 'View roles', 'role', 'read'),
|
||||
('role:write', 'Manage roles', 'role', 'write'),
|
||||
('tenant:read', 'View tenant info', 'tenant', 'read'),
|
||||
('tenant:write', 'Manage tenant settings and status', 'tenant', 'write')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO roles (tenant_id, name, description, is_system)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111', 'Admin', 'Administrator with full access', TRUE)
|
||||
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r, permissions p
|
||||
WHERE r.name = 'Admin'
|
||||
AND r.tenant_id = '11111111-1111-1111-1111-111111111111'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
88
scripts/db/migrations/0002_enabled_apps.sql
Normal file
88
scripts/db/migrations/0002_enabled_apps.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_entitlements (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
enabled_apps TEXT[] NOT NULL DEFAULT '{}',
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_enabled_apps_history (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
enabled_apps TEXT[] NOT NULL,
|
||||
actor_user_id UUID,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO tenants (id, name, config)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'Platform',
|
||||
jsonb_build_object('enabled_apps', jsonb_build_array(), 'enabled_apps_version', 0)
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO apps (id, name, description) VALUES
|
||||
('iam', 'IAM', 'Identity and Access Management'),
|
||||
('cms', 'CMS', 'Content Management Platform'),
|
||||
('tms', 'TMS', 'Task Management Platform')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO tenant_entitlements (tenant_id, enabled_apps, version)
|
||||
SELECT t.id, '{}'::text[], 0
|
||||
FROM tenants t
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
|
||||
UPDATE tenant_entitlements
|
||||
SET enabled_apps = ARRAY['tms']::text[]
|
||||
WHERE tenant_id = '11111111-1111-1111-1111-111111111111'
|
||||
AND enabled_apps = '{}'::text[];
|
||||
|
||||
UPDATE tenants
|
||||
SET config =
|
||||
jsonb_set(
|
||||
jsonb_set(COALESCE(config, '{}'::jsonb), '{enabled_apps}', to_jsonb(te.enabled_apps), true),
|
||||
'{enabled_apps_version}', to_jsonb(te.version), true
|
||||
)
|
||||
FROM tenant_entitlements te
|
||||
WHERE tenants.id = te.tenant_id;
|
||||
|
||||
INSERT INTO permissions (code, description, resource, action) VALUES
|
||||
('iam:tenant:enabled_apps:read', 'Read tenant enabled apps', 'tenant_enabled_apps', 'read'),
|
||||
('iam:tenant:enabled_apps:write', 'Manage tenant enabled apps', 'tenant_enabled_apps', 'write')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO roles (tenant_id, name, description, is_system)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'SuperAdmin', 'Platform super administrator', TRUE)
|
||||
ON CONFLICT (tenant_id, name) DO NOTHING;
|
||||
|
||||
DELETE FROM role_permissions rp
|
||||
USING roles r, permissions p
|
||||
WHERE rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
AND r.name = 'Admin'
|
||||
AND r.tenant_id <> '00000000-0000-0000-0000-000000000001'
|
||||
AND p.code LIKE 'iam:%';
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r, permissions p
|
||||
WHERE r.name = 'SuperAdmin'
|
||||
AND r.tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
AND p.code IN ('iam:tenant:enabled_apps:read', 'iam:tenant:enabled_apps:write')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -82,8 +82,8 @@ if [[ "${BACKUP}" == "1" ]]; then
|
||||
echo "Backup written: ${backup_file}"
|
||||
fi
|
||||
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/drop_iam_schema.sql"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/schema_post_init.sql"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${ROOT_DIR}/sql/verify_iam_schema.sql"
|
||||
CONFIRM=1 "${ROOT_DIR}/scripts/db/reset.sh"
|
||||
"${ROOT_DIR}/scripts/db/migrate.sh"
|
||||
"${ROOT_DIR}/scripts/db/verify.sh"
|
||||
|
||||
echo "Rebuild completed"
|
||||
|
||||
52
scripts/db/reset.sh
Normal file
52
scripts/db/reset.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
CONFIRM="${CONFIRM:-0}"
|
||||
if [[ "${CONFIRM}" != "1" ]]; then
|
||||
echo "Refusing to reset database without CONFIRM=1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIRM=1 TARGET_VERSION=0000 "${SCRIPT_DIR}/rollback.sh" || true
|
||||
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS iam_schema_migrations"
|
||||
|
||||
echo "Reset completed"
|
||||
101
scripts/db/rollback.sh
Normal file
101
scripts/db/rollback.sh
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
CONFIRM="${CONFIRM:-0}"
|
||||
if [[ "${CONFIRM}" != "1" ]]; then
|
||||
echo "Refusing to run rollback without CONFIRM=1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_VERSION="${TARGET_VERSION:-}"
|
||||
STEPS="${STEPS:-1}"
|
||||
|
||||
if ! psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "SELECT 1 FROM iam_schema_migrations LIMIT 1" >/dev/null 2>&1; then
|
||||
echo "No iam_schema_migrations table found; nothing to rollback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
applied_versions=()
|
||||
while IFS= read -r v; do
|
||||
[[ -n "${v}" ]] && applied_versions+=( "${v}" )
|
||||
done < <(psql "${DATABASE_URL}" -At -c "SELECT version FROM iam_schema_migrations ORDER BY version DESC")
|
||||
|
||||
if [[ ${#applied_versions[@]} -eq 0 ]]; then
|
||||
echo "No applied migrations; nothing to rollback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
to_rollback=()
|
||||
if [[ -n "${TARGET_VERSION}" ]]; then
|
||||
for v in "${applied_versions[@]}"; do
|
||||
if [[ "${v}" > "${TARGET_VERSION}" ]]; then
|
||||
to_rollback+=( "${v}" )
|
||||
fi
|
||||
done
|
||||
else
|
||||
count=0
|
||||
for v in "${applied_versions[@]}"; do
|
||||
to_rollback+=( "${v}" )
|
||||
count=$((count + 1))
|
||||
if [[ "${count}" -ge "${STEPS}" ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#to_rollback[@]} -eq 0 ]]; then
|
||||
echo "No migrations to rollback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for v in "${to_rollback[@]}"; do
|
||||
down_file="${SCRIPT_DIR}/rollback/${v}.down.sql"
|
||||
if [[ ! -f "${down_file}" ]]; then
|
||||
echo "Missing rollback script: ${down_file}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Rolling back ${v}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${down_file}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DELETE FROM iam_schema_migrations WHERE version='${v}'"
|
||||
done
|
||||
|
||||
echo "Rollback completed"
|
||||
|
||||
13
scripts/db/rollback/0001.down.sql
Normal file
13
scripts/db/rollback/0001.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS role_permissions;
|
||||
DROP TABLE IF EXISTS user_roles;
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS audit_logs;
|
||||
DROP TABLE IF EXISTS roles;
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
|
||||
COMMIT;
|
||||
|
||||
26
scripts/db/rollback/0002.down.sql
Normal file
26
scripts/db/rollback/0002.down.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM role_permissions rp
|
||||
USING roles r, permissions p
|
||||
WHERE rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
AND r.name = 'SuperAdmin'
|
||||
AND r.tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
AND p.code IN ('iam:tenant:enabled_apps:read', 'iam:tenant:enabled_apps:write');
|
||||
|
||||
DELETE FROM roles
|
||||
WHERE tenant_id = '00000000-0000-0000-0000-000000000001'
|
||||
AND name = 'SuperAdmin';
|
||||
|
||||
DELETE FROM permissions
|
||||
WHERE code IN ('iam:tenant:enabled_apps:read', 'iam:tenant:enabled_apps:write');
|
||||
|
||||
DROP TABLE IF EXISTS tenant_enabled_apps_history;
|
||||
DROP TABLE IF EXISTS tenant_entitlements;
|
||||
DROP TABLE IF EXISTS apps;
|
||||
|
||||
DELETE FROM tenants
|
||||
WHERE id = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
COMMIT;
|
||||
|
||||
88
scripts/db/verify.sh
Normal file
88
scripts/db/verify.sh
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if ! psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "SELECT 1 FROM iam_schema_migrations LIMIT 1" >/dev/null 2>&1; then
|
||||
echo "iam_schema_migrations not found"
|
||||
echo "Run migrations first: ./scripts/db/migrate.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STRICT="${STRICT:-1}"
|
||||
verify_dir="${SCRIPT_DIR}/verify"
|
||||
if [[ ! -d "${verify_dir}" ]]; then
|
||||
echo "Missing verify directory: ${verify_dir}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
applied_versions=()
|
||||
while IFS= read -r v; do
|
||||
[[ -n "${v}" ]] && applied_versions+=( "${v}" )
|
||||
done < <(psql "${DATABASE_URL}" -At -c "SELECT version FROM iam_schema_migrations ORDER BY version ASC")
|
||||
|
||||
declare -A verify_by_version=()
|
||||
shopt -s nullglob
|
||||
for f in "${verify_dir}/"*.sql; do
|
||||
base="$(basename "${f}")"
|
||||
version="${base%%_*}"
|
||||
version="${version%%.*}"
|
||||
verify_by_version["${version}"]="${f}"
|
||||
done
|
||||
|
||||
if [[ "${STRICT}" == "1" ]]; then
|
||||
for v in "${applied_versions[@]}"; do
|
||||
if [[ -z "${verify_by_version[${v}]:-}" ]]; then
|
||||
echo "Missing verify script for applied migration version: ${v}"
|
||||
echo "Expected: ${verify_dir}/${v}_*.sql"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
for v in "${applied_versions[@]}"; do
|
||||
f="${verify_by_version[${v}]:-}"
|
||||
if [[ -z "${f}" ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "Verifying ${v} ($(basename "${f}"))"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${f}"
|
||||
done
|
||||
|
||||
echo "Verify completed"
|
||||
74
scripts/db/verify/0001_core.sql
Normal file
74
scripts/db/verify/0001_core.sql
Normal file
@@ -0,0 +1,74 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.tenants') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: tenants';
|
||||
END IF;
|
||||
IF to_regclass('public.users') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: users';
|
||||
END IF;
|
||||
IF to_regclass('public.roles') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: roles';
|
||||
END IF;
|
||||
IF to_regclass('public.permissions') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: permissions';
|
||||
END IF;
|
||||
IF to_regclass('public.user_roles') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: user_roles';
|
||||
END IF;
|
||||
IF to_regclass('public.role_permissions') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: role_permissions';
|
||||
END IF;
|
||||
IF to_regclass('public.refresh_tokens') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: refresh_tokens';
|
||||
END IF;
|
||||
IF to_regclass('public.audit_logs') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: audit_logs';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'tenants' AND column_name = 'status'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'tenants.status missing';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'tenants' AND column_name = 'config'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'tenants.config missing';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'mfa_enabled'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'users.mfa_enabled missing';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'users' AND indexname = 'idx_users_tenant_email'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'missing index: idx_users_tenant_email';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'users' AND c.contype = 'f' AND pg_get_constraintdef(c.oid) LIKE 'FOREIGN KEY (tenant_id)%'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'missing foreign key users.tenant_id -> tenants.id';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tenants WHERE id = '11111111-1111-1111-1111-111111111111'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'missing seed tenant Default Corp';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'user:read') THEN
|
||||
RAISE EXCEPTION 'missing seed permission user:read';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
31
scripts/db/verify/0002_enabled_apps.sql
Normal file
31
scripts/db/verify/0002_enabled_apps.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.apps') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: apps';
|
||||
END IF;
|
||||
IF to_regclass('public.tenant_entitlements') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: tenant_entitlements';
|
||||
END IF;
|
||||
IF to_regclass('public.tenant_enabled_apps_history') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: tenant_enabled_apps_history';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tenants WHERE id = '00000000-0000-0000-0000-000000000001'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'missing seed tenant Platform';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'iam:tenant:enabled_apps:write') THEN
|
||||
RAISE EXCEPTION 'missing seed permission iam:tenant:enabled_apps:write';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM apps WHERE id = 'tms') THEN
|
||||
RAISE EXCEPTION 'missing seed app tms';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM tenant_entitlements WHERE tenant_id = '11111111-1111-1111-1111-111111111111') THEN
|
||||
RAISE EXCEPTION 'missing tenant_entitlements row for Default Corp';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
Reference in New Issue
Block a user