Merge pull request #3024 from coreos-inc/manageable-robots

Manageable robots epic
This commit is contained in:
josephschorr 2018-03-21 18:50:17 -04:00 committed by GitHub
commit 6c43b7ff0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 411 additions and 131 deletions

View file

@ -527,3 +527,7 @@ class DefaultConfig(ImmutableConfig):
# Defines the number of successive internal errors of a build trigger's build before the # Defines the number of successive internal errors of a build trigger's build before the
# trigger is automatically disabled. # trigger is automatically disabled.
SUCCESSIVE_TRIGGER_INTERNAL_ERROR_DISABLE_THRESHOLD = 5 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

View file

@ -440,6 +440,8 @@ class User(BaseModel):
location = CharField(null=True) location = CharField(null=True)
maximum_queued_builds_count = IntegerField(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): def delete_instance(self, recursive=False, delete_nullable=False):
# If we are deleting a robot account, only execute the subset of queries necessary. # If we are deleting a robot account, only execute the subset of queries necessary.
@ -477,6 +479,12 @@ class User(BaseModel):
Namespace = User.alias() Namespace = User.alias()
class RobotAccountMetadata(BaseModel):
robot_account = QuayUserField(index=True, allows_robots=True, unique=True)
description = CharField()
unstructured_json = JSONField()
class DeletedNamespace(BaseModel): class DeletedNamespace(BaseModel):
namespace = QuayUserField(index=True, allows_robots=False, unique=True) namespace = QuayUserField(index=True, allows_robots=False, unique=True)
marked = DateTimeField(default=datetime.now) marked = DateTimeField(default=datetime.now)

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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 ###

View file

@ -1,11 +1,17 @@
from peewee import fn import logging
from peewee import fn, PeeweeException
from cachetools import lru_cache 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, from data.database import (Repository, User, Team, TeamMember, RepositoryPermission, TeamRole,
Namespace, Visibility, ImageStorage, Image, RepositoryKind, Namespace, Visibility, ImageStorage, Image, RepositoryKind,
db_for_update) db_for_update)
logger = logging.getLogger(__name__)
def reduce_as_tree(queries_to_reduce): def reduce_as_tree(queries_to_reduce):
""" This method will split a list of queries into halves recursively until we reach individual """ 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. 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 None
return ancestor_size + image_size 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

View file

@ -3,10 +3,10 @@ import logging
from datetime import datetime from datetime import datetime
from cachetools import lru_cache from cachetools import lru_cache
from peewee import PeeweeException
from data.database import AppSpecificAuthToken, User, db_transaction from data.database import AppSpecificAuthToken, User, db_transaction
from data.model import config from data.model import config
from data.model._basequery import update_last_accessed
from util.timedeltastring import convert_to_timedelta from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -104,23 +104,7 @@ def access_valid_token(token_code):
((AppSpecificAuthToken.expiration > datetime.now()) | ((AppSpecificAuthToken.expiration > datetime.now()) |
(AppSpecificAuthToken.expiration >> None))) (AppSpecificAuthToken.expiration >> None)))
.get()) .get())
update_last_accessed(token)
return token
except AppSpecificAuthToken.DoesNotExist: except AppSpecificAuthToken.DoesNotExist:
return None 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

View file

