diff --git a/AUTHENTICATION_AND_USER_MANAGEMENT.md b/AUTHENTICATION_AND_USER_MANAGEMENT.md index 8b04db3..7b617d7 100644 --- a/AUTHENTICATION_AND_USER_MANAGEMENT.md +++ b/AUTHENTICATION_AND_USER_MANAGEMENT.md @@ -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 diff --git a/resources/sql/004_metadata_auth_management.sql b/resources/sql/004_metadata_auth_management.sql deleted file mode 100644 index 3e58a88..0000000 --- a/resources/sql/004_metadata_auth_management.sql +++ /dev/null @@ -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); diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql deleted file mode 100644 index d3f9e12..0000000 --- a/resources/sql/005_metadata_project_configuration.sql +++ /dev/null @@ -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); diff --git a/resources/sql/006_metadata_rbac_roles.sql b/resources/sql/006_metadata_rbac_roles.sql deleted file mode 100644 index 947e256..0000000 --- a/resources/sql/006_metadata_rbac_roles.sql +++ /dev/null @@ -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')); diff --git a/scripts/migrate_local_users_to_metadata.py b/scripts/migrate_local_users_to_metadata.py deleted file mode 100644 index 55edbc3..0000000 --- a/scripts/migrate_local_users_to_metadata.py +++ /dev/null @@ -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() diff --git a/tests/auth/test_rbac_migration.py b/tests/auth/test_rbac_migration.py deleted file mode 100644 index 8111be4..0000000 --- a/tests/auth/test_rbac_migration.py +++ /dev/null @@ -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