"""Add certificate authority and device certificate tables. Revision ID: 013 Revises: 012 Create Date: 2026-03-03 Creates the `certificate_authorities` (one per tenant) and `device_certificates` (one per device) tables for the Internal Certificate Authority feature. Also adds a `tls_mode` column to the `devices` table to track per-device TLS verification mode (insecure vs portal_ca). Both tables have RLS policies for tenant isolation, plus poller_user read access (the poller needs CA cert PEM to verify device TLS connections). """ revision = "013" down_revision = "012" branch_labels = None depends_on = None from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID def upgrade() -> None: # --- certificate_authorities table --- op.create_table( "certificate_authorities", 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, unique=True, ), sa.Column("common_name", sa.String(255), nullable=False), sa.Column("cert_pem", sa.Text(), nullable=False), sa.Column("encrypted_private_key", sa.LargeBinary(), nullable=False), sa.Column("serial_number", sa.String(64), nullable=False), sa.Column("fingerprint_sha256", sa.String(95), nullable=False), sa.Column( "not_valid_before", sa.DateTime(timezone=True), nullable=False, ), sa.Column( "not_valid_after", sa.DateTime(timezone=True), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), ), ) # --- device_certificates table --- op.create_table( "device_certificates", 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( "device_id", UUID(as_uuid=True), sa.ForeignKey("devices.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "ca_id", UUID(as_uuid=True), sa.ForeignKey("certificate_authorities.id", ondelete="CASCADE"), nullable=False, ), sa.Column("common_name", sa.String(255), nullable=False), sa.Column("serial_number", sa.String(64), nullable=False), sa.Column("fingerprint_sha256", sa.String(95), nullable=False), sa.Column("cert_pem", sa.Text(), nullable=False), sa.Column("encrypted_private_key", sa.LargeBinary(), nullable=False), sa.Column( "not_valid_before", sa.DateTime(timezone=True), nullable=False, ), sa.Column( "not_valid_after", sa.DateTime(timezone=True), nullable=False, ), sa.Column( "status", sa.String(20), nullable=False, server_default="issued", ), sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True), 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()"), ), ) # --- Add tls_mode column to devices table --- op.add_column( "devices", sa.Column( "tls_mode", sa.String(20), nullable=False, server_default="insecure", ), ) # --- RLS policies --- conn = op.get_bind() # certificate_authorities RLS conn.execute(sa.text("ALTER TABLE certificate_authorities ENABLE ROW LEVEL SECURITY")) conn.execute( sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON certificate_authorities TO app_user") ) conn.execute( sa.text( "CREATE POLICY tenant_isolation ON certificate_authorities FOR ALL " "USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid) " "WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)" ) ) conn.execute(sa.text("GRANT SELECT ON certificate_authorities TO poller_user")) # device_certificates RLS conn.execute(sa.text("ALTER TABLE device_certificates ENABLE ROW LEVEL SECURITY")) conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON device_certificates TO app_user")) conn.execute( sa.text( "CREATE POLICY tenant_isolation ON device_certificates FOR ALL " "USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid) " "WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)" ) ) conn.execute(sa.text("GRANT SELECT ON device_certificates TO poller_user")) def downgrade() -> None: conn = op.get_bind() # Drop RLS policies conn.execute(sa.text("DROP POLICY IF EXISTS tenant_isolation ON device_certificates")) conn.execute(sa.text("DROP POLICY IF EXISTS tenant_isolation ON certificate_authorities")) # Revoke grants conn.execute(sa.text("REVOKE ALL ON device_certificates FROM app_user")) conn.execute(sa.text("REVOKE ALL ON device_certificates FROM poller_user")) conn.execute(sa.text("REVOKE ALL ON certificate_authorities FROM app_user")) conn.execute(sa.text("REVOKE ALL ON certificate_authorities FROM poller_user")) # Drop tls_mode column from devices op.drop_column("devices", "tls_mode") # Drop tables op.drop_table("device_certificates") op.drop_table("certificate_authorities")