diff --git a/config.py b/config.py index 369c850c2..f91637992 100644 --- a/config.py +++ b/config.py @@ -527,3 +527,7 @@ class DefaultConfig(ImmutableConfig): # Defines the number of successive internal errors of a build trigger's build before the # trigger is automatically disabled. SUCCESSIVE_TRIGGER_INTERNAL_ERROR_DISABLE_THRESHOLD = 5 + + # Defines the delay required (in seconds) before the last_accessed field of a user/robot or access + # token will be updated after the previous update. + LAST_ACCESSED_UPDATE_THRESHOLD_S = 60 diff --git a/data/database.py b/data/database.py index 4e767c9b0..422275f84 100644 --- a/data/database.py +++ b/data/database.py @@ -440,6 +440,8 @@ class User(BaseModel): location = CharField(null=True) maximum_queued_builds_count = IntegerField(null=True) + creation_date = DateTimeField(default=datetime.utcnow, null=True) + last_accessed = DateTimeField(null=True, index=True) def delete_instance(self, recursive=False, delete_nullable=False): # If we are deleting a robot account, only execute the subset of queries necessary. @@ -477,6 +479,12 @@ class User(BaseModel): Namespace = User.alias() +class RobotAccountMetadata(BaseModel): + robot_account = QuayUserField(index=True, allows_robots=True, unique=True) + description = CharField() + unstructured_json = JSONField() + + class DeletedNamespace(BaseModel): namespace = QuayUserField(index=True, allows_robots=False, unique=True) marked = DateTimeField(default=datetime.now) diff --git a/data/migrations/versions/0cf50323c78b_add_creation_date_to_user_table.py b/data/migrations/versions/0cf50323c78b_add_creation_date_to_user_table.py new file mode 100644 index 000000000..775c5caa7 --- /dev/null +++ b/data/migrations/versions/0cf50323c78b_add_creation_date_to_user_table.py @@ -0,0 +1,26 @@ +"""Add creation date to User table + +Revision ID: 0cf50323c78b +Revises: 87fbbc224f10 +Create Date: 2018-03-09 13:19:41.903196 + +""" + +# revision identifiers, used by Alembic. +revision = '0cf50323c78b' +down_revision = '87fbbc224f10' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('creation_date', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(tables): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'creation_date') + # ### end Alembic commands ### diff --git a/data/migrations/versions/224ce4c72c2f_add_last_accessed_field_to_user_table.py b/data/migrations/versions/224ce4c72c2f_add_last_accessed_field_to_user_table.py new file mode 100644 index 000000000..06326c566 --- /dev/null +++ b/data/migrations/versions/224ce4c72c2f_add_last_accessed_field_to_user_table.py @@ -0,0 +1,28 @@ +"""Add last_accessed field to User table + +Revision ID: 224ce4c72c2f +Revises: b547bc139ad8 +Create Date: 2018-03-12 22:44:07.070490 + +""" + +# revision identifiers, used by Alembic. +revision = '224ce4c72c2f' +down_revision = 'b547bc139ad8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('last_accessed', sa.DateTime(), nullable=True)) + op.create_index('user_last_accessed', 'user', ['last_accessed'], unique=False) + # ### end Alembic commands ### + + +def downgrade(tables): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('user_last_accessed', table_name='user') + op.drop_column('user', 'last_accessed') + # ### end Alembic commands ### diff --git a/data/migrations/versions/b547bc139ad8_add_robotaccountmetadata_table.py b/data/migrations/versions/b547bc139ad8_add_robotaccountmetadata_table.py new file mode 100644 index 000000000..0530963e7 --- /dev/null +++ b/data/migrations/versions/b547bc139ad8_add_robotaccountmetadata_table.py @@ -0,0 +1,35 @@ +"""Add RobotAccountMetadata table + +Revision ID: b547bc139ad8 +Revises: 0cf50323c78b +Create Date: 2018-03-09 15:50:48.298880 + +""" + +# revision identifiers, used by Alembic. +revision = 'b547bc139ad8' +down_revision = '0cf50323c78b' + +from alembic import op +import sqlalchemy as sa +from util.migrate import UTF8CharField + + +def upgrade(tables): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('robotaccountmetadata', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('robot_account_id', sa.Integer(), nullable=False), + sa.Column('description', UTF8CharField(length=255), nullable=False), + sa.Column('unstructured_json', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['robot_account_id'], ['user.id'], name=op.f('fk_robotaccountmetadata_robot_account_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_robotaccountmetadata')) + ) + op.create_index('robotaccountmetadata_robot_account_id', 'robotaccountmetadata', ['robot_account_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade(tables): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('robotaccountmetadata') + # ### end Alembic commands ### diff --git a/data/model/_basequery.py b/data/model/_basequery.py index 28b0b1952..571b8ac9b 100644 --- a/data/model/_basequery.py +++ b/data/model/_basequery.py @@ -1,11 +1,17 @@ -from peewee import fn +import logging + +from peewee import fn, PeeweeException from cachetools import lru_cache -from data.model import DataModelException +from datetime import datetime, timedelta + +from data.model import DataModelException, config from data.database import (Repository, User, Team, TeamMember, RepositoryPermission, TeamRole, Namespace, Visibility, ImageStorage, Image, RepositoryKind, db_for_update) +logger = logging.getLogger(__name__) + def reduce_as_tree(queries_to_reduce): """ This method will split a list of queries into halves recursively until we reach individual queries, at which point it will start unioning the queries, or the already unioned subqueries. @@ -164,3 +170,36 @@ def calculate_image_aggregate_size(ancestors_str, image_size, parent_image): return None return ancestor_size + image_size + + +def update_last_accessed(token_or_user): + """ Updates the `last_accessed` field on the given token or user. If the existing field's value + is within the configured threshold, the update is skipped. """ + threshold = timedelta(seconds=config.app_config.get('LAST_ACCESSED_UPDATE_THRESHOLD_S', 120)) + if (token_or_user.last_accessed is not None and + datetime.utcnow() - token_or_user.last_accessed < threshold): + # Skip updating, as we don't want to put undue pressure on the database. + return + + model_class = token_or_user.__class__ + last_accessed = datetime.utcnow() + + try: + (model_class + .update(last_accessed=last_accessed) + .where(model_class.id == token_or_user.id) + .execute()) + token_or_user.last_accessed = last_accessed + except PeeweeException as ex: + # If there is any form of DB exception, only fail if strict logging is enabled. + strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING') + if strict_logging_disabled: + data = { + 'exception': ex, + 'token_or_user': token_or_user.id, + 'class': str(model_class), + } + + logger.exception('update last_accessed for token/user failed', extra=data) + else: + raise diff --git a/data/model/appspecifictoken.py b/data/model/appspecifictoken.py index 4a1d1c686..10130c785 100644 --- a/data/model/appspecifictoken.py +++ b/data/model/appspecifictoken.py @@ -3,10 +3,10 @@ import logging from datetime import datetime from cachetools import lru_cache -from peewee import PeeweeException from data.database import AppSpecificAuthToken, User, db_transaction from data.model import config +from data.model._basequery import update_last_accessed from util.timedeltastring import convert_to_timedelta logger = logging.getLogger(__name__) @@ -104,23 +104,7 @@ def access_valid_token(token_code): ((AppSpecificAuthToken.expiration > datetime.now()) | (AppSpecificAuthToken.expiration >> None))) .get()) + update_last_accessed(token) + return token except AppSpecificAuthToken.DoesNotExist: return None - - token.last_accessed = datetime.now() - - try: - token.save() - except PeeweeException as ex: - strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING') - if strict_logging_disabled: - data = { - 'exception': ex, - 'token': token.id, - } - - logger.exception('update last_accessed for token failed', extra=data) - else: - raise - - return token diff --git a/data/model/user.py b/data/model/user.py index 0c86b5a7b..28942898b 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -14,7 +14,8 @@ from data.database import (User, LoginService, FederatedLogin, RepositoryPermiss EmailConfirmation, Role, db_for_update, random_string_generator, UserRegion, ImageStorageLocation, ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger, - UserPromptKind, UserPrompt, UserPromptTypes, DeletedNamespace) + UserPromptKind, UserPrompt, UserPromptTypes, DeletedNamespace, + RobotAccountMetadata) from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException, InvalidUsernameException, InvalidEmailAddressException, TooManyLoginAttemptsException, db_transaction, @@ -172,7 +173,7 @@ def change_username(user_id, new_username): user = db_for_update(User.select().where(User.id == user_id)).get() # Rename the robots - for robot in db_for_update(_list_entity_robots(user.username)): + for robot in db_for_update(_list_entity_robots(user.username, include_metadata=False)): _, robot_shortname = parse_robot_username(robot.username) new_robot_name = format_robot_username(new_username, robot_shortname) robot.username = new_robot_name @@ -231,7 +232,7 @@ def update_enabled(user, set_enabled): user.save() -def create_robot(robot_shortname, parent): +def create_robot(robot_shortname, parent, description='', unstructured_metadata=None): (username_valid, username_issue) = validate_username(robot_shortname) if not username_valid: raise InvalidRobotException('The name for the robot \'%s\' is invalid: %s' % @@ -245,33 +246,48 @@ def create_robot(robot_shortname, parent): msg = 'Existing robot with name: %s' % username logger.info(msg) raise InvalidRobotException(msg) - except User.DoesNotExist: pass + service = LoginService.get(name='quayrobot') try: - created = User.create(username=username, robot=True) + with db_transaction(): + created = User.create(username=username, robot=True) + password = created.email - service = LoginService.get(name='quayrobot') - password = created.email - FederatedLogin.create(user=created, service=service, - service_ident=password) - - return created, password + FederatedLogin.create(user=created, service=service, service_ident=password) + RobotAccountMetadata.create(robot_account=created, description=description[0:255], + unstructured_json=unstructured_metadata or {}) + return created, password except Exception as ex: raise DataModelException(ex.message) -def get_robot(robot_shortname, parent): +def get_or_create_robot_metadata(robot): + defaults = dict(description='', unstructured_json='{}') + metadata, _ = RobotAccountMetadata.get_or_create(robot_account=robot, defaults=defaults) + return metadata + + +def update_robot_metadata(robot, description='', unstructured_json=None): + """ Updates the description and user-specified unstructured metadata associated + with a robot account to that specified. """ + metadata = get_or_create_robot_metadata(robot) + metadata.description = description + metadata.unstructured_json = unstructured_json or metadata.unstructured_json or {} + metadata.save() + + +def get_robot_and_metadata(robot_shortname, parent): robot_username = format_robot_username(parent.username, robot_shortname) - robot = lookup_robot(robot_username) - return robot, robot.email + robot, metadata = lookup_robot_and_metadata(robot_username) + return robot, robot.email, metadata def lookup_robot(robot_username): try: return (User - .select() + .select(User, FederatedLogin) .join(FederatedLogin) .join(LoginService) .where(LoginService.name == 'quayrobot', User.username == robot_username, @@ -281,6 +297,11 @@ def lookup_robot(robot_username): raise InvalidRobotException('Could not find robot with username: %s' % robot_username) +def lookup_robot_and_metadata(robot_username): + robot = lookup_robot(robot_username) + return robot, get_or_create_robot_metadata(robot) + + def get_matching_robots(name_prefix, username, limit=10): admined_orgs = (_basequery.get_user_organizations(username) .switch(Team) @@ -328,12 +349,15 @@ def verify_robot(robot_username, password): if not owner.enabled: raise InvalidRobotException('This user has been disabled. Please contact your administrator.') + # Mark that the robot was accessed. + _basequery.update_last_accessed(robot) + return robot def regenerate_robot_token(robot_shortname, parent): robot_username = format_robot_username(parent.username, robot_shortname) - robot = lookup_robot(robot_username) + robot, metadata = lookup_robot_and_metadata(robot_username) password = random_string_generator(length=64)() robot.email = password robot.uuid = str(uuid4()) @@ -345,7 +369,7 @@ def regenerate_robot_token(robot_shortname, parent): login.save() robot.save() - return robot, password + return robot, password, metadata def delete_robot(robot_username): try: @@ -362,20 +386,28 @@ def list_namespace_robots(namespace): return _list_entity_robots(namespace) -def _list_entity_robots(entity_name): +def _list_entity_robots(entity_name, include_metadata=True): """ Return the list of robots for the specified entity. This MUST return a query, not a materialized list so that callers can use db_for_update. - """ - return (User - .select() - .join(FederatedLogin) - .where(User.robot == True, User.username ** (entity_name + '+%'))) + """ + query = (User + .select(User, FederatedLogin) + .join(FederatedLogin) + .where(User.robot == True, User.username ** (entity_name + '+%'))) + + if include_metadata: + query = (query.switch(User) + .join(RobotAccountMetadata, JOIN_LEFT_OUTER) + .select(User, FederatedLogin, RobotAccountMetadata)) + + return query def list_entity_robot_permission_teams(entity_name, include_permissions=False): query = (_list_entity_robots(entity_name)) - fields = [User.username, FederatedLogin.service_ident] + fields = [User.username, User.creation_date, User.last_accessed, FederatedLogin.service_ident, + RobotAccountMetadata.description, RobotAccountMetadata.unstructured_json] if include_permissions: query = (query .join(RepositoryPermission, JOIN_LEFT_OUTER, @@ -449,6 +481,10 @@ def verify_federated_login(service_id, service_ident): .switch(FederatedLogin).join(User) .where(FederatedLogin.service_ident == service_ident, LoginService.name == service_id) .get()) + + # Mark that the user was accessed. + _basequery.update_last_accessed(found.user) + return found.user except FederatedLogin.DoesNotExist: return None @@ -714,6 +750,9 @@ def verify_user(username_or_email, password): .where(User.id == fetched.id) .execute()) + # Mark that the user was accessed. + _basequery.update_last_accessed(fetched) + # Return the valid user. return fetched @@ -955,10 +994,10 @@ def get_pull_credentials(robotname): return None return { - 'username': robot.username, - 'password': login_info.service_ident, - 'registry': '%s://%s/v1/' % (config.app_config['PREFERRED_URL_SCHEME'], - config.app_config['SERVER_HOSTNAME']), + 'username': robot.username, + 'password': login_info.service_ident, + 'registry': '%s://%s/v1/' % (config.app_config['PREFERRED_URL_SCHEME'], + config.app_config['SERVER_HOSTNAME']), } def get_region_locations(user): diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index cf660b41c..b9c501b2c 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -321,7 +321,7 @@ def require_scope(scope_object): return wrapper -def validate_json_request(schema_name): +def validate_json_request(schema_name, optional=False): def wrapper(func): @add_method_metadata('request_schema', schema_name) @wraps(func) @@ -330,9 +330,10 @@ def validate_json_request(schema_name): try: json_data = request.get_json() if json_data is None: - raise InvalidRequest('Missing JSON body') - - validate(json_data, schema) + if not optional: + raise InvalidRequest('Missing JSON body') + else: + validate(json_data, schema) return func(self, *args, **kwargs) except ValidationError as ex: raise InvalidRequest(ex.message) diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py index 0e0cc64fc..e815498ce 100644 --- a/endpoints/api/robot.py +++ b/endpoints/api/robot.py @@ -2,14 +2,31 @@ from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource, require_user_admin, require_scope, path_param, parse_args, - truthy_bool, query_param) + truthy_bool, query_param, validate_json_request) from endpoints.api.robot_models_pre_oci import pre_oci_model as model from endpoints.exception import Unauthorized from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission from auth.auth_context import get_authenticated_user from auth import scopes from util.names import format_robot_username -from flask import abort +from flask import abort, request + + +CREATE_ROBOT_SCHEMA = { + 'type': 'object', + 'description': 'Optional data for creating a robot', + 'properties': { + 'description': { + 'type': 'string', + 'description': 'Optional text description for the robot', + 'maxLength': 255, + }, + 'unstructured_metadata': { + 'type': 'object', + 'description': 'Optional unstructured metadata for the robot', + }, + }, +} def robots_list(prefix, include_permissions=False): @@ -38,6 +55,9 @@ class UserRobotList(ApiResource): 'The short name for the robot, without any user or organization prefix') class UserRobot(ApiResource): """ Resource for managing a user's robots. """ + schemas = { + 'CreateRobot': CREATE_ROBOT_SCHEMA, + } @require_user_admin @nickname('getUserRobot') @@ -45,16 +65,23 @@ class UserRobot(ApiResource): """ Returns the user's robot with the specified name. """ parent = get_authenticated_user() robot = model.get_user_robot(robot_shortname, parent) - return robot.to_dict() + return robot.to_dict(include_metadata=True) @require_user_admin @nickname('createUserRobot') + @validate_json_request('CreateRobot', optional=True) def put(self, robot_shortname): """ Create a new user robot with the specified name. """ parent = get_authenticated_user() - robot = model.create_user_robot(robot_shortname, parent) - log_action('create_robot', parent.username, {'robot': robot_shortname}) - return robot.to_dict(), 201 + create_data = request.get_json() or {} + robot = model.create_user_robot(robot_shortname, parent, create_data.get('description'), + create_data.get('unstructured_metadata')) + log_action('create_robot', parent.username, { + 'robot': robot_shortname, + 'description': create_data.get('description'), + 'unstructured_metadata': create_data.get('unstructured_metadata'), + }) + return robot.to_dict(include_metadata=True), 201 @require_user_admin @nickname('deleteUserRobot') @@ -94,6 +121,9 @@ class OrgRobotList(ApiResource): @related_user_resource(UserRobot) class OrgRobot(ApiResource): """ Resource for managing an organization's robots. """ + schemas = { + 'CreateRobot': CREATE_ROBOT_SCHEMA, + } @require_scope(scopes.ORG_ADMIN) @nickname('getOrgRobot') @@ -102,19 +132,26 @@ class OrgRobot(ApiResource): permission = AdministerOrganizationPermission(orgname) if permission.can(): robot = model.get_org_robot(robot_shortname, orgname) - return robot.to_dict() + return robot.to_dict(include_metadata=True) raise Unauthorized() @require_scope(scopes.ORG_ADMIN) @nickname('createOrgRobot') + @validate_json_request('CreateRobot', optional=True) def put(self, orgname, robot_shortname): """ Create a new robot in the organization. """ permission = AdministerOrganizationPermission(orgname) if permission.can(): - robot = model.create_org_robot(robot_shortname, orgname) - log_action('create_robot', orgname, {'robot': robot_shortname}) - return robot.to_dict(), 201 + create_data = request.get_json() or {} + robot = model.create_org_robot(robot_shortname, orgname, create_data.get('description'), + create_data.get('unstructured_metadata')) + log_action('create_robot', orgname, { + 'robot': robot_shortname, + 'description': create_data.get('description'), + 'unstructured_metadata': create_data.get('unstructured_metadata'), + }) + return robot.to_dict(include_metadata=True), 201 raise Unauthorized() diff --git a/endpoints/api/robot_models_interface.py b/endpoints/api/robot_models_interface.py index 98b641663..707fc976f 100644 --- a/endpoints/api/robot_models_interface.py +++ b/endpoints/api/robot_models_interface.py @@ -3,6 +3,8 @@ from collections import namedtuple from six import add_metaclass +from endpoints.api import format_date + class Permission(namedtuple('Permission', ['repository_name', 'repository_visibility_name', 'role_name'])): """ @@ -36,24 +38,32 @@ class RobotWithPermissions( namedtuple('RobotWithPermissions', [ 'name', 'password', + 'created', + 'last_accessed', 'teams', 'repository_names', + 'description', ])): """ RobotWithPermissions is a list of robot entries. :type name: string :type password: string + :type created: datetime|None + :type last_accessed: datetime|None :type teams: [Team] :type repository_names: [string] - + :type description: string """ def to_dict(self): return { 'name': self.name, 'token': self.password, + 'created': format_date(self.created) if self.created is not None else None, + 'last_accessed': format_date(self.last_accessed) if self.last_accessed is not None else None, 'teams': [team.to_dict() for team in self.teams], - 'repositories': self.repository_names + 'repositories': self.repository_names, + 'description': self.description, } @@ -61,20 +71,35 @@ class Robot( namedtuple('Robot', [ 'name', 'password', + 'created', + 'last_accessed', + 'description', + 'unstructured_metadata', ])): """ Robot represents a robot entity. :type name: string :type password: string - + :type created: datetime|None + :type last_accessed: datetime|None + :type description: string + :type unstructured_metadata: dict """ - def to_dict(self): - return { + def to_dict(self, include_metadata=False): + data = { 'name': self.name, - 'token': self.password + 'token': self.password, + 'created': format_date(self.created) if self.created is not None else None, + 'last_accessed': format_date(self.last_accessed) if self.last_accessed is not None else None, + 'description': self.description, } + if include_metadata: + data['unstructured_metadata'] = self.unstructured_metadata + + return data + @add_metaclass(ABCMeta) class RobotInterface(object): diff --git a/endpoints/api/robot_models_pre_oci.py b/endpoints/api/robot_models_pre_oci.py index abbd75a62..492455e81 100644 --- a/endpoints/api/robot_models_pre_oci.py +++ b/endpoints/api/robot_models_pre_oci.py @@ -1,6 +1,6 @@ from app import avatar from data import model -from data.database import User, FederatedLogin, Team as TeamTable, Repository +from data.database import User, FederatedLogin, Team as TeamTable, Repository, RobotAccountMetadata from endpoints.api.robot_models_interface import (RobotInterface, Robot, RobotWithPermissions, Team, Permission) @@ -23,14 +23,21 @@ class RobotPreOCIModel(RobotInterface): robot_dict = { 'name': robot_name, 'token': robot_tuple.get(FederatedLogin.service_ident), + 'created': robot_tuple.get(User.creation_date), + 'last_accessed': robot_tuple.get(User.last_accessed), + 'description': robot_tuple.get(RobotAccountMetadata.description), + 'unstructured_metadata': robot_tuple.get(RobotAccountMetadata.unstructured_json), } if include_permissions: robot_dict.update({ 'teams': [], - 'repositories': [] + 'repositories': [], }) - robots[robot_name] = Robot(robot_dict['name'], robot_dict['token']) + + robots[robot_name] = Robot(robot_dict['name'], robot_dict['token'], robot_dict['created'], + robot_dict['last_accessed'], robot_dict['description'], + robot_dict['unstructured_metadata']) if include_permissions: team_name = robot_tuple.get(TeamTable.name) repository_name = robot_tuple.get(Repository.name) @@ -48,40 +55,52 @@ class RobotPreOCIModel(RobotInterface): if repository_name is not None: if repository_name not in robot_dict['repositories']: robot_dict['repositories'].append(repository_name) - robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'], robot_dict['teams'], - robot_dict['repositories']) + robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'], + robot_dict['created'], + robot_dict['last_accessed'], + robot_dict['teams'], + robot_dict['repositories'], + robot_dict['description']) return robots.values() def regenerate_user_robot_token(self, robot_shortname, owning_user): - robot, password = model.user.regenerate_robot_token(robot_shortname, owning_user) - return Robot(robot.username, password) + robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, owning_user) + return Robot(robot.username, password, robot.creation_date, robot.last_accessed, + metadata.description, metadata.unstructured_json) def regenerate_org_robot_token(self, robot_shortname, orgname): parent = model.organization.get_organization(orgname) - robot, password = model.user.regenerate_robot_token(robot_shortname, parent) - return Robot(robot.username, password) + robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, parent) + return Robot(robot.username, password, robot.creation_date, robot.last_accessed, + metadata.description, metadata.unstructured_json) def delete_robot(self, robot_username): model.user.delete_robot(robot_username) - def create_user_robot(self, robot_shortname, owning_user): - robot, password = model.user.create_robot(robot_shortname, owning_user) - return Robot(robot.username, password) + def create_user_robot(self, robot_shortname, owning_user, description, unstructured_metadata): + robot, password = model.user.create_robot(robot_shortname, owning_user, description or '', + unstructured_metadata) + return Robot(robot.username, password, robot.creation_date, robot.last_accessed, + description or '', unstructured_metadata) - def create_org_robot(self, robot_shortname, orgname): + def create_org_robot(self, robot_shortname, orgname, description, unstructured_metadata): parent = model.organization.get_organization(orgname) - robot, password = model.user.create_robot(robot_shortname, parent) - return Robot(robot.username, password) + robot, password = model.user.create_robot(robot_shortname, parent, description or '', + unstructured_metadata) + return Robot(robot.username, password, robot.creation_date, robot.last_accessed, + description or '', unstructured_metadata) def get_org_robot(self, robot_shortname, orgname): parent = model.organization.get_organization(orgname) - robot, password = model.user.get_robot(robot_shortname, parent) - return Robot(robot.username, password) + robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, parent) + return Robot(robot.username, password, robot.creation_date, robot.last_accessed, + metadata.description, metadata.unstructured_json) def get_user_robot(self, robot_shortname, owning_user): - robot, password = model.user.get_robot(robot_shortname, owning_user) - return Robot(robot.username, password) + robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, owning_user) + return Robot(robot.username, password, robot.creation_date, robot.last_accessed, + metadata.description, metadata.unstructured_json) pre_oci_model = RobotPreOCIModel() diff --git a/endpoints/api/test/test_robot.py b/endpoints/api/test/test_robot.py new file mode 100644 index 000000000..2e3821176 --- /dev/null +++ b/endpoints/api/test/test_robot.py @@ -0,0 +1,38 @@ +import pytest +import json + +from data import model +from endpoints.api import api +from endpoints.api.test.shared import conduct_api_call +from endpoints.api.robot import UserRobot, OrgRobot +from endpoints.test.shared import client_with_identity + +from test.test_ldap import mock_ldap + +from test.fixtures import * + +@pytest.mark.parametrize('endpoint', [ + UserRobot, + OrgRobot, +]) +@pytest.mark.parametrize('body', [ + {}, + {'description': 'this is a description'}, + {'unstructured_metadata': {'foo': 'bar'}}, + {'description': 'this is a description', 'unstructured_metadata': {'foo': 'bar'}}, +]) +def test_create_robot_with_metadata(endpoint, body, client): + with client_with_identity('devtable', client) as cl: + # Create the robot with the specified body. + conduct_api_call(cl, endpoint, 'PUT', {'orgname': 'buynlarge', 'robot_shortname': 'somebot'}, + body, expected_code=201) + + # Ensure the create succeeded. + resp = conduct_api_call(cl, endpoint, 'GET', { + 'orgname': 'buynlarge', + 'robot_shortname': 'somebot', + }) + + body = body or {} + assert resp.json['description'] == (body.get('description') or '') + assert resp.json['unstructured_metadata'] == (body.get('unstructured_metadata') or {}) diff --git a/endpoints/appr/models_oci.py b/endpoints/appr/models_oci.py index 75d5050fa..3f1c7d19c 100644 --- a/endpoints/appr/models_oci.py +++ b/endpoints/appr/models_oci.py @@ -268,17 +268,6 @@ class OCIAppModel(AppRegistryDataInterface): channel = oci_model.channel.create_or_update_channel(repo, channel_name, release) return ChannelView(current=channel.linked_tag.name, name=channel.name) - def get_user(self, username, password): - err_msg = None - if parse_robot_username(username) is not None: - try: - user = data.model.user.verify_robot(username, password) - except data.model.InvalidRobotException as exc: - return (None, exc.message) - else: - user, err_msg = authentication.verify_and_link_user(username, password) - return (user, err_msg) - def get_blob_locations(self, digest): return oci_model.blob.get_blob_locations(digest) diff --git a/endpoints/v1/models_interface.py b/endpoints/v1/models_interface.py index 93aed61a6..0d03d7005 100644 --- a/endpoints/v1/models_interface.py +++ b/endpoints/v1/models_interface.py @@ -160,22 +160,6 @@ class DockerRegistryV1DataInterface(object): """ pass - @abstractmethod - def load_token(self, token): - """ - Loads the data associated with the given (deprecated) access token, and, if - found returns True. - """ - pass - - @abstractmethod - def verify_robot(self, username, token): - """ - Returns True if the given robot username and token match an existing robot - account. - """ - pass - @abstractmethod def change_user_password(self, user, new_password): """ diff --git a/endpoints/v1/models_pre_oci.py b/endpoints/v1/models_pre_oci.py index 2de743e70..816369089 100644 --- a/endpoints/v1/models_pre_oci.py +++ b/endpoints/v1/models_pre_oci.py @@ -137,19 +137,6 @@ class PreOCIModel(DockerRegistryV1DataInterface): def delete_tag(self, namespace_name, repo_name, tag_name): model.tag.delete_tag(namespace_name, repo_name, tag_name) - def load_token(self, token): - try: - model.token.load_token_data(token) - return True - except model.InvalidTokenException: - return False - - def verify_robot(self, username, token): - try: - return bool(model.user.verify_robot(username, token)) - except model.InvalidRobotException: - return False - def change_user_password(self, user, new_password): model.user.change_password(user, new_password) diff --git a/static/directives/create-entity-dialog.html b/static/directives/create-entity-dialog.html index 5e8bec9bf..100913817 100644 --- a/static/directives/create-entity-dialog.html +++ b/static/directives/create-entity-dialog.html @@ -36,6 +36,14 @@ Choose a name to inform your teammates about this {{ entityTitle }}. Must match {{ entityNameRegex }}. + +
+ + +
+ Enter a description to provide extran information to your teammates about this {{ entityTitle }}. +
+
\ No newline at end of file diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html index 65908bd21..54053d1b2 100644 --- a/static/directives/robots-manager.html +++ b/static/directives/robots-manager.html @@ -36,10 +36,17 @@ Robot Account Name + Description Teams Repositories + + Created + + + Last Accessed + @@ -50,6 +57,10 @@ + + (None) + {{ ::robotInfo.description }} + No teams @@ -78,6 +89,12 @@ + + + + + + diff --git a/static/js/directives/ui/create-entity-dialog.js b/static/js/directives/ui/create-entity-dialog.js index 605701eda..142090a38 100644 --- a/static/js/directives/ui/create-entity-dialog.js +++ b/static/js/directives/ui/create-entity-dialog.js @@ -15,6 +15,7 @@ angular.module('quay').directive('createEntityDialog', function () { 'entityTitle': '@entityTitle', 'entityIcon': '@entityIcon', 'entityNameRegex': '@entityNameRegex', + 'allowEntityDescription': '@allowEntityDescription', 'entityCreateRequested': '&entityCreateRequested', 'entityCreateCompleted': '&entityCreateCompleted' @@ -41,6 +42,7 @@ angular.module('quay').directive('createEntityDialog', function () { $scope.show = function() { $scope.entityName = null; + $scope.entityDescription = null; $scope.entity = null; $scope.entityForPermissions = null; $scope.creating = false; @@ -67,6 +69,7 @@ angular.module('quay').directive('createEntityDialog', function () { $scope.view = 'creating'; $scope.entityCreateRequested({ 'name': $scope.entityName, + 'description': $scope.entityDescription, 'callback': entityCreateCallback }); }; diff --git a/static/js/directives/ui/create-robot-dialog.js b/static/js/directives/ui/create-robot-dialog.js index 6b2a24b23..3ef3912cc 100644 --- a/static/js/directives/ui/create-robot-dialog.js +++ b/static/js/directives/ui/create-robot-dialog.js @@ -19,7 +19,7 @@ angular.module('quay').directive('createRobotDialog', function () { $scope.robotCreated({'robot': robot}); }; - $scope.createRobot = function(name, callback) { + $scope.createRobot = function(name, description, callback) { var organization = $scope.info.namespace; if (!UserService.isOrganization(organization)) { organization = null; @@ -29,11 +29,15 @@ angular.module('quay').directive('createRobotDialog', function () { 'robot_shortname': name }; + var data = { + 'description': description + }; + var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() { callback(null); }); - ApiService.createRobot(organization, null, params).then(function(resp) { + ApiService.createRobot(organization, data, params).then(function(resp) { callback(resp); }, errorDisplay); }; diff --git a/static/js/directives/ui/robots-manager.js b/static/js/directives/ui/robots-manager.js index 67bf312ae..bf42eb2d2 100644 --- a/static/js/directives/ui/robots-manager.js +++ b/static/js/directives/ui/robots-manager.js @@ -39,6 +39,9 @@ angular.module('quay').directive('robotsManager', function () { robot['teams_string'] = robot.teams.map(function(team) { return team['name'] || ''; }).join(','); + + robot['created_datetime'] = robot.created ? TableService.getReversedTimestamp(robot.created) : null; + robot['last_accessed_datetime'] = robot.last_accessed ? TableService.getReversedTimestamp(robot.last_accessed) : null; }); $scope.orderedRobots = TableService.buildOrderedItems(robots, $scope.options, diff --git a/test/data/test.db b/test/data/test.db index 46d05acbb..201814669 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/config/schema.py b/util/config/schema.py index 5ba7ae21c..f37f0e766 100644 --- a/util/config/schema.py +++ b/util/config/schema.py @@ -66,6 +66,7 @@ INTERNAL_ONLY_PROPERTIES = { 'LOCAL_OAUTH_HANDLER', 'USE_CDN', 'ANALYTICS_TYPE', + 'LAST_ACCESSED_UPDATE_THRESHOLD_S', 'EXCEPTION_LOG_TYPE', 'SENTRY_DSN',