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
|
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.
|
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
|
## Frontend System Management
|
||||||
|
|
||||||
`/system-admin` is shown only when `GET /api/v1/access/context` returns
|
`/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