feat(w5): dual Flyway history (V1-V6)

Migrations in db/migration/auth/ with separate flyway_schema_history_auth table:
- V1: users + user_identities (with provider/subject unique constraint)
- V2: memberships (polymorphic org_type/org_id, unique per user+org)
- V3: invitations (64-char token, status lifecycle)
- V4: access_requests (requester → reviewer workflow)
- V5: Microsoft tenant_id partial index on user_identities
- V6: login_events + refresh_tokens + revinfo actor_user_id column

PlateAuthFlywayConfig runs a second Flyway bean against flyway_schema_history_auth,
independent of consumer's own flyway_schema_history. Runs at bean init (before JPA).
This commit is contained in:
Patrick Plate
2026-06-24 15:48:00 +02:00
parent 63c953d9b9
commit a2e4393d05
7 changed files with 168 additions and 0 deletions
@@ -0,0 +1,28 @@
package de.platesoft.auth.config;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
/**
* Configures a separate Flyway instance for plate-auth migrations.
* Uses its own history table (flyway_schema_history_auth) to avoid
* version collisions with the consumer application's migrations.
*/
@Configuration
@ConditionalOnClass(Flyway.class)
public class PlateAuthFlywayConfig {
@Bean(initMethod = "migrate")
public Flyway plateAuthFlyway(DataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/auth")
.table("flyway_schema_history_auth")
.baselineOnMigrate(true)
.load();
}
}
@@ -0,0 +1,32 @@
-- V1: Create users and user_identities tables
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'ROLE_USER',
active BOOLEAN NOT NULL DEFAULT TRUE,
default_org_id UUID,
last_provider VARCHAR(32),
version BIGINT DEFAULT 0,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_identities (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
subject VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
tenant_id VARCHAR(64),
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ,
version BIGINT DEFAULT 0,
CONSTRAINT uq_user_identities_provider_subject UNIQUE (provider, subject)
);
CREATE INDEX idx_user_identities_user_id ON user_identities(user_id);
CREATE INDEX idx_users_email ON users(email);
@@ -0,0 +1,20 @@
-- V2: Create memberships table
CREATE TABLE IF NOT EXISTS memberships (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_type VARCHAR(16) NOT NULL,
org_id UUID NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'MEMBER',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
granted_by UUID,
grant_reason VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
revoked_by UUID,
version BIGINT DEFAULT 0,
CONSTRAINT uq_memberships_user_org UNIQUE (user_id, org_type, org_id)
);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org ON memberships(org_type, org_id);
CREATE INDEX idx_memberships_status ON memberships(status);
@@ -0,0 +1,23 @@
-- V3: Create invitations table
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
org_type VARCHAR(16) NOT NULL,
org_id UUID NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'MEMBER',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
accepted_by UUID,
revoked_at TIMESTAMPTZ,
revoked_by UUID,
note VARCHAR(500),
version BIGINT DEFAULT 0
);
CREATE INDEX idx_invitations_email ON invitations(email);
CREATE INDEX idx_invitations_token ON invitations(token);
CREATE INDEX idx_invitations_org ON invitations(org_type, org_id);
@@ -0,0 +1,19 @@
-- V4: Create access_requests table
CREATE TABLE IF NOT EXISTS access_requests (
id UUID PRIMARY KEY,
requester_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_type VARCHAR(16) NOT NULL,
org_id UUID NOT NULL,
requested_role VARCHAR(16) NOT NULL DEFAULT 'VIEWER',
justification VARCHAR(500),
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
reviewer_id UUID,
decision_reason VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
decided_at TIMESTAMPTZ,
version BIGINT DEFAULT 0
);
CREATE INDEX idx_access_requests_requester ON access_requests(requester_id);
CREATE INDEX idx_access_requests_org ON access_requests(org_type, org_id);
CREATE INDEX idx_access_requests_status ON access_requests(status);
@@ -0,0 +1,3 @@
-- V5: Add index on user_identities.tenant_id for Microsoft Entra lookups
CREATE INDEX IF NOT EXISTS idx_user_identities_microsoft_tenant_id
ON user_identities(tenant_id) WHERE tenant_id IS NOT NULL;
@@ -0,0 +1,43 @@
-- V6: Create login_events table and revinfo actor column
CREATE TABLE IF NOT EXISTS login_events (
id BIGSERIAL PRIMARY KEY,
user_id UUID,
email VARCHAR(255) NOT NULL,
provider VARCHAR(32) NOT NULL,
outcome VARCHAR(32) NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(512),
correlation_id VARCHAR(64),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_login_events_user_id ON login_events(user_id);
CREATE INDEX idx_login_events_email ON login_events(email);
CREATE INDEX idx_login_events_occurred_at ON login_events(occurred_at);
-- Create refresh_tokens table
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
-- Envers revinfo actor tracking (add actor_user_id if revinfo table exists)
-- This is safe to run regardless of whether Envers has created its tables yet,
-- because Hibernate Envers will create revinfo on first boot.
-- Consumer's Flyway or plate-auth's FlywayConfig runs before JPA init.
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE tablename = 'revinfo') THEN
IF NOT EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'revinfo' AND column_name = 'actor_user_id') THEN
ALTER TABLE revinfo ADD COLUMN actor_user_id UUID;
END IF;
END IF;
END $$;