"""Add zero-knowledge authentication schema. Revision ID: 016 Revises: 015 Create Date: 2026-03-03 Adds SRP columns to users, creates user_key_sets table for encrypted key bundles, creates immutable key_access_log audit trail, and adds vault key columns to tenants (Phase 29 preparation). Both new tables have RLS policies. key_access_log is append-only (INSERT+SELECT only, no UPDATE/DELETE). """ revision = "016" down_revision = "015" branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID def upgrade() -> None: # --- Add SRP columns to users table --- op.add_column( "users", sa.Column("srp_salt", sa.LargeBinary(), nullable=True), ) op.add_column( "users", sa.Column("srp_verifier", sa.LargeBinary(), nullable=True), ) op.add_column( "users", sa.Column( "auth_version", sa.SmallInteger(), server_default=sa.text("1"), nullable=False, ), ) # --- Create user_key_sets table --- op.create_table( "user_key_sets", sa.Column( "id", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True, ), sa.Column( "user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, ), sa.Column( "tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, # NULL for super_admin ), sa.Column("encrypted_private_key", sa.LargeBinary(), nullable=False), sa.Column("private_key_nonce", sa.LargeBinary(), nullable=False), sa.Column("encrypted_vault_key", sa.LargeBinary(), nullable=False), sa.Column("vault_key_nonce", sa.LargeBinary(), nullable=False), sa.Column("public_key", sa.LargeBinary(), nullable=False), sa.Column( "pbkdf2_iterations", sa.Integer(), server_default=sa.text("650000"), nullable=False, ), sa.Column("pbkdf2_salt", sa.LargeBinary(), nullable=False), sa.Column("hkdf_salt", sa.LargeBinary(), nullable=False), sa.Column( "key_version", sa.Integer(), server_default=sa.text("1"), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), ), ) # --- Create key_access_log table (immutable audit trail) --- op.create_table( "key_access_log", sa.Column( "id", UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True, ), sa.Column( "tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ), sa.Column("action", sa.Text(), nullable=False), sa.Column("resource_type", sa.Text(), nullable=True), sa.Column("resource_id", sa.Text(), nullable=True), sa.Column("key_version", sa.Integer(), nullable=True), sa.Column("ip_address", sa.Text(), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), ) # --- Add vault key columns to tenants (Phase 29 preparation) --- op.add_column( "tenants", sa.Column("encrypted_vault_key", sa.LargeBinary(), nullable=True), ) op.add_column( "tenants", sa.Column( "vault_key_version", sa.Integer(), server_default=sa.text("1"), ), ) # --- RLS policies --- conn = op.get_bind() # user_key_sets RLS conn.execute(sa.text( "ALTER TABLE user_key_sets ENABLE ROW LEVEL SECURITY" )) conn.execute(sa.text( "CREATE POLICY user_key_sets_tenant_isolation ON user_key_sets " "USING (tenant_id::text = current_setting('app.current_tenant', true) " "OR current_setting('app.current_tenant', true) = 'super_admin')" )) conn.execute(sa.text( "GRANT SELECT, INSERT, UPDATE ON user_key_sets TO app_user" )) # key_access_log RLS (append-only: INSERT+SELECT only, no UPDATE/DELETE) conn.execute(sa.text( "ALTER TABLE key_access_log ENABLE ROW LEVEL SECURITY" )) conn.execute(sa.text( "CREATE POLICY key_access_log_tenant_isolation ON key_access_log " "USING (tenant_id::text = current_setting('app.current_tenant', true) " "OR current_setting('app.current_tenant', true) = 'super_admin')" )) conn.execute(sa.text( "GRANT INSERT, SELECT ON key_access_log TO app_user" )) # poller_user needs INSERT to log key access events when decrypting credentials conn.execute(sa.text( "GRANT INSERT, SELECT ON key_access_log TO poller_user" )) def downgrade() -> None: conn = op.get_bind() # Drop RLS policies conn.execute(sa.text( "DROP POLICY IF EXISTS key_access_log_tenant_isolation ON key_access_log" )) conn.execute(sa.text( "DROP POLICY IF EXISTS user_key_sets_tenant_isolation ON user_key_sets" )) # Revoke grants conn.execute(sa.text("REVOKE ALL ON key_access_log FROM app_user")) conn.execute(sa.text("REVOKE ALL ON key_access_log FROM poller_user")) conn.execute(sa.text("REVOKE ALL ON user_key_sets FROM app_user")) # Drop vault key columns from tenants op.drop_column("tenants", "vault_key_version") op.drop_column("tenants", "encrypted_vault_key") # Drop tables op.drop_table("key_access_log") op.drop_table("user_key_sets") # Drop SRP columns from users op.drop_column("users", "auth_version") op.drop_column("users", "srp_verifier") op.drop_column("users", "srp_salt")