Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Add Flask-Security columns to tUser and backfill from hwi.user

Revision ID: f1a2b3c4d5e6
Revises: b7f3e1a92c44
Create Date: 2026-07-16 00:00:00.000000

"""
from alembic import op
import sqlalchemy as sa
import uuid

import logging
log = logging.getLogger(__name__)

# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5e6'
down_revision = 'b7f3e1a92c44'
branch_labels = None
depends_on = None

ROOT_USER_ID = 1
ROOT_PLACEHOLDER_EMAIL = 'root@system.internal'

NEW_COLUMNS = [
sa.Column('email', sa.String(255), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('confirmed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('first_name', sa.String(255), nullable=True),
sa.Column('last_name', sa.String(255), nullable=True),
sa.Column('demographic', sa.String(255), nullable=True),
sa.Column('country_code', sa.String(255), nullable=True),
sa.Column('organization', sa.String(255), nullable=True),
sa.Column('current_login_at', sa.TIMESTAMP(), nullable=True),
sa.Column('last_login_ip', sa.String(255), nullable=True),
sa.Column('current_login_ip', sa.String(255), nullable=True),
sa.Column('login_count', sa.Integer(), nullable=True),
sa.Column('fs_uniquifier', sa.String(255), nullable=True),
]


def upgrade():
bind = op.get_bind()

for col in NEW_COLUMNS:
try:
op.add_column('tUser', col)
except Exception as e:
log.exception(e)

# Backfill from hwi's `user` table -- same physical database, matched
# 1:1 on email/username except tUser.id == ROOT_USER_ID, which has no
# corresponding hwi login and is handled separately below.
try:
bind.execute(sa.text("""
UPDATE tUser t
JOIN user u ON LOWER(u.email) = LOWER(t.username)
SET t.email = u.email,
t.active = u.active,
t.confirmed_at = u.confirmed_at,
t.first_name = u.first_name,
t.last_name = u.last_name,
t.demographic = u.demographic,
t.country_code = u.country_code,
t.organization = u.organization,
t.current_login_at = u.current_login_at,
t.last_login_ip = u.last_login_ip,
t.current_login_ip = u.current_login_ip,
t.login_count = u.login_count
"""))
except Exception as e:
log.exception(e)

# Root/system user has no hwi.user counterpart: give it a synthetic,
# inactive identity so email/fs_uniquifier stay NOT NULL uniformly
# rather than carving out a permanent nullable exception.
try:
bind.execute(
sa.text("""
UPDATE tUser
SET email = :email, active = FALSE
WHERE id = :root_id AND email IS NULL
"""),
{"email": ROOT_PLACEHOLDER_EMAIL, "root_id": ROOT_USER_ID},
)
except Exception as e:
log.exception(e)

# fs_uniquifier has no natural source value -- generate one per row.
try:
result = bind.execute(sa.text("SELECT id FROM tUser WHERE fs_uniquifier IS NULL"))
for (user_id,) in result:
bind.execute(
sa.text("UPDATE tUser SET fs_uniquifier = :fsu WHERE id = :uid"),
{"fsu": uuid.uuid4().hex, "uid": user_id},
)
except Exception as e:
log.exception(e)

# Tighten constraints now that every row is populated.
try:
op.alter_column('tUser', 'email', type_=sa.String(255), nullable=False)
op.alter_column('tUser', 'active', type_=sa.Boolean(), nullable=False, server_default=sa.true())
op.alter_column('tUser', 'fs_uniquifier', type_=sa.String(255), nullable=False)
op.create_unique_constraint('uq_tuser_email', 'tUser', ['email'])
op.create_unique_constraint('uq_tuser_fs_uniquifier', 'tUser', ['fs_uniquifier'])
except Exception as e:
log.exception(e)


def downgrade():
try:
op.drop_constraint('uq_tuser_fs_uniquifier', 'tUser', type_='unique')
op.drop_constraint('uq_tuser_email', 'tUser', type_='unique')
except Exception as e:
log.exception(e)

for col in reversed(NEW_COLUMNS):
try:
op.drop_column('tUser', col.name)
except Exception as e:
log.exception(e)
76 changes: 75 additions & 1 deletion hydra_base/db/model/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
# You should have received a copy of the GNU Lesser General Public License
# along with HydraPlatform. If not, see <http://www.gnu.org/licenses/>
#
import uuid

from sqlalchemy import Boolean
from sqlalchemy.orm import validates

from .base import *

#***************************************************
Expand Down Expand Up @@ -107,21 +112,80 @@ class User(Base, Inspect):

id = Column(Integer(), primary_key=True, nullable=False)
username = Column(String(60), nullable=False, unique=True)
password = Column(LargeBinary(), nullable=False)
_password = Column('password', LargeBinary(), nullable=False)
display_name = Column(String(200), nullable=False, server_default=text(u"''"))
last_login = Column(TIMESTAMP())
last_edit = Column(TIMESTAMP())
cr_date = Column(TIMESTAMP(), nullable=False, server_default=text(u'CURRENT_TIMESTAMP'))
failed_logins = Column(SMALLINT, nullable=True, default=0)

# Flask-Security-required columns. This is the single mapping of
# tUser -- HWI's Flask-Security User model imports this class rather
# than declaring its own, so the schema is defined in one place.
email = Column(String(255), nullable=False, unique=True)
active = Column(Boolean(), nullable=False, server_default=text('1'))
confirmed_at = Column(TIMESTAMP())
first_name = Column(String(255))
last_name = Column(String(255))
demographic = Column(String(255))
country_code = Column(String(255))
organization = Column(String(255))
current_login_at = Column(TIMESTAMP())
last_login_ip = Column(String(255))
current_login_ip = Column(String(255))
login_count = Column(Integer)
fs_uniquifier = Column(String(255), nullable=False, unique=True, default=lambda: uuid.uuid4().hex)

_parents = []
_children = ['tRoleUser']

@hybrid_property
def password(self):
# Stored as LargeBinary (raw bcrypt hash bytes); exposed as str
# since that's what Flask-Security's hasher (and callers
# generally) work with. Bytes in, bytes out for anything that
# still passes bytes directly (e.g. hydra-base's own add_user).
if self._password is None:
return None
return self._password.decode('utf-8') if isinstance(self._password, bytes) else self._password

@password.setter
def password(self, value):
self._password = value.encode('utf-8') if isinstance(value, str) else value

@validates('email')
def _default_username_from_email(self, key, value):
# username is hydra-base's own NOT NULL identity column; every
# creation path (Flask-Security register/admin, hydra-base
# add_user) sets email, so default username from it unless a
# caller already set a different username.
if not self.username:
self.username = value
return value

def validate_password(self, password):
if bcrypt.hashpw(password.encode('utf-8'), self.password.encode('utf-8')) == self.password.encode('utf-8'):
return True
return False

# -- flask-login's expected duck-typed interface (deliberately not
# importing flask_login here -- hydra-base has non-Flask consumers,
# e.g. the Spyne-based hydra-server, and must stay framework-agnostic).
@property
def is_authenticated(self):
return True

@property
def is_active(self):
return bool(self.active)

@property
def is_anonymous(self):
return False

def get_id(self):
return str(self.id)

@property
def permissions(self):
"""Return a set with all permissions granted to the user."""
Expand All @@ -148,5 +212,15 @@ def is_admin(self):

return False

def has_role(self, role):
"""
Flask-Security/flask-login call this with a role code string
(e.g. 'admin', 'developer') -- hydra-base identifies roles by
`code`, not `name`, so this checks against that, not the
default RoleMixin behaviour of comparing role.name.
"""
role_code = role.code if hasattr(role, 'code') else role
return any(r.code == role_code for r in self.roles)

def __repr__(self):
return "{0}".format(self.username)
Loading