Fix: bootstrap sequence, sqlite database detection, and migrations
Browse files- .DS_Store +0 -0
- .gitignore +1 -0
- app.py +8 -9
- main.py +51 -26
- migrations/versions/d2485e8b5d85_add_saml_support.py +139 -10
.DS_Store
DELETED
|
Binary file (8.2 kB)
|
|
|
.gitignore
CHANGED
|
@@ -14,6 +14,7 @@ __pycache__/
|
|
| 14 |
attstore/
|
| 15 |
|
| 16 |
.DS_Store/
|
|
|
|
| 17 |
node_modules/
|
| 18 |
|
| 19 |
# Documentation (pulled during build or via download script)
|
|
|
|
| 14 |
attstore/
|
| 15 |
|
| 16 |
.DS_Store/
|
| 17 |
+
*.DS_Store
|
| 18 |
node_modules/
|
| 19 |
|
| 20 |
# Documentation (pulled during build or via download script)
|
app.py
CHANGED
|
@@ -22,6 +22,7 @@ jwt = JWTManager()
|
|
| 22 |
|
| 23 |
# Create the app
|
| 24 |
app = Flask(__name__)
|
|
|
|
| 25 |
# Ensure templates auto-reload for development
|
| 26 |
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
| 27 |
app.jinja_env.auto_reload = True
|
|
@@ -51,17 +52,13 @@ jwt.init_app(app)
|
|
| 51 |
migrate = Migrate(app, db)
|
| 52 |
|
| 53 |
# Import config and setup
|
| 54 |
-
from config import setup_config
|
| 55 |
setup_config(app)
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# Note: Default admin user creation moved to main.py after migrations
|
| 63 |
-
|
| 64 |
-
# Import and register blueprints
|
| 65 |
from api.evidence import evidence_bp
|
| 66 |
from api.login import login_bp
|
| 67 |
from api.syslog import syslog_bp
|
|
@@ -84,6 +81,8 @@ with app.app_context():
|
|
| 84 |
app.register_blueprint(saml_config_bp)
|
| 85 |
app.register_blueprint(saml_bp)
|
| 86 |
|
|
|
|
|
|
|
| 87 |
# Public health endpoint (no auth)
|
| 88 |
@app.get('/healthz')
|
| 89 |
def healthz():
|
|
|
|
| 22 |
|
| 23 |
# Create the app
|
| 24 |
app = Flask(__name__)
|
| 25 |
+
os.makedirs(app.instance_path, exist_ok=True)
|
| 26 |
# Ensure templates auto-reload for development
|
| 27 |
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
| 28 |
app.jinja_env.auto_reload = True
|
|
|
|
| 52 |
migrate = Migrate(app, db)
|
| 53 |
|
| 54 |
# Import config and setup
|
| 55 |
+
from config import setup_config
|
| 56 |
setup_config(app)
|
| 57 |
|
| 58 |
+
# Import models so SQLAlchemy metadata is populated for migrations
|
| 59 |
+
import models # noqa: E402,F401
|
| 60 |
+
|
| 61 |
+
def register_blueprints(app):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
from api.evidence import evidence_bp
|
| 63 |
from api.login import login_bp
|
| 64 |
from api.syslog import syslog_bp
|
|
|
|
| 81 |
app.register_blueprint(saml_config_bp)
|
| 82 |
app.register_blueprint(saml_bp)
|
| 83 |
|
| 84 |
+
register_blueprints(app)
|
| 85 |
+
|
| 86 |
# Public health endpoint (no auth)
|
| 87 |
@app.get('/healthz')
|
| 88 |
def healthz():
|
main.py
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
-
from app import app
|
| 2 |
-
from config import PORT
|
| 3 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from werkzeug.serving import WSGIRequestHandler
|
| 5 |
# --- Alembic migration runner ---
|
| 6 |
from alembic import command
|
|
@@ -18,6 +23,44 @@ def run_migrations():
|
|
| 18 |
command.upgrade(alembic_cfg, "head")
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# Filter out specific paths from Werkzeug logs
|
| 22 |
class FilteredWSGIRequestHandler(WSGIRequestHandler):
|
| 23 |
def log(self, type, message, *args):
|
|
@@ -26,31 +69,13 @@ class FilteredWSGIRequestHandler(WSGIRequestHandler):
|
|
| 26 |
return
|
| 27 |
return super().log(type, message, *args)
|
| 28 |
|
| 29 |
-
import os
|
| 30 |
-
from app import db
|
| 31 |
-
|
| 32 |
-
def db_file_exists():
|
| 33 |
-
# Only works for SQLite
|
| 34 |
-
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
| 35 |
-
if db_uri.startswith("sqlite:///"):
|
| 36 |
-
db_path = db_uri.replace("sqlite:///", "")
|
| 37 |
-
return os.path.exists(db_path)
|
| 38 |
-
return True # For non-SQLite, always run migrations
|
| 39 |
-
|
| 40 |
if __name__ == '__main__':
|
| 41 |
from config import create_default_admin_user
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
create_default_admin_user(app)
|
| 49 |
-
else:
|
| 50 |
-
run_migrations() # Ensure DB is migrated before serving requests
|
| 51 |
-
# Create default admin user after migrations
|
| 52 |
-
with app.app_context():
|
| 53 |
-
create_default_admin_user(app)
|
| 54 |
-
|
| 55 |
# Use filtered request handler
|
| 56 |
app.run(host='0.0.0.0', port=PORT, debug=True, request_handler=FilteredWSGIRequestHandler)
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import inspect
|
| 5 |
+
from sqlalchemy.engine.url import make_url
|
| 6 |
+
|
| 7 |
+
from app import app, db
|
| 8 |
+
from config import PORT
|
| 9 |
from werkzeug.serving import WSGIRequestHandler
|
| 10 |
# --- Alembic migration runner ---
|
| 11 |
from alembic import command
|
|
|
|
| 23 |
command.upgrade(alembic_cfg, "head")
|
| 24 |
|
| 25 |
|
| 26 |
+
def ensure_sqlite_database():
|
| 27 |
+
"""Bootstrap SQLite DB files before Alembic runs."""
|
| 28 |
+
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
| 29 |
+
if not db_uri.startswith("sqlite"):
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
url = make_url(db_uri)
|
| 33 |
+
database = url.database
|
| 34 |
+
if not database:
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
if os.path.isabs(database):
|
| 38 |
+
db_path = database
|
| 39 |
+
else:
|
| 40 |
+
db_path = os.path.join(app.instance_path, database)
|
| 41 |
+
|
| 42 |
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
| 43 |
+
|
| 44 |
+
bootstrap_reason = None
|
| 45 |
+
if not os.path.exists(db_path):
|
| 46 |
+
bootstrap_reason = "file missing"
|
| 47 |
+
else:
|
| 48 |
+
with app.app_context():
|
| 49 |
+
inspector = inspect(db.engine)
|
| 50 |
+
tables = inspector.get_table_names()
|
| 51 |
+
if 'users' not in tables:
|
| 52 |
+
bootstrap_reason = "schema missing"
|
| 53 |
+
|
| 54 |
+
if not bootstrap_reason:
|
| 55 |
+
logging.info(f"SQLite database already initialized at {db_path}")
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
logging.info(f"Bootstrapping SQLite database at {db_path} ({bootstrap_reason})")
|
| 59 |
+
with app.app_context():
|
| 60 |
+
db.create_all()
|
| 61 |
+
logging.info("SQLite database initialization complete")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
# Filter out specific paths from Werkzeug logs
|
| 65 |
class FilteredWSGIRequestHandler(WSGIRequestHandler):
|
| 66 |
def log(self, type, message, *args):
|
|
|
|
| 69 |
return
|
| 70 |
return super().log(type, message, *args)
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
if __name__ == '__main__':
|
| 73 |
from config import create_default_admin_user
|
| 74 |
+
|
| 75 |
+
ensure_sqlite_database()
|
| 76 |
+
run_migrations() # Always ensure DB schema is up to date before serving
|
| 77 |
+
with app.app_context():
|
| 78 |
+
create_default_admin_user(app)
|
| 79 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
# Use filtered request handler
|
| 81 |
app.run(host='0.0.0.0', port=PORT, debug=True, request_handler=FilteredWSGIRequestHandler)
|
migrations/versions/d2485e8b5d85_add_saml_support.py
CHANGED
|
@@ -7,6 +7,7 @@ Create Date: 2025-12-08 11:21:20.267870
|
|
| 7 |
"""
|
| 8 |
from alembic import op
|
| 9 |
import sqlalchemy as sa
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
# revision identifiers, used by Alembic.
|
|
@@ -17,20 +18,148 @@ depends_on = None
|
|
| 17 |
|
| 18 |
|
| 19 |
def upgrade():
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# ### end Alembic commands ###
|
| 27 |
|
| 28 |
|
| 29 |
def downgrade():
|
| 30 |
-
# ### commands auto generated by Alembic - please adjust! ###
|
| 31 |
with op.batch_alter_table('users', schema=None) as batch_op:
|
| 32 |
-
batch_op.drop_constraint(
|
| 33 |
batch_op.drop_index('ix_users_tenant_id')
|
| 34 |
batch_op.drop_column('tenant_id')
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
from alembic import op
|
| 9 |
import sqlalchemy as sa
|
| 10 |
+
from sqlalchemy import inspect
|
| 11 |
|
| 12 |
|
| 13 |
# revision identifiers, used by Alembic.
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def upgrade():
|
| 21 |
+
"""Ensure all SAML/tenant tables and relationships exist."""
|
| 22 |
+
bind = op.get_bind()
|
| 23 |
+
ensure_tenants(bind)
|
| 24 |
+
ensure_saml_configurations(bind)
|
| 25 |
+
ensure_external_identities(bind)
|
| 26 |
+
ensure_users_tenant_relationship(bind)
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def downgrade():
|
|
|
|
| 30 |
with op.batch_alter_table('users', schema=None) as batch_op:
|
| 31 |
+
batch_op.drop_constraint('fk_users_tenant_id_tenants', type_='foreignkey')
|
| 32 |
batch_op.drop_index('ix_users_tenant_id')
|
| 33 |
batch_op.drop_column('tenant_id')
|
| 34 |
|
| 35 |
+
op.drop_table('external_identities')
|
| 36 |
+
op.drop_table('saml_configurations')
|
| 37 |
+
op.drop_table('tenants')
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def ensure_tenants(bind):
|
| 41 |
+
inspector = inspect(bind)
|
| 42 |
+
tables = inspector.get_table_names()
|
| 43 |
+
if 'tenants' not in tables:
|
| 44 |
+
op.create_table(
|
| 45 |
+
'tenants',
|
| 46 |
+
sa.Column('id', sa.Integer(), primary_key=True),
|
| 47 |
+
sa.Column('name', sa.String(length=128), nullable=False),
|
| 48 |
+
sa.Column('slug', sa.String(length=64), nullable=False, unique=True),
|
| 49 |
+
sa.Column('description', sa.Text(), nullable=True),
|
| 50 |
+
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
| 51 |
+
sa.Column('allow_password_auth', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
| 52 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 53 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 54 |
+
)
|
| 55 |
+
op.create_index('ix_tenants_slug', 'tenants', ['slug'], unique=False)
|
| 56 |
+
op.create_index('ix_tenants_active', 'tenants', ['active'], unique=False)
|
| 57 |
+
else:
|
| 58 |
+
existing_indexes = {idx['name'] for idx in inspector.get_indexes('tenants')}
|
| 59 |
+
if 'ix_tenants_slug' not in existing_indexes:
|
| 60 |
+
op.create_index('ix_tenants_slug', 'tenants', ['slug'], unique=False)
|
| 61 |
+
if 'ix_tenants_active' not in existing_indexes:
|
| 62 |
+
op.create_index('ix_tenants_active', 'tenants', ['active'], unique=False)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def ensure_saml_configurations(bind):
|
| 66 |
+
inspector = inspect(bind)
|
| 67 |
+
tables = inspector.get_table_names()
|
| 68 |
+
if 'saml_configurations' not in tables:
|
| 69 |
+
op.create_table(
|
| 70 |
+
'saml_configurations',
|
| 71 |
+
sa.Column('id', sa.Integer(), primary_key=True),
|
| 72 |
+
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', name='fk_saml_configurations_tenant_id'), nullable=False),
|
| 73 |
+
sa.Column('name', sa.String(length=128), nullable=False),
|
| 74 |
+
sa.Column('slug', sa.String(length=64), nullable=False),
|
| 75 |
+
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
| 76 |
+
sa.Column('metadata_xml', sa.Text(), nullable=False),
|
| 77 |
+
sa.Column('idp_entity_id', sa.String(length=512), nullable=False),
|
| 78 |
+
sa.Column('idp_sso_url', sa.String(length=512), nullable=False),
|
| 79 |
+
sa.Column('idp_slo_url', sa.String(length=512), nullable=True),
|
| 80 |
+
sa.Column('idp_certificate', sa.Text(), nullable=False),
|
| 81 |
+
sa.Column('sp_entity_id', sa.String(length=512), nullable=True),
|
| 82 |
+
sa.Column('acs_url', sa.String(length=512), nullable=True),
|
| 83 |
+
sa.Column('slo_url', sa.String(length=512), nullable=True),
|
| 84 |
+
sa.Column('attribute_mapping', sa.JSON(), nullable=True),
|
| 85 |
+
sa.Column('auto_provision_users', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
| 86 |
+
sa.Column('allow_idp_initiated', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
|
| 87 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 88 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 89 |
+
sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id', name='fk_saml_configurations_created_by'), nullable=True),
|
| 90 |
+
sa.UniqueConstraint('tenant_id', 'slug', name='uq_saml_tenant_slug'),
|
| 91 |
+
)
|
| 92 |
+
op.create_index('ix_saml_configurations_tenant_id', 'saml_configurations', ['tenant_id'], unique=False)
|
| 93 |
+
op.create_index('ix_saml_configurations_enabled', 'saml_configurations', ['enabled'], unique=False)
|
| 94 |
+
else:
|
| 95 |
+
existing_indexes = {idx['name'] for idx in inspector.get_indexes('saml_configurations')}
|
| 96 |
+
if 'ix_saml_configurations_tenant_id' not in existing_indexes:
|
| 97 |
+
op.create_index('ix_saml_configurations_tenant_id', 'saml_configurations', ['tenant_id'], unique=False)
|
| 98 |
+
if 'ix_saml_configurations_enabled' not in existing_indexes:
|
| 99 |
+
op.create_index('ix_saml_configurations_enabled', 'saml_configurations', ['enabled'], unique=False)
|
| 100 |
+
|
| 101 |
+
existing_uniques = {uc['name'] for uc in inspector.get_unique_constraints('saml_configurations')}
|
| 102 |
+
if 'uq_saml_tenant_slug' not in existing_uniques:
|
| 103 |
+
op.create_unique_constraint('uq_saml_tenant_slug', 'saml_configurations', ['tenant_id', 'slug'])
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def ensure_external_identities(bind):
|
| 107 |
+
inspector = inspect(bind)
|
| 108 |
+
tables = inspector.get_table_names()
|
| 109 |
+
if 'external_identities' not in tables:
|
| 110 |
+
op.create_table(
|
| 111 |
+
'external_identities',
|
| 112 |
+
sa.Column('id', sa.Integer(), primary_key=True),
|
| 113 |
+
sa.Column('provider', sa.String(length=64), nullable=False),
|
| 114 |
+
sa.Column('provider_id', sa.Integer(), nullable=True),
|
| 115 |
+
sa.Column('external_id', sa.String(length=255), nullable=False),
|
| 116 |
+
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', name='fk_external_identities_tenant_id'), nullable=True),
|
| 117 |
+
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', name='fk_external_identities_user_id'), nullable=False),
|
| 118 |
+
sa.Column('attributes', sa.JSON(), nullable=True),
|
| 119 |
+
sa.Column('last_login_at', sa.DateTime(), nullable=True),
|
| 120 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
| 121 |
+
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
| 122 |
+
sa.UniqueConstraint('provider', 'provider_id', 'external_id', name='uq_external_identity'),
|
| 123 |
+
)
|
| 124 |
+
op.create_index('ix_external_identities_user_id', 'external_identities', ['user_id'], unique=False)
|
| 125 |
+
op.create_index('ix_external_identities_tenant_id', 'external_identities', ['tenant_id'], unique=False)
|
| 126 |
+
else:
|
| 127 |
+
existing_indexes = {idx['name'] for idx in inspector.get_indexes('external_identities')}
|
| 128 |
+
if 'ix_external_identities_user_id' not in existing_indexes:
|
| 129 |
+
op.create_index('ix_external_identities_user_id', 'external_identities', ['user_id'], unique=False)
|
| 130 |
+
if 'ix_external_identities_tenant_id' not in existing_indexes:
|
| 131 |
+
op.create_index('ix_external_identities_tenant_id', 'external_identities', ['tenant_id'], unique=False)
|
| 132 |
+
|
| 133 |
+
existing_uniques = {uc['name'] for uc in inspector.get_unique_constraints('external_identities')}
|
| 134 |
+
if 'uq_external_identity' not in existing_uniques:
|
| 135 |
+
op.create_unique_constraint('uq_external_identity', 'external_identities', ['provider', 'provider_id', 'external_id'])
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def ensure_users_tenant_relationship(bind):
|
| 139 |
+
inspector = inspect(bind)
|
| 140 |
+
columns = {col['name'] for col in inspector.get_columns('users')}
|
| 141 |
+
existing_indexes = {idx['name'] for idx in inspector.get_indexes('users')}
|
| 142 |
+
existing_fks = inspector.get_foreign_keys('users')
|
| 143 |
+
|
| 144 |
+
fk_exists = any(
|
| 145 |
+
fk.get('name') == 'fk_users_tenant_id_tenants' or
|
| 146 |
+
(fk.get('referred_table') == 'tenants' and fk.get('constrained_columns') == ['tenant_id'])
|
| 147 |
+
for fk in existing_fks
|
| 148 |
+
)
|
| 149 |
+
needs_column = 'tenant_id' not in columns
|
| 150 |
+
needs_fk = not fk_exists
|
| 151 |
+
|
| 152 |
+
if needs_column or needs_fk:
|
| 153 |
+
with op.batch_alter_table('users', schema=None) as batch_op:
|
| 154 |
+
if needs_column:
|
| 155 |
+
batch_op.add_column(sa.Column('tenant_id', sa.Integer(), nullable=True))
|
| 156 |
+
if needs_fk:
|
| 157 |
+
batch_op.create_foreign_key(
|
| 158 |
+
'fk_users_tenant_id_tenants',
|
| 159 |
+
'tenants',
|
| 160 |
+
['tenant_id'],
|
| 161 |
+
['id']
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
if 'ix_users_tenant_id' not in existing_indexes:
|
| 165 |
+
op.create_index('ix_users_tenant_id', 'users', ['tenant_id'], unique=False)
|