refactor(delivery): remove legacy metadata migrations
This commit is contained in:
@@ -90,19 +90,6 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d
|
||||
Keep keys stable for the lifetime of encrypted metadata. Rotating a key requires
|
||||
decrypting with the old key and re-encrypting with the new key.
|
||||
|
||||
## Metadata Schema Patches
|
||||
|
||||
Apply metadata patches in order:
|
||||
|
||||
1. `resources/sql/004_metadata_auth_management.sql`
|
||||
2. `resources/sql/005_metadata_project_configuration.sql`
|
||||
3. `resources/sql/006_metadata_rbac_roles.sql`
|
||||
|
||||
`004` creates Keycloak-backed metadata users and project memberships. `005`
|
||||
creates project and project database routing tables with uniqueness, role/type,
|
||||
and pool-size constraints. `006` extends existing membership constraints with
|
||||
the fixed delivery roles.
|
||||
|
||||
## Frontend System Management
|
||||
|
||||
`/system-admin` is shown only when `GET /api/v1/access/context` returns
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
-- Metadata auth management schema patch.
|
||||
-- Keycloak owns login credentials; TJWater stores only business identity and access.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
users_id_type text;
|
||||
BEGIN
|
||||
SELECT data_type INTO users_id_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'id';
|
||||
|
||||
IF users_id_type IS NULL THEN
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
keycloak_id UUID UNIQUE NOT NULL,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'user' NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
is_superuser BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
attributes JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
last_login_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
ELSIF users_id_type <> 'uuid' THEN
|
||||
RAISE EXCEPTION
|
||||
'Existing public.users.id is %, not uuid. Export old local users, create Keycloak accounts, then migrate to metadata UUID users before applying this patch.',
|
||||
users_id_type;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS keycloak_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS attributes JSONB,
|
||||
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role SET DEFAULT 'user';
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_role_check
|
||||
CHECK (role IN ('admin', 'user'));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_id ON users(keycloak_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_project_membership (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
project_id UUID NOT NULL,
|
||||
project_role VARCHAR(20) DEFAULT 'viewer' NOT NULL,
|
||||
CONSTRAINT user_project_membership_role_check
|
||||
CHECK (
|
||||
project_role IN ('member', 'viewer')
|
||||
),
|
||||
CONSTRAINT user_project_membership_unique UNIQUE (user_id, project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_project_membership_user_id
|
||||
ON user_project_membership(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_project_membership_project_id
|
||||
ON user_project_membership(project_id);
|
||||
@@ -1,55 +0,0 @@
|
||||
-- Metadata project configuration schema patch.
|
||||
-- Admin APIs write these tables; operators should not hand-edit encrypted values.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
code VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
|
||||
map_extent JSONB,
|
||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_code ON projects(code);
|
||||
|
||||
DROP TRIGGER IF EXISTS update_projects_updated_at ON projects;
|
||||
CREATE TRIGGER update_projects_updated_at
|
||||
BEFORE UPDATE ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_databases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
db_role VARCHAR(20) NOT NULL,
|
||||
db_type VARCHAR(20) NOT NULL,
|
||||
dsn_encrypted TEXT NOT NULL,
|
||||
pool_min_size INTEGER DEFAULT 2 NOT NULL,
|
||||
pool_max_size INTEGER DEFAULT 10 NOT NULL,
|
||||
CONSTRAINT project_databases_unique_role UNIQUE (project_id, db_role),
|
||||
CONSTRAINT project_databases_role_check CHECK (db_role IN ('biz_data', 'iot_data')),
|
||||
CONSTRAINT project_databases_type_check CHECK (db_type IN ('postgresql', 'timescaledb')),
|
||||
CONSTRAINT project_databases_pool_check CHECK (
|
||||
pool_min_size >= 1 AND pool_max_size >= pool_min_size
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_databases_project_id
|
||||
ON project_databases(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_databases_role
|
||||
ON project_databases(db_role);
|
||||
@@ -1,32 +0,0 @@
|
||||
-- Normalize existing roles to the Web authorization model.
|
||||
-- This migration is intentionally re-runnable.
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
UPDATE users
|
||||
SET role = 'user'
|
||||
WHERE role NOT IN ('admin', 'user');
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_role_check
|
||||
CHECK (role IN ('admin', 'user'));
|
||||
|
||||
ALTER TABLE user_project_membership
|
||||
DROP CONSTRAINT IF EXISTS user_project_membership_role_check;
|
||||
|
||||
UPDATE user_project_membership
|
||||
SET project_role = CASE
|
||||
WHEN project_role IN (
|
||||
'owner',
|
||||
'admin',
|
||||
'modeler',
|
||||
'dispatcher'
|
||||
) THEN 'member'
|
||||
ELSE 'viewer'
|
||||
END
|
||||
WHERE project_role NOT IN ('member', 'viewer');
|
||||
|
||||
ALTER TABLE user_project_membership
|
||||
ADD CONSTRAINT user_project_membership_role_check
|
||||
CHECK (project_role IN ('member', 'viewer'));
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build metadata user sync payloads from an old-user to Keycloak mapping CSV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REQUIRED_COLUMNS = {"keycloak_id", "username", "email"}
|
||||
|
||||
|
||||
def parse_bool(value: str | None) -> bool:
|
||||
if value is None or value == "":
|
||||
return True
|
||||
return value.strip().lower() not in {"0", "false", "no", "n", "disabled"}
|
||||
|
||||
|
||||
def build_payload(mapping_csv: Path) -> dict:
|
||||
with mapping_csv.open(newline="", encoding="utf-8") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
missing = REQUIRED_COLUMNS.difference(reader.fieldnames or [])
|
||||
if missing:
|
||||
raise SystemExit(f"missing required CSV columns: {', '.join(sorted(missing))}")
|
||||
|
||||
users = []
|
||||
for row in reader:
|
||||
users.append(
|
||||
{
|
||||
"keycloak_id": row["keycloak_id"].strip(),
|
||||
"username": row["username"].strip(),
|
||||
"email": row["email"].strip(),
|
||||
"role": (row.get("role") or "user").strip().lower(),
|
||||
"is_active": parse_bool(row.get("is_active")),
|
||||
}
|
||||
)
|
||||
|
||||
return {"users": users}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Convert old local user mappings into a JSON body for "
|
||||
"POST /api/v1/admin/users/sync/batch. Passwords are never migrated."
|
||||
)
|
||||
)
|
||||
parser.add_argument("mapping_csv", type=Path)
|
||||
parser.add_argument("-o", "--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = build_payload(args.mapping_csv)
|
||||
content = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
args.output.write_text(content + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_rbac_migration_normalizes_legacy_roles():
|
||||
sql = Path("resources/sql/006_metadata_rbac_roles.sql").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "role IN ('admin', 'user')" in sql
|
||||
assert "project_role IN ('member', 'viewer')" in sql
|
||||
assert "'modeler'," in sql
|
||||
assert "'dispatcher'" in sql
|
||||
assert "THEN 'member'" in sql
|
||||
assert "ELSE 'viewer'" in sql
|
||||
Reference in New Issue
Block a user