template-saas-database-v2/migrations/V20260120_001__migrate_auth_sessions_structure.sql
rckrdmrd 27de049441 [TEMPLATE-SAAS-DB] chore: Update audit schema and add migrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:38:47 -06:00

253 lines
8.2 KiB
PL/PgSQL

-- ============================================
-- 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;