-- ============================================ -- Migration: V20260120_001 -- Description: Migrate auth.sessions structure -- Changes: -- - Rename session_token -> token_hash (VARCHAR(64) -> VARCHAR(255)) -- - Change is_active (BOOLEAN) -> status (ENUM) -- - Add device_name, browser, os, location columns -- - Add revoked_at, revoked_reason columns -- ============================================ -- UP Migration BEGIN; -- ============================================ -- 1. Check if we need to migrate (legacy structure) -- ============================================ DO $$ DECLARE has_session_token BOOLEAN; has_is_active BOOLEAN; has_status BOOLEAN; BEGIN -- Check for legacy column session_token SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'session_token' ) INTO has_session_token; -- Check for legacy column is_active SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'is_active' ) INTO has_is_active; -- Check if status column already exists SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'status' ) INTO has_status; -- Store results in temp table for use in subsequent statements CREATE TEMP TABLE _migration_flags ( flag_name VARCHAR(50) PRIMARY KEY, flag_value BOOLEAN ) ON COMMIT DROP; INSERT INTO _migration_flags VALUES ('has_session_token', has_session_token), ('has_is_active', has_is_active), ('has_status', has_status); END $$; -- ============================================ -- 2. Ensure session_status ENUM exists -- ============================================ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'session_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'auth')) THEN CREATE TYPE auth.session_status AS ENUM ('active', 'expired', 'revoked'); END IF; END $$; -- ============================================ -- 3. Rename session_token -> token_hash (if legacy) -- ============================================ DO $$ BEGIN IF EXISTS (SELECT 1 FROM _migration_flags WHERE flag_name = 'has_session_token' AND flag_value = TRUE) THEN -- Drop old index if exists DROP INDEX IF EXISTS auth.idx_sessions_token; -- Rename column ALTER TABLE auth.sessions RENAME COLUMN session_token TO token_hash; -- Expand varchar size (64 -> 255) ALTER TABLE auth.sessions ALTER COLUMN token_hash TYPE VARCHAR(255); -- Recreate index CREATE INDEX idx_sessions_token ON auth.sessions(token_hash); RAISE NOTICE 'Renamed session_token to token_hash and expanded to VARCHAR(255)'; END IF; END $$; -- ============================================ -- 4. Convert is_active (BOOLEAN) -> status (ENUM) -- ============================================ DO $$ DECLARE has_is_active BOOLEAN; has_status BOOLEAN; BEGIN SELECT flag_value INTO has_is_active FROM _migration_flags WHERE flag_name = 'has_is_active'; SELECT flag_value INTO has_status FROM _migration_flags WHERE flag_name = 'has_status'; IF has_is_active AND NOT has_status THEN -- Add status column ALTER TABLE auth.sessions ADD COLUMN status auth.session_status; -- Migrate data: is_active = true -> 'active', is_active = false -> 'revoked' UPDATE auth.sessions SET status = CASE WHEN is_active = true THEN 'active'::auth.session_status ELSE 'revoked'::auth.session_status END; -- Mark expired sessions UPDATE auth.sessions SET status = 'expired'::auth.session_status WHERE expires_at < NOW() AND status = 'active'; -- Set NOT NULL and default ALTER TABLE auth.sessions ALTER COLUMN status SET NOT NULL; ALTER TABLE auth.sessions ALTER COLUMN status SET DEFAULT 'active'; -- Drop old column ALTER TABLE auth.sessions DROP COLUMN is_active; -- Update indexes to use status instead of is_active DROP INDEX IF EXISTS auth.idx_sessions_user; DROP INDEX IF EXISTS auth.idx_sessions_tenant; DROP INDEX IF EXISTS auth.idx_sessions_expires; CREATE INDEX idx_sessions_user ON auth.sessions(user_id) WHERE status = 'active'; CREATE INDEX idx_sessions_tenant ON auth.sessions(tenant_id) WHERE status = 'active'; CREATE INDEX idx_sessions_expires ON auth.sessions(expires_at) WHERE status = 'active'; RAISE NOTICE 'Converted is_active to status enum'; END IF; END $$; -- ============================================ -- 5. Add new columns if they don't exist -- ============================================ -- device_name DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'device_name' ) THEN ALTER TABLE auth.sessions ADD COLUMN device_name VARCHAR(200); RAISE NOTICE 'Added device_name column'; END IF; END $$; -- browser DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'browser' ) THEN ALTER TABLE auth.sessions ADD COLUMN browser VARCHAR(100); RAISE NOTICE 'Added browser column'; END IF; END $$; -- os DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'os' ) THEN ALTER TABLE auth.sessions ADD COLUMN os VARCHAR(100); RAISE NOTICE 'Added os column'; END IF; END $$; -- location DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'location' ) THEN ALTER TABLE auth.sessions ADD COLUMN location VARCHAR(200); RAISE NOTICE 'Added location column'; END IF; END $$; -- revoked_at DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'revoked_at' ) THEN ALTER TABLE auth.sessions ADD COLUMN revoked_at TIMESTAMPTZ; RAISE NOTICE 'Added revoked_at column'; END IF; END $$; -- revoked_reason DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'revoked_reason' ) THEN ALTER TABLE auth.sessions ADD COLUMN revoked_reason VARCHAR(100); RAISE NOTICE 'Added revoked_reason column'; END IF; END $$; -- ============================================ -- 6. Update cleanup function to use status -- ============================================ CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions() RETURNS INTEGER AS $$ DECLARE deleted_count INTEGER; BEGIN WITH deleted AS ( DELETE FROM auth.sessions WHERE expires_at < NOW() - INTERVAL '7 days' OR (status = 'revoked' AND revoked_at < NOW() - INTERVAL '30 days') RETURNING * ) SELECT COUNT(*) INTO deleted_count FROM deleted; RETURN deleted_count; END; $$ LANGUAGE plpgsql; -- ============================================ -- 7. Update comments -- ============================================ COMMENT ON COLUMN auth.sessions.token_hash IS 'SHA256 hash of session token for secure storage'; COMMENT ON COLUMN auth.sessions.status IS 'Session status: active, expired, revoked'; COMMENT ON COLUMN auth.sessions.device_name IS 'Human-readable device name'; COMMENT ON COLUMN auth.sessions.browser IS 'Browser name and version'; COMMENT ON COLUMN auth.sessions.os IS 'Operating system name and version'; COMMENT ON COLUMN auth.sessions.location IS 'Geographic location (City, Country)'; COMMENT ON COLUMN auth.sessions.revoked_at IS 'Timestamp when session was revoked'; COMMENT ON COLUMN auth.sessions.revoked_reason IS 'Reason for revocation: logout, security, password_change, admin'; COMMIT;