"Fossies" - the Fresh Open Source Software Archive

Member "keystone-18.0.0/keystone/identity/backends/sql_model.py" (14 Oct 2020, 18466 Bytes) of package /linux/misc/openstack/keystone-18.0.0.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "sql_model.py" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 17.0.0_vs_18.0.0.

    1 # Copyright 2012 OpenStack Foundation
    2 #
    3 # Licensed under the Apache License, Version 2.0 (the "License"); you may
    4 # not use this file except in compliance with the License. You may obtain
    5 # a copy of the License at
    6 #
    7 #      http://www.apache.org/licenses/LICENSE-2.0
    8 #
    9 # Unless required by applicable law or agreed to in writing, software
   10 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
   11 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
   12 # License for the specific language governing permissions and limitations
   13 # under the License.
   14 
   15 import datetime
   16 
   17 import sqlalchemy
   18 from sqlalchemy.ext.hybrid import hybrid_property
   19 from sqlalchemy import orm
   20 from sqlalchemy.orm import collections
   21 
   22 from keystone.common import password_hashing
   23 from keystone.common import resource_options
   24 from keystone.common import sql
   25 import keystone.conf
   26 from keystone.identity.backends import resource_options as iro
   27 
   28 
   29 CONF = keystone.conf.CONF
   30 
   31 
   32 class User(sql.ModelBase, sql.ModelDictMixinWithExtras):
   33     __tablename__ = 'user'
   34     attributes = ['id', 'name', 'domain_id', 'password', 'enabled',
   35                   'default_project_id', 'password_expires_at']
   36     readonly_attributes = ['id', 'password_expires_at', 'password']
   37     resource_options_registry = iro.USER_OPTIONS_REGISTRY
   38     id = sql.Column(sql.String(64), primary_key=True)
   39     domain_id = sql.Column(sql.String(64), nullable=False)
   40     _enabled = sql.Column('enabled', sql.Boolean)
   41     extra = sql.Column(sql.JsonBlob())
   42     default_project_id = sql.Column(sql.String(64), index=True)
   43     _resource_option_mapper = orm.relationship(
   44         'UserOption',
   45         single_parent=True,
   46         cascade='all,delete,delete-orphan',
   47         lazy='subquery',
   48         backref='user',
   49         collection_class=collections.attribute_mapped_collection('option_id'))
   50     local_user = orm.relationship('LocalUser', uselist=False,
   51                                   single_parent=True, lazy='joined',
   52                                   cascade='all,delete-orphan', backref='user')
   53     federated_users = orm.relationship('FederatedUser',
   54                                        single_parent=True,
   55                                        lazy='joined',
   56                                        cascade='all,delete-orphan',
   57                                        backref='user')
   58     nonlocal_user = orm.relationship('NonLocalUser',
   59                                      uselist=False,
   60                                      single_parent=True,
   61                                      lazy='joined',
   62                                      cascade='all,delete-orphan',
   63                                      backref='user')
   64     expiring_user_group_memberships = orm.relationship(
   65         'ExpiringUserGroupMembership',
   66         cascade='all, delete-orphan',
   67         backref="user"
   68     )
   69     created_at = sql.Column(sql.DateTime, nullable=True)
   70     last_active_at = sql.Column(sql.Date, nullable=True)
   71     # unique constraint needed here to support composite fk constraints
   72     __table_args__ = (sql.UniqueConstraint('id', 'domain_id'), {})
   73 
   74     # NOTE(stevemar): we use a hybrid property here because we leverage the
   75     # expression method, see `@name.expression` and `LocalUser.name` below.
   76     @hybrid_property
   77     def name(self):
   78         """Return the current user name."""
   79         if self.local_user:
   80             return self.local_user.name
   81         elif self.nonlocal_user:
   82             return self.nonlocal_user.name
   83         elif self.federated_users:
   84             return self.federated_users[0].display_name
   85         else:
   86             return None
   87 
   88     @name.setter
   89     def name(self, value):
   90         if self.federated_users:
   91             self.federated_users[0].display_name = value
   92         elif self.local_user:
   93             self.local_user.name = value
   94         else:
   95             self.local_user = LocalUser()
   96             self.local_user.name = value
   97 
   98     @name.expression
   99     def name(cls):
  100         return LocalUser.name
  101 
  102     # password properties
  103     @property
  104     def password_ref(self):
  105         """Return the current password ref."""
  106         if self.local_user and self.local_user.passwords:
  107             return self.local_user.passwords[-1]
  108         return None
  109 
  110     # NOTE(stevemar): we use a hybrid property here because we leverage the
  111     # expression method, see `@password.expression` and `Password.password`
  112     # below.
  113     @hybrid_property
  114     def password(self):
  115         """Return the current password."""
  116         if self.password_ref:
  117             return self.password_ref.password_hash
  118         return None
  119 
  120     @property
  121     def password_created_at(self):
  122         """Return when password was created at."""
  123         if self.password_ref:
  124             return self.password_ref.created_at
  125         return None
  126 
  127     @property
  128     def password_expires_at(self):
  129         """Return when password expires at."""
  130         if self.password_ref:
  131             return self.password_ref.expires_at
  132         return None
  133 
  134     @property
  135     def password_is_expired(self):
  136         """Return whether password is expired or not."""
  137         if self.password_expires_at and not self._password_expiry_exempt():
  138             return datetime.datetime.utcnow() >= self.password_expires_at
  139         return False
  140 
  141     @password.setter
  142     def password(self, value):
  143         now = datetime.datetime.utcnow()
  144         if not self.local_user:
  145             self.local_user = LocalUser()
  146         # truncate extra passwords
  147         if self.local_user.passwords:
  148             unique_cnt = CONF.security_compliance.unique_last_password_count
  149             unique_cnt = unique_cnt + 1 if unique_cnt == 0 else unique_cnt
  150             self.local_user.passwords = self.local_user.passwords[-unique_cnt:]
  151         # set all previous passwords to be expired
  152         for ref in self.local_user.passwords:
  153             if not ref.expires_at or ref.expires_at > now:
  154                 ref.expires_at = now
  155         new_password_ref = Password()
  156 
  157         hashed_passwd = None
  158         if value is not None:
  159             # NOTE(notmorgan): hash the passwords, never directly bind the
  160             # "value" in the unhashed form to hashed_passwd to ensure the
  161             # unhashed password cannot end up in the db. If an unhashed
  162             # password ends up in the DB, it cannot be used for auth, it is
  163             # however incorrect and could leak user credentials (due to users
  164             # doing insecure things such as sharing passwords across
  165             # different systems) to unauthorized parties.
  166             hashed_passwd = password_hashing.hash_password(value)
  167 
  168         new_password_ref.password_hash = hashed_passwd
  169         new_password_ref.created_at = now
  170         new_password_ref.expires_at = self._get_password_expires_at(now)
  171         self.local_user.passwords.append(new_password_ref)
  172 
  173     def _password_expiry_exempt(self):
  174         # Get the IGNORE_PASSWORD_EXPIRY_OPT value from the user's
  175         # option_mapper.
  176         return getattr(
  177             self.get_resource_option(iro.IGNORE_PASSWORD_EXPIRY_OPT.option_id),
  178             'option_value',
  179             False)
  180 
  181     def _get_password_expires_at(self, created_at):
  182         expires_days = CONF.security_compliance.password_expires_days
  183         if not self._password_expiry_exempt():
  184             if expires_days:
  185                 expired_date = (created_at +
  186                                 datetime.timedelta(days=expires_days))
  187                 return expired_date.replace(microsecond=0)
  188         return None
  189 
  190     @password.expression
  191     def password(cls):
  192         return Password.password_hash
  193 
  194     # NOTE(stevemar): we use a hybrid property here because we leverage the
  195     # expression method, see `@enabled.expression` and `User._enabled` below.
  196     @hybrid_property
  197     def enabled(self):
  198         """Return whether user is enabled or not."""
  199         if self._enabled:
  200             max_days = (
  201                 CONF.security_compliance.disable_user_account_days_inactive)
  202             inactivity_exempt = getattr(
  203                 self.get_resource_option(
  204                     iro.IGNORE_USER_INACTIVITY_OPT.option_id),
  205                 'option_value',
  206                 False)
  207             last_active = self.last_active_at
  208             if not last_active and self.created_at:
  209                 last_active = self.created_at.date()
  210             if max_days and last_active:
  211                 now = datetime.datetime.utcnow().date()
  212                 days_inactive = (now - last_active).days
  213                 if days_inactive >= max_days and not inactivity_exempt:
  214                     self._enabled = False
  215         return self._enabled
  216 
  217     @enabled.setter
  218     def enabled(self, value):
  219         if (value and
  220                 CONF.security_compliance.disable_user_account_days_inactive):
  221             self.last_active_at = datetime.datetime.utcnow().date()
  222         if value and self.local_user:
  223             self.local_user.failed_auth_count = 0
  224             self.local_user.failed_auth_at = None
  225         self._enabled = value
  226 
  227     @enabled.expression
  228     def enabled(cls):
  229         return User._enabled
  230 
  231     def get_resource_option(self, option_id):
  232         if option_id in self._resource_option_mapper.keys():
  233             return self._resource_option_mapper[option_id]
  234         return None
  235 
  236     def to_dict(self, include_extra_dict=False):
  237         d = super(User, self).to_dict(include_extra_dict=include_extra_dict)
  238         if 'default_project_id' in d and d['default_project_id'] is None:
  239             del d['default_project_id']
  240         # NOTE(notmorgan): Eventually it may make sense to drop the empty
  241         # option dict creation to the superclass (if enough models use it)
  242         d['options'] = resource_options.ref_mapper_to_dict_options(self)
  243         return d
  244 
  245     @classmethod
  246     def from_dict(cls, user_dict):
  247         """Override from_dict to remove password_expires_at attribute.
  248 
  249         Overriding this method to remove password_expires_at attribute to
  250         support update_user and unit tests where password_expires_at
  251         inadvertently gets added by calling to_dict followed by from_dict.
  252 
  253         :param user_dict: User entity dictionary
  254         :returns User: User object
  255 
  256         """
  257         new_dict = user_dict.copy()
  258         resource_options = {}
  259         options = new_dict.pop('options', {})
  260         password_expires_at_key = 'password_expires_at'  # nosec
  261         if password_expires_at_key in user_dict:
  262             del new_dict[password_expires_at_key]
  263         for opt in cls.resource_options_registry.options:
  264             if opt.option_name in options:
  265                 opt_value = options[opt.option_name]
  266                 # NOTE(notmorgan): None is always a valid type
  267                 if opt_value is not None:
  268                     opt.validator(opt_value)
  269                 resource_options[opt.option_id] = opt_value
  270         user_obj = super(User, cls).from_dict(new_dict)
  271         setattr(user_obj, '_resource_options', resource_options)
  272         return user_obj
  273 
  274 
  275 class LocalUser(sql.ModelBase, sql.ModelDictMixin):
  276     __tablename__ = 'local_user'
  277     attributes = ['id', 'user_id', 'domain_id', 'name']
  278     id = sql.Column(sql.Integer, primary_key=True)
  279     user_id = sql.Column(sql.String(64))
  280     domain_id = sql.Column(sql.String(64), nullable=False)
  281     name = sql.Column(sql.String(255), nullable=False)
  282     passwords = orm.relationship('Password',
  283                                  single_parent=True,
  284                                  cascade='all,delete-orphan',
  285                                  lazy='joined',
  286                                  backref='local_user',
  287                                  order_by='Password.created_at_int')
  288     failed_auth_count = sql.Column(sql.Integer, nullable=True)
  289     failed_auth_at = sql.Column(sql.DateTime, nullable=True)
  290     __table_args__ = (
  291         sql.UniqueConstraint('user_id'),
  292         sql.UniqueConstraint('domain_id', 'name'),
  293         sqlalchemy.ForeignKeyConstraint(['user_id', 'domain_id'],
  294                                         ['user.id', 'user.domain_id'],
  295                                         onupdate='CASCADE', ondelete='CASCADE')
  296     )
  297 
  298 
  299 class Password(sql.ModelBase, sql.ModelDictMixin):
  300     __tablename__ = 'password'
  301     attributes = ['id', 'local_user_id', 'password_hash', 'created_at',
  302                   'expires_at']
  303     id = sql.Column(sql.Integer, primary_key=True)
  304     local_user_id = sql.Column(sql.Integer, sql.ForeignKey('local_user.id',
  305                                ondelete='CASCADE'))
  306     password_hash = sql.Column(sql.String(255), nullable=True)
  307 
  308     # TODO(lbragstad): Once Rocky opens for development, the _created_at and
  309     # _expires_at attributes/columns can be removed from the schema. The
  310     # migration ensures all passwords are converted from datetime objects to
  311     # big integers. The old datetime columns and their corresponding attributes
  312     # in the model are no longer required.
  313     # created_at default set here to safe guard in case it gets missed
  314     _created_at = sql.Column('created_at', sql.DateTime, nullable=False,
  315                              default=datetime.datetime.utcnow)
  316     _expires_at = sql.Column('expires_at', sql.DateTime, nullable=True)
  317     # set the default to 0, a 0 indicates it is unset.
  318     created_at_int = sql.Column(sql.DateTimeInt(), nullable=False,
  319                                 default=datetime.datetime.utcnow)
  320     expires_at_int = sql.Column(sql.DateTimeInt(), nullable=True)
  321     self_service = sql.Column(sql.Boolean, default=False, nullable=False,
  322                               server_default='0')
  323 
  324     @hybrid_property
  325     def created_at(self):
  326         return self.created_at_int or self._created_at
  327 
  328     @created_at.setter
  329     def created_at(self, value):
  330         self._created_at = value
  331         self.created_at_int = value
  332 
  333     @hybrid_property
  334     def expires_at(self):
  335         return self.expires_at_int or self._expires_at
  336 
  337     @expires_at.setter
  338     def expires_at(self, value):
  339         self._expires_at = value
  340         self.expires_at_int = value
  341 
  342 
  343 class FederatedUser(sql.ModelBase, sql.ModelDictMixin):
  344     __tablename__ = 'federated_user'
  345     attributes = ['id', 'user_id', 'idp_id', 'protocol_id', 'unique_id',
  346                   'display_name']
  347     id = sql.Column(sql.Integer, primary_key=True)
  348     user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id',
  349                                                         ondelete='CASCADE'))
  350     idp_id = sql.Column(sql.String(64), sql.ForeignKey('identity_provider.id',
  351                                                        ondelete='CASCADE'))
  352     protocol_id = sql.Column(sql.String(64), nullable=False)
  353     unique_id = sql.Column(sql.String(255), nullable=False)
  354     display_name = sql.Column(sql.String(255), nullable=True)
  355     __table_args__ = (
  356         sql.UniqueConstraint('idp_id', 'protocol_id', 'unique_id'),
  357         sqlalchemy.ForeignKeyConstraint(['protocol_id', 'idp_id'],
  358                                         ['federation_protocol.id',
  359                                          'federation_protocol.idp_id'],
  360                                         ondelete='CASCADE')
  361     )
  362 
  363 
  364 class NonLocalUser(sql.ModelBase, sql.ModelDictMixin):
  365     """SQL data model for nonlocal users (LDAP and custom)."""
  366 
  367     __tablename__ = 'nonlocal_user'
  368     attributes = ['domain_id', 'name', 'user_id']
  369     domain_id = sql.Column(sql.String(64), primary_key=True)
  370     name = sql.Column(sql.String(255), primary_key=True)
  371     user_id = sql.Column(sql.String(64))
  372     __table_args__ = (
  373         sql.UniqueConstraint('user_id'),
  374         sqlalchemy.ForeignKeyConstraint(
  375             ['user_id', 'domain_id'], ['user.id', 'user.domain_id'],
  376             onupdate='CASCADE', ondelete='CASCADE'),)
  377 
  378 
  379 class Group(sql.ModelBase, sql.ModelDictMixinWithExtras):
  380     __tablename__ = 'group'
  381     attributes = ['id', 'name', 'domain_id', 'description']
  382     id = sql.Column(sql.String(64), primary_key=True)
  383     name = sql.Column(sql.String(64), nullable=False)
  384     domain_id = sql.Column(sql.String(64), nullable=False)
  385     description = sql.Column(sql.Text())
  386     extra = sql.Column(sql.JsonBlob())
  387     expiring_user_group_memberships = orm.relationship(
  388         'ExpiringUserGroupMembership',
  389         cascade='all, delete-orphan',
  390         backref="group"
  391     )
  392     # Unique constraint across two columns to create the separation
  393     # rather than just only 'name' being unique
  394     __table_args__ = (sql.UniqueConstraint('domain_id', 'name'),)
  395 
  396 
  397 class UserGroupMembership(sql.ModelBase, sql.ModelDictMixin):
  398     """Group membership join table."""
  399 
  400     __tablename__ = 'user_group_membership'
  401     user_id = sql.Column(sql.String(64),
  402                          sql.ForeignKey('user.id'),
  403                          primary_key=True)
  404     group_id = sql.Column(sql.String(64),
  405                           sql.ForeignKey('group.id'),
  406                           primary_key=True)
  407 
  408 
  409 class ExpiringUserGroupMembership(sql.ModelBase, sql.ModelDictMixin):
  410     """Expiring group membership through federation mapping rules."""
  411 
  412     __tablename__ = 'expiring_user_group_membership'
  413     user_id = sql.Column(sql.String(64),
  414                          sql.ForeignKey('user.id'),
  415                          primary_key=True)
  416     group_id = sql.Column(sql.String(64),
  417                           sql.ForeignKey('group.id'),
  418                           primary_key=True)
  419     idp_id = sql.Column(sql.String(64),
  420                         sql.ForeignKey('identity_provider.id',
  421                                        ondelete='CASCADE'),
  422                         primary_key=True)
  423     last_verified = sql.Column(sql.DateTime, nullable=False)
  424 
  425     @hybrid_property
  426     def expires(self):
  427         ttl = self.idp.authorization_ttl
  428         if not ttl:
  429             ttl = CONF.federation.default_authorization_ttl
  430         return self.last_verified + datetime.timedelta(minutes=ttl)
  431 
  432     @hybrid_property
  433     def expired(self):
  434         return self.expires <= datetime.datetime.utcnow()
  435 
  436 
  437 class UserOption(sql.ModelBase):
  438     __tablename__ = 'user_option'
  439     user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id',
  440                          ondelete='CASCADE'), nullable=False,
  441                          primary_key=True)
  442     option_id = sql.Column(sql.String(4), nullable=False,
  443                            primary_key=True)
  444     option_value = sql.Column(sql.JsonBlob, nullable=True)
  445 
  446     def __init__(self, option_id, option_value):
  447         self.option_id = option_id
  448         self.option_value = option_value