@ -14,7 +14,8 @@ from data.database import (User, LoginService, FederatedLogin, RepositoryPermiss
EmailConfirmation, Role, db_for_update, random_string_generator, EmailConfirmation, Role, db_for_update, random_string_generator,
UserRegion, ImageStorageLocation, UserRegion, ImageStorageLocation,
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger, ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger,
UserPromptKind, UserPrompt, UserPromptTypes, DeletedNamespace) UserPromptKind, UserPrompt, UserPromptTypes, DeletedNamespace,
RobotAccountMetadata)
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException, from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
InvalidUsernameException, InvalidEmailAddressException, InvalidUsernameException, InvalidEmailAddressException,
TooManyLoginAttemptsException, db_transaction, 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() user = db_for_update(User.select().where(User.id == user_id)).get()
# Rename the robots # 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) _, robot_shortname = parse_robot_username(robot.username)
new_robot_name = format_robot_username(new_username, robot_shortname) new_robot_name = format_robot_username(new_username, robot_shortname)
robot.username = new_robot_name robot.username = new_robot_name
@ -231,7 +232,7 @@ def update_enabled(user, set_enabled):
user.save() 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) (username_valid, username_issue) = validate_username(robot_shortname)
if not username_valid: if not username_valid:
raise InvalidRobotException('The name for the robot \'%s\' is invalid: %s' % 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 msg = 'Existing robot with name: %s' % username
logger.info(msg) logger.info(msg)
raise InvalidRobotException(msg) raise InvalidRobotException(msg)
except User.DoesNotExist: except User.DoesNotExist:
pass pass
try:
created = User.create(username=username, robot=True)
service = LoginService.get(name='quayrobot') service = LoginService.get(name='quayrobot')
try:
with db_transaction():
created = User.create(username=username, robot=True)
password = created.email password = created.email
FederatedLogin.create(user=created, service=service,
service_ident=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 return created, password
except Exception as ex: except Exception as ex:
raise DataModelException(ex.message) 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_username = format_robot_username(parent.username, robot_shortname)
robot = lookup_robot(robot_username) robot, metadata = lookup_robot_and_metadata(robot_username)
return robot, robot.email return robot, robot.email, metadata
def lookup_robot(robot_username): def lookup_robot(robot_username):
try: try:
return (User return (User
.select() .select(User, FederatedLogin)
.join(FederatedLogin) .join(FederatedLogin)
.join(LoginService) .join(LoginService)
.where(LoginService.name == 'quayrobot', User.username == robot_username, .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) 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): def get_matching_robots(name_prefix, username, limit=10):
admined_orgs = (_basequery.get_user_organizations(username) admined_orgs = (_basequery.get_user_organizations(username)
.switch(Team) .switch(Team)
@ -328,12 +349,15 @@ def verify_robot(robot_username, password):
if not owner.enabled: if not owner.enabled:
raise InvalidRobotException('This user has been disabled. Please contact your administrator.') raise InvalidRobotException('This user has been disabled. Please contact your administrator.')
# Mark that the robot was accessed.
_basequery.update_last_accessed(robot)
return robot return robot
def regenerate_robot_token(robot_shortname, parent): def regenerate_robot_token(robot_shortname, parent):
robot_username = format_robot_username(parent.username, robot_shortname) 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)() password = random_string_generator(length=64)()
robot.email = password robot.email = password
robot.uuid = str(uuid4()) robot.uuid = str(uuid4())
@ -345,7 +369,7 @@ def regenerate_robot_token(robot_shortname, parent):
login.save() login.save()
robot.save() robot.save()
return robot, password return robot, password, metadata
def delete_robot(robot_username): def delete_robot(robot_username):
try: try:
@ -362,20 +386,28 @@ def list_namespace_robots(namespace):
return _list_entity_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 """ 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. materialized list so that callers can use db_for_update.
""" """
return (User query = (User
.select() .select(User, FederatedLogin)
.join(FederatedLogin) .join(FederatedLogin)
.where(User.robot == True, User.username ** (entity_name + '+%'))) .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): def list_entity_robot_permission_teams(entity_name, include_permissions=False):
query = (_list_entity_robots(entity_name)) 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: if include_permissions:
query = (query query = (query
.join(RepositoryPermission, JOIN_LEFT_OUTER, .join(RepositoryPermission, JOIN_LEFT_OUTER,
@ -449,6 +481,10 @@ def verify_federated_login(service_id, service_ident):
.switch(FederatedLogin).join(User) .switch(FederatedLogin).join(User)
.where(FederatedLogin.service_ident == service_ident, LoginService.name == service_id) .where(FederatedLogin.service_ident == service_ident, LoginService.name == service_id)
.get()) .get())
# Mark that the user was accessed.
_basequery.update_last_accessed(found.user)
return found.user return found.user
except FederatedLogin.DoesNotExist: except FederatedLogin.DoesNotExist:
return None return None
@ -714,6 +750,9 @@ def verify_user(username_or_email, password):
.where(User.id == fetched.id) .where(User.id == fetched.id)
.execute()) .execute())
# Mark that the user was accessed.
_basequery.update_last_accessed(fetched)
# Return the valid user. # Return the valid user.
return fetched return fetched

View file

@ -321,7 +321,7 @@ def require_scope(scope_object):
return wrapper return wrapper
def validate_json_request(schema_name): def validate_json_request(schema_name, optional=False):
def wrapper(func): def wrapper(func):
@add_method_metadata('request_schema', schema_name) @add_method_metadata('request_schema', schema_name)
@wraps(func) @wraps(func)
@ -330,8 +330,9 @@ def validate_json_request(schema_name):
try: try:
json_data = request.get_json() json_data = request.get_json()
if json_data is None: if json_data is None:
if not optional:
raise InvalidRequest('Missing JSON body') raise InvalidRequest('Missing JSON body')
else:
validate(json_data, schema) validate(json_data, schema)
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
except ValidationError as ex: except ValidationError as ex:

View file

@ -2,14 +2,31 @@
from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource, from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource,
require_user_admin, require_scope, path_param, parse_args, 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.api.robot_models_pre_oci import pre_oci_model as model
from endpoints.exception import Unauthorized from endpoints.exception import Unauthorized
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from util.names import format_robot_username 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): 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') 'The short name for the robot, without any user or organization prefix')
class UserRobot(ApiResource): class UserRobot(ApiResource):
""" Resource for managing a user's robots. """ """ Resource for managing a user's robots. """
schemas = {
'CreateRobot': CREATE_ROBOT_SCHEMA,
}
@require_user_admin @require_user_admin
@nickname('getUserRobot') @nickname('getUserRobot')
@ -45,16 +65,23 @@ class UserRobot(ApiResource):
""" Returns the user's robot with the specified name. """ """ Returns the user's robot with the specified name. """
parent = get_authenticated_user() parent = get_authenticated_user()
robot = model.get_user_robot(robot_shortname, parent) robot = model.get_user_robot(robot_shortname, parent)
return robot.to_dict() return robot.to_dict(include_metadata=True)
@require_user_admin @require_user_admin
@nickname('createUserRobot') @nickname('createUserRobot')
@validate_json_request('CreateRobot', optional=True)
def put(self, robot_shortname): def put(self, robot_shortname):
""" Create a new user robot with the specified name. """ """ Create a new user robot with the specified name. """
parent = get_authenticated_user() parent = get_authenticated_user()
robot = model.create_user_robot(robot_shortname, parent) create_data = request.get_json() or {}
log_action('create_robot', parent.username, {'robot': robot_shortname}) robot = model.create_user_robot(robot_shortname, parent, create_data.get('description'),
return robot.to_dict(), 201 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 @require_user_admin
@nickname('deleteUserRobot') @nickname('deleteUserRobot')
@ -94,6 +121,9 @@ class OrgRobotList(ApiResource):
@related_user_resource(UserRobot) @related_user_resource(UserRobot)
class OrgRobot(ApiResource): class OrgRobot(ApiResource):
""" Resource for managing an organization's robots. """ """ Resource for managing an organization's robots. """
schemas = {
'CreateRobot': CREATE_ROBOT_SCHEMA,
}
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('getOrgRobot') @nickname('getOrgRobot')
@ -102,19 +132,26 @@ class OrgRobot(ApiResource):
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
if permission.can(): if permission.can():
robot = model.get_org_robot(robot_shortname, orgname) robot = model.get_org_robot(robot_shortname, orgname)
return robot.to_dict() return robot.to_dict(include_metadata=True)
raise Unauthorized() raise Unauthorized()
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('createOrgRobot') @nickname('createOrgRobot')
@validate_json_request('CreateRobot', optional=True)
def put(self, orgname, robot_shortname): def put(self, orgname, robot_shortname):
""" Create a new robot in the organization. """ """ Create a new robot in the organization. """
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
if permission.can(): if permission.can():
robot = model.create_org_robot(robot_shortname, orgname) create_data = request.get_json() or {}
log_action('create_robot', orgname, {'robot': robot_shortname}) robot = model.create_org_robot(robot_shortname, orgname, create_data.get('description'),
return robot.to_dict(), 201 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() raise Unauthorized()

View file

@ -3,6 +3,8 @@ from collections import namedtuple
from six import add_metaclass from six import add_metaclass
from endpoints.api import format_date
class Permission(namedtuple('Permission', ['repository_name', 'repository_visibility_name', 'role_name'])): class Permission(namedtuple('Permission', ['repository_name', 'repository_visibility_name', 'role_name'])):
""" """
@ -36,24 +38,32 @@ class RobotWithPermissions(
namedtuple('RobotWithPermissions', [ namedtuple('RobotWithPermissions', [
'name', 'name',
'password', 'password',
'created',
'last_accessed',
'teams', 'teams',
'repository_names', 'repository_names',
'description',
])): ])):
""" """
RobotWithPermissions is a list of robot entries. RobotWithPermissions is a list of robot entries.
:type name: string :type name: string
:type password: string :type password: string
:type created: datetime|None
:type last_accessed: datetime|None
:type teams: [Team] :type teams: [Team]
:type repository_names: [string] :type repository_names: [string]
:type description: string
""" """
def to_dict(self): def to_dict(self):
return { return {
'name': self.name, '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,
'teams': [team.to_dict() for team in self.teams], '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', [ namedtuple('Robot', [
'name', 'name',
'password', 'password',
'created',
'last_accessed',
'description',
'unstructured_metadata',
])): ])):
""" """
Robot represents a robot entity. Robot represents a robot entity.
:type name: string :type name: string
:type password: string :type password: string
:type created: datetime|None
:type last_accessed: datetime|None
:type description: string
:type unstructured_metadata: dict
""" """
def to_dict(self): def to_dict(self, include_metadata=False):
return { data = {
'name': self.name, '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) @add_metaclass(ABCMeta)
class RobotInterface(object): class RobotInterface(object):

View file

@ -1,6 +1,6 @@
from app import avatar from app import avatar
from data import model 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, from endpoints.api.robot_models_interface import (RobotInterface, Robot, RobotWithPermissions, Team,
Permission) Permission)
@ -23,14 +23,21 @@ class RobotPreOCIModel(RobotInterface):
robot_dict = { robot_dict = {
'name': robot_name, 'name': robot_name,
'token': robot_tuple.get(FederatedLogin.service_ident), '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: if include_permissions:
robot_dict.update({ robot_dict.update({
'teams': [], '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: if include_permissions:
team_name = robot_tuple.get(TeamTable.name) team_name = robot_tuple.get(TeamTable.name)
repository_name = robot_tuple.get(Repository.name) repository_name = robot_tuple.get(Repository.name)
@ -48,40 +55,52 @@ class RobotPreOCIModel(RobotInterface):
if repository_name is not None: if repository_name is not None:
if repository_name not in robot_dict['repositories']: if repository_name not in robot_dict['repositories']:
robot_dict['repositories'].append(repository_name) robot_dict['repositories'].append(repository_name)
robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'], robot_dict['teams'], robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'],
robot_dict['repositories']) robot_dict['created'],
robot_dict['last_accessed'],
robot_dict['teams'],
robot_dict['repositories'],
robot_dict['description'])
return robots.values() return robots.values()
def regenerate_user_robot_token(self, robot_shortname, owning_user): def regenerate_user_robot_token(self, robot_shortname, owning_user):
robot, password = model.user.regenerate_robot_token(robot_shortname, owning_user) robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, owning_user)
return Robot(robot.username, password) 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): def regenerate_org_robot_token(self, robot_shortname, orgname):
parent = model.organization.get_organization(orgname) parent = model.organization.get_organization(orgname)
robot, password = model.user.regenerate_robot_token(robot_shortname, parent) robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, parent)
return Robot(robot.username, password) return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
metadata.description, metadata.unstructured_json)
def delete_robot(self, robot_username): def delete_robot(self, robot_username):
model.user.delete_robot(robot_username) model.user.delete_robot(robot_username)
def create_user_robot(self, robot_shortname, owning_user): def create_user_robot(self, robot_shortname, owning_user, description, unstructured_metadata):
robot, password = model.user.create_robot(robot_shortname, owning_user) robot, password = model.user.create_robot(robot_shortname, owning_user, description or '',
return Robot(robot.username, password) 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) parent = model.organization.get_organization(orgname)
robot, password = model.user.create_robot(robot_shortname, parent) robot, password = model.user.create_robot(robot_shortname, parent, description or '',
return Robot(robot.username, password) 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): def get_org_robot(self, robot_shortname, orgname):
parent = model.organization.get_organization(orgname) parent = model.organization.get_organization(orgname)
robot, password = model.user.get_robot(robot_shortname, parent) robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, parent)
return Robot(robot.username, password) 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): def get_user_robot(self, robot_shortname, owning_user):
robot, password = model.user.get_robot(robot_shortname, owning_user) robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, owning_user)
return Robot(robot.username, password) return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
metadata.description, metadata.unstructured_json)
pre_oci_model = RobotPreOCIModel() pre_oci_model = RobotPreOCIModel()

View file

@ -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 {})

View file

@ -268,17 +268,6 @@ class OCIAppModel(AppRegistryDataInterface):
channel = oci_model.channel.create_or_update_channel(repo, channel_name, release) channel = oci_model.channel.create_or_update_channel(repo, channel_name, release)
return ChannelView(current=channel.linked_tag.name, name=channel.name) 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): def get_blob_locations(self, digest):
return oci_model.blob.get_blob_locations(digest) return oci_model.blob.get_blob_locations(digest)

View file

@ -160,22 +160,6 @@ class DockerRegistryV1DataInterface(object):
""" """
pass 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 @abstractmethod
def change_user_password(self, user, new_password): def change_user_password(self, user, new_password):
""" """

View file

@ -137,19 +137,6 @@ class PreOCIModel(DockerRegistryV1DataInterface):
def delete_tag(self, namespace_name, repo_name, tag_name): def delete_tag(self, namespace_name, repo_name, tag_name):
model.tag.delete_tag(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): def change_user_password(self, user, new_password):
model.user.change_password(user, new_password) model.user.change_password(user, new_password)

View file

@ -36,6 +36,14 @@
Choose a name to inform your teammates Choose a name to inform your teammates
about this {{ entityTitle }}. Must match {{ entityNameRegex }}. about this {{ entityTitle }}. Must match {{ entityNameRegex }}.
</div> </div>
<div ng-show="allowEntityDescription" style="margin-top: 20px;">
<label>Provide an optional description for your new {{ entityTitle }}:</label>
<input type="text" class="form-control" ng-model="entityDescription" max-length="255">
<div class="help-text">
Enter a description to provide extran information to your teammates about this {{ entityTitle }}.
</div>
</div>
</form> </form>
</div> <!-- /.modal-body --> </div> <!-- /.modal-body -->
<div class="modal-footer" ng-show="view == 'setperms'"> <div class="modal-footer" ng-show="view == 'setperms'">

View file

@ -3,7 +3,8 @@
<div class="create-entity-dialog" info="info" entity-title="robot account" <div class="create-entity-dialog" info="info" entity-title="robot account"
entity-kind="robot" entity-kind="robot"
entity-icon="ci-robot" entity-name-regex="{{ ROBOT_PATTERN }}" entity-icon="ci-robot" entity-name-regex="{{ ROBOT_PATTERN }}"
entity-create-requested="createRobot(name, callback)" allow-entity-description="true"
entity-create-requested="createRobot(name, description, callback)"
entity-create-completed="robotFinished(entity)"></div> entity-create-completed="robotFinished(entity)"></div>
</div> </div>
</div> </div>

View file

@ -36,10 +36,17 @@
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)"> <td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('name', options)">Robot Account Name</a> <a ng-click="TableService.orderBy('name', options)">Robot Account Name</a>
</td> </td>
<td>Description</td>
<td ng-if="organization" ng-class="TableService.tablePredicateClass('teams_string', options.predicate, options.reverse)"> <td ng-if="organization" ng-class="TableService.tablePredicateClass('teams_string', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('teams_string', options)">Teams</a> <a ng-click="TableService.orderBy('teams_string', options)">Teams</a>
</td> </td>
<td>Repositories</td> <td>Repositories</td>
<td ng-class="TableService.tablePredicateClass('created_datetime', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('created_datetime', options)">Created</a>
</td>
<td ng-class="TableService.tablePredicateClass('last_accessed_datetime', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('last_accessed_datetime', options)">Last Accessed</a>
</td>
<td class="options-col"></td> <td class="options-col"></td>
</thead> </thead>
@ -50,6 +57,10 @@
<span class="prefix" bo-text="getPrefix(robotInfo.name) + '+'"></span><span bo-text="getShortenedName(robotInfo.name)"></span> <span class="prefix" bo-text="getPrefix(robotInfo.name) + '+'"></span><span bo-text="getShortenedName(robotInfo.name)"></span>
</a> </a>
</td> </td>
<td>
<span class="empty" bo-if="!robotInfo.description">(None)</span>
<span bo-if="robotInfo.description">{{ ::robotInfo.description }}</span>
</td>
<td bo-if="organization"> <td bo-if="organization">
<span class="empty" bo-if="robotInfo.teams.length == 0"> <span class="empty" bo-if="robotInfo.teams.length == 0">
No teams No teams
@ -78,6 +89,12 @@
</a> </a>
</span> </span>
</td> </td>
<td>
<time-ago datetime="robotInfo.created"></time-ago>
</td>
<td>
<time-ago datetime="robotInfo.last_accessed"></time-ago>
</td>
<td class="options-col"> <td class="options-col">
<span class="cor-options-menu"> <span class="cor-options-menu">
<span class="cor-option" option-click="showRobot(robotInfo)"> <span class="cor-option" option-click="showRobot(robotInfo)">

View file

@ -15,6 +15,7 @@ angular.module('quay').directive('createEntityDialog', function () {
'entityTitle': '@entityTitle', 'entityTitle': '@entityTitle',
'entityIcon': '@entityIcon', 'entityIcon': '@entityIcon',
'entityNameRegex': '@entityNameRegex', 'entityNameRegex': '@entityNameRegex',
'allowEntityDescription': '@allowEntityDescription',
'entityCreateRequested': '&entityCreateRequested', 'entityCreateRequested': '&entityCreateRequested',
'entityCreateCompleted': '&entityCreateCompleted' 'entityCreateCompleted': '&entityCreateCompleted'
@ -41,6 +42,7 @@ angular.module('quay').directive('createEntityDialog', function () {
$scope.show = function() { $scope.show = function() {
$scope.entityName = null; $scope.entityName = null;
$scope.entityDescription = null;
$scope.entity = null; $scope.entity = null;
$scope.entityForPermissions = null; $scope.entityForPermissions = null;
$scope.creating = false; $scope.creating = false;
@ -67,6 +69,7 @@ angular.module('quay').directive('createEntityDialog', function () {
$scope.view = 'creating'; $scope.view = 'creating';
$scope.entityCreateRequested({ $scope.entityCreateRequested({
'name': $scope.entityName, 'name': $scope.entityName,
'description': $scope.entityDescription,
'callback': entityCreateCallback 'callback': entityCreateCallback
}); });
}; };

View file

@ -19,7 +19,7 @@ angular.module('quay').directive('createRobotDialog', function () {
$scope.robotCreated({'robot': robot}); $scope.robotCreated({'robot': robot});
}; };
$scope.createRobot = function(name, callback) { $scope.createRobot = function(name, description, callback) {
var organization = $scope.info.namespace; var organization = $scope.info.namespace;
if (!UserService.isOrganization(organization)) { if (!UserService.isOrganization(organization)) {
organization = null; organization = null;
@ -29,11 +29,15 @@ angular.module('quay').directive('createRobotDialog', function () {
'robot_shortname': name 'robot_shortname': name
}; };
var data = {
'description': description
};
var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() { var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() {
callback(null); callback(null);
}); });
ApiService.createRobot(organization, null, params).then(function(resp) { ApiService.createRobot(organization, data, params).then(function(resp) {
callback(resp); callback(resp);
}, errorDisplay); }, errorDisplay);
}; };

View file

@ -39,6 +39,9 @@ angular.module('quay').directive('robotsManager', function () {
robot['teams_string'] = robot.teams.map(function(team) { robot['teams_string'] = robot.teams.map(function(team) {
return team['name'] || ''; return team['name'] || '';
}).join(','); }).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, $scope.orderedRobots = TableService.buildOrderedItems(robots, $scope.options,

Binary file not shown.

View file

@ -66,6 +66,7 @@ INTERNAL_ONLY_PROPERTIES = {
'LOCAL_OAUTH_HANDLER', 'LOCAL_OAUTH_HANDLER',
'USE_CDN', 'USE_CDN',
'ANALYTICS_TYPE', 'ANALYTICS_TYPE',
'LAST_ACCESSED_UPDATE_THRESHOLD_S',
'EXCEPTION_LOG_TYPE', 'EXCEPTION_LOG_TYPE',
'SENTRY_DSN', 'SENTRY_DSN',