redash/models/users.py (335 lines of code) (raw):

import hashlib import itertools import logging import time from functools import reduce from operator import or_ from flask import current_app as app, url_for, request_started from flask_login import current_user, AnonymousUserMixin, UserMixin from passlib.apps import custom_app_context as pwd_context from sqlalchemy.exc import DBAPIError from sqlalchemy.dialects import postgresql from sqlalchemy_utils import EmailType from sqlalchemy_utils.models import generic_repr from redash import redis_connection from redash.utils import generate_token, utcnow, dt_from_timestamp from .base import db, Column, GFKBase, key_type, primary_key from .mixins import TimestampMixin, BelongsToOrgMixin from .types import json_cast_property, MutableDict, MutableList logger = logging.getLogger(__name__) LAST_ACTIVE_KEY = "users:last_active_at" def sync_last_active_at(): """ Update User model with the active_at timestamp from Redis. We first fetch all the user_ids to update, and then fetch the timestamp to minimize the time between fetching the value and updating the DB. This is because there might be a more recent update we skip otherwise. """ user_ids = redis_connection.hkeys(LAST_ACTIVE_KEY) for user_id in user_ids: timestamp = redis_connection.hget(LAST_ACTIVE_KEY, user_id) active_at = dt_from_timestamp(timestamp) user = User.query.filter(User.id == user_id).first() if user: user.active_at = active_at redis_connection.hdel(LAST_ACTIVE_KEY, user_id) db.session.commit() def update_user_active_at(sender, *args, **kwargs): """ Used as a Flask request_started signal callback that adds the current user's details to Redis """ if current_user.is_authenticated and not current_user.is_api_user(): redis_connection.hset(LAST_ACTIVE_KEY, current_user.id, int(time.time())) def init_app(app): """ A Flask extension to keep user details updates in Redis and sync it periodically to the database (User.details). """ request_started.connect(update_user_active_at, app) class PermissionsCheckMixin(object): def has_permission(self, permission): return self.has_permissions((permission,)) def has_permissions(self, permissions): has_permissions = reduce( lambda a, b: a and b, [permission in self.permissions for permission in permissions], True, ) return has_permissions @generic_repr("id", "name", "email") class User( TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCheckMixin ): id = primary_key("User") org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id")) org = db.relationship("Organization", backref=db.backref("users", lazy="dynamic")) name = Column(db.String(320)) email = Column(EmailType) _profile_image_url = Column("profile_image_url", db.String(320), nullable=True) password_hash = Column(db.String(128), nullable=True) group_ids = Column( "groups", MutableList.as_mutable(postgresql.ARRAY(key_type("Group"))), nullable=True ) api_key = Column(db.String(40), default=lambda: generate_token(40), unique=True) disabled_at = Column(db.DateTime(True), default=None, nullable=True) details = Column( MutableDict.as_mutable(postgresql.JSON), nullable=True, server_default="{}", default={}, ) active_at = json_cast_property( db.DateTime(True), "details", "active_at", default=None ) is_invitation_pending = json_cast_property( db.Boolean(True), "details", "is_invitation_pending", default=False ) is_email_verified = json_cast_property( db.Boolean(True), "details", "is_email_verified", default=True ) attributes = json_cast_property( db.PickleType(), "details", "attributes", default=None ) __tablename__ = "users" __table_args__ = (db.Index("users_org_id_email", "org_id", "email", unique=True),) def __str__(self): return "%s (%s)" % (self.name, self.email) def __init__(self, *args, **kwargs): if kwargs.get("email") is not None: kwargs["email"] = kwargs["email"].lower() super(User, self).__init__(*args, **kwargs) @property def is_disabled(self): return self.disabled_at is not None def disable(self): self.disabled_at = db.func.now() def enable(self): self.disabled_at = None def regenerate_api_key(self): self.api_key = generate_token(40) def to_dict(self, with_api_key=False): profile_image_url = self.profile_image_url if self.is_disabled: assets = app.extensions["webpack"]["assets"] or {} path = "images/avatar.svg" profile_image_url = url_for("static", filename=assets.get(path, path)) d = { "id": self.id, "name": self.name, "email": self.email, "profile_image_url": profile_image_url, "groups": self.group_ids, "updated_at": self.updated_at, "created_at": self.created_at, "disabled_at": self.disabled_at, "is_disabled": self.is_disabled, "active_at": self.active_at, "is_invitation_pending": self.is_invitation_pending, "is_email_verified": self.is_email_verified, } if self.password_hash is None: d["auth_type"] = "external" else: d["auth_type"] = "password" if with_api_key: d["api_key"] = self.api_key return d def is_api_user(self): return False @property def profile_image_url(self): if self._profile_image_url is not None: return self._profile_image_url email_md5 = hashlib.md5(self.email.lower().encode()).hexdigest() return "https://www.gravatar.com/avatar/{}?s=40&d=identicon".format(email_md5) @property def permissions(self): # TODO: this should be cached. return list( itertools.chain( *[ g.permissions for g in Group.query.filter(Group.id.in_(self.group_ids)) ] ) ) @classmethod def get_by_org(cls, org): return cls.query.filter(cls.org == org) @classmethod def get_by_id(cls, _id): return cls.query.filter(cls.id == _id).one() @classmethod def get_by_email_and_org(cls, email, org): return cls.get_by_org(org).filter(cls.email == email).one() @classmethod def get_by_api_key_and_org(cls, api_key, org): return cls.get_by_org(org).filter(cls.api_key == api_key).one() @classmethod def all(cls, org): return cls.get_by_org(org).filter(cls.disabled_at.is_(None)) @classmethod def all_disabled(cls, org): return cls.get_by_org(org).filter(cls.disabled_at.isnot(None)) @classmethod def search(cls, base_query, term): term = "%{}%".format(term) search_filter = or_(cls.name.ilike(term), cls.email.like(term)) return base_query.filter(search_filter) @classmethod def pending(cls, base_query, pending): if pending: return base_query.filter(cls.is_invitation_pending.is_(True)) else: return base_query.filter( cls.is_invitation_pending.isnot(True) ) # check for both `false`/`null` @classmethod def find_by_email(cls, email): return cls.query.filter(cls.email == email) def hash_password(self, password): self.password_hash = pwd_context.encrypt(password) def verify_password(self, password): return self.password_hash and pwd_context.verify(password, self.password_hash) def update_group_assignments(self, group_names): groups = Group.find_by_name(self.org, group_names) groups.append(self.org.default_group) self.group_ids = [g.id for g in groups] db.session.add(self) db.session.commit() def has_access(self, obj, access_type): return AccessPermission.exists(obj, access_type, grantee=self) def get_id(self): identity = hashlib.md5( "{},{}".format(self.email, self.password_hash).encode() ).hexdigest() return "{0}-{1}".format(self.id, identity) @generic_repr("id", "name", "type", "org_id") class Group(db.Model, BelongsToOrgMixin): DEFAULT_PERMISSIONS = [ "create_dashboard", "create_query", "edit_dashboard", "edit_query", "view_query", "view_source", "execute_query", "list_users", "schedule_query", "list_dashboards", "list_alerts", "list_data_sources", ] BUILTIN_GROUP = "builtin" REGULAR_GROUP = "regular" id = primary_key("Group") data_sources = db.relationship( "DataSourceGroup", back_populates="group", cascade="all" ) org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id")) org = db.relationship("Organization", back_populates="groups") type = Column(db.String(255), default=REGULAR_GROUP) name = Column(db.String(100)) permissions = Column(postgresql.ARRAY(db.String(255)), default=DEFAULT_PERMISSIONS) created_at = Column(db.DateTime(True), default=db.func.now()) __tablename__ = "groups" def __str__(self): return str(self.id) def to_dict(self): return { "id": self.id, "name": self.name, "permissions": self.permissions, "type": self.type, "created_at": self.created_at, } @classmethod def all(cls, org): return cls.query.filter(cls.org == org) @classmethod def members(cls, group_id): return User.query.filter(User.group_ids.any(group_id)) @classmethod def find_by_name(cls, org, group_names): result = cls.query.filter(cls.org == org, cls.name.in_(group_names)) return list(result) @generic_repr( "id", "object_type", "object_id", "access_type", "grantor_id", "grantee_id" ) class AccessPermission(GFKBase, db.Model): id = primary_key("AccessPermission") # 'object' defined in GFKBase access_type = Column(db.String(255)) grantor_id = Column(key_type("User"), db.ForeignKey("users.id")) grantor = db.relationship(User, backref="grantor", foreign_keys=[grantor_id]) grantee_id = Column(key_type("User"), db.ForeignKey("users.id")) grantee = db.relationship(User, backref="grantee", foreign_keys=[grantee_id]) __tablename__ = "access_permissions" @classmethod def grant(cls, obj, access_type, grantee, grantor): grant = cls.query.filter( cls.object_type == obj.__tablename__, cls.object_id == obj.id, cls.access_type == access_type, cls.grantee == grantee, cls.grantor == grantor, ).one_or_none() if not grant: grant = cls( object_type=obj.__tablename__, object_id=obj.id, access_type=access_type, grantee=grantee, grantor=grantor, ) db.session.add(grant) return grant @classmethod def revoke(cls, obj, grantee, access_type=None): permissions = cls._query(obj, access_type, grantee) return permissions.delete() @classmethod def find(cls, obj, access_type=None, grantee=None, grantor=None): return cls._query(obj, access_type, grantee, grantor) @classmethod def exists(cls, obj, access_type, grantee): return cls.find(obj, access_type, grantee).count() > 0 @classmethod def _query(cls, obj, access_type=None, grantee=None, grantor=None): q = cls.query.filter( cls.object_id == obj.id, cls.object_type == obj.__tablename__ ) if access_type: q = q.filter(AccessPermission.access_type == access_type) if grantee: q = q.filter(AccessPermission.grantee == grantee) if grantor: q = q.filter(AccessPermission.grantor == grantor) return q def to_dict(self): d = { "id": self.id, "object_id": self.object_id, "object_type": self.object_type, "access_type": self.access_type, "grantor": self.grantor_id, "grantee": self.grantee_id, } return d class AnonymousUser(AnonymousUserMixin, PermissionsCheckMixin): @property def permissions(self): return [] def is_api_user(self): return False class ApiUser(UserMixin, PermissionsCheckMixin): def __init__(self, api_key, org, groups, name=None): self.object = None if isinstance(api_key, str): self.id = api_key self.name = name else: self.id = api_key.api_key self.name = "ApiKey: {}".format(api_key.id) self.object = api_key.object self.group_ids = groups self.org = org def __repr__(self): return "<{}>".format(self.name) def is_api_user(self): return True @property def org_id(self): if not self.org: return None return self.org.id @property def permissions(self): return ["view_query"] def has_access(self, obj, access_type): return False