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