diff --git a/hydra_base/db/alembic/versions/f1a2b3c4d5e6_tuser_flask_security_columns.py b/hydra_base/db/alembic/versions/f1a2b3c4d5e6_tuser_flask_security_columns.py new file mode 100644 index 00000000..a86bdc39 --- /dev/null +++ b/hydra_base/db/alembic/versions/f1a2b3c4d5e6_tuser_flask_security_columns.py @@ -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) diff --git a/hydra_base/db/model/permissions.py b/hydra_base/db/model/permissions.py index a16dfc1d..d45c4f1a 100644 --- a/hydra_base/db/model/permissions.py +++ b/hydra_base/db/model/permissions.py @@ -16,6 +16,11 @@ # You should have received a copy of the GNU Lesser General Public License # along with HydraPlatform. If not, see # +import uuid + +from sqlalchemy import Boolean +from sqlalchemy.orm import validates + from .base import * #*************************************************** @@ -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.""" @@ -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) \ No newline at end of file