diff --git a/auth/credentials.py b/auth/credentials.py index fc33ca65f..183f85b8e 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -1,16 +1,20 @@ import logging from enum import Enum +from flask import request + import features -from app import authentication +from app import app, authentication from auth.credential_consts import ( ACCESS_TOKEN_USERNAME, APP_SPECIFIC_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, ) +from auth.log import log_action from auth.oauth import validate_oauth_token from auth.validateresult import AuthKind, ValidateResult from data import model +from data.database import User from util.names import parse_robot_username logger = logging.getLogger(__name__) @@ -52,17 +56,50 @@ def validate_credentials(auth_username, auth_password_or_token): logger.debug( "Failed to validate credentials for app specific token: %s", auth_password_or_token ) + + error_message = "Invalid token" + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + log_action( + "login_failure", + None, + { + "type": "v2auth", + "kind": "app_specific_token", + "useragent": request.user_agent.string, + "message": error_message, + }, + ) + return ( - ValidateResult(AuthKind.credentials, error_message="Invalid token"), + ValidateResult(AuthKind.credentials, error_message=error_message), CredentialKind.app_specific_token, ) if not token.user.enabled: logger.debug("Tried to use an app specific token for a disabled user: %s", token.uuid) + + error_message = "This user has been disabled. Please contact your administrator." + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + log_action( + "login_failure", + token.user.username, + { + "type": "v2auth", + "kind": "app_specific_token", + "app_specific_token_title": token.title, + "username": token.user.username, + "useragent": request.user_agent.string, + "message": error_message, + }, + performer=token.user, + ) + return ( ValidateResult( AuthKind.credentials, - error_message="This user has been disabled. Please contact your administrator.", + error_message=error_message, ), CredentialKind.app_specific_token, ) @@ -83,10 +120,97 @@ def validate_credentials(auth_username, auth_password_or_token): logger.debug("Found credentials header for robot %s", auth_username) try: robot = model.user.verify_robot(auth_username, auth_password_or_token) + logger.debug("Successfully validated credentials for robot %s", auth_username) + return ValidateResult(AuthKind.credentials, robot=robot), CredentialKind.robot + except model.DeactivatedRobotOwnerException as dre: + robot_owner, robot_name = parse_robot_username(auth_username) + + logger.debug( + "Tried to use the robot %s for a disabled user: %s", robot_name, robot_owner + ) + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + try: + performer = model.user.lookup_robot(auth_username) + except User.DoesNotExist: + performer = None + + log_action( + "login_failure", + robot_owner, + { + "type": "v2auth", + "kind": "robot", + "robot": auth_username, + "username": robot_owner, + "useragent": request.user_agent.string, + "message": str(dre), + }, + performer=performer, + ) + + return ( + ValidateResult(AuthKind.credentials, error_message=str(dre)), + CredentialKind.robot, + ) + except model.InvalidRobotCredentialException as ire: + logger.debug("Failed to validate credentials for robot %s: %s", auth_username, ire) + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + robot_owner, _ = parse_robot_username(auth_username) + + try: + performer = model.user.lookup_robot(auth_username) + except User.DoesNotExist: + performer = None + + log_action( + "login_failure", + robot_owner, + { + "type": "v2auth", + "kind": "robot", + "robot": auth_username, + "username": robot_owner, + "useragent": request.user_agent.string, + "message": str(ire), + }, + performer=performer, + ) + + return ( + ValidateResult(AuthKind.credentials, error_message=str(ire)), + CredentialKind.robot, + ) except model.InvalidRobotException as ire: - logger.warning("Failed to validate credentials for robot %s: %s", auth_username, ire) + if isinstance(ire, model.InvalidRobotException): + logger.debug("Failed to validate credentials for robot %s: %s", auth_username, ire) + elif isinstance(ire, model.InvalidRobotOwnerException): + logger.debug( + "Tried to use the robot %s for a non-existing user: %s", auth_username, ire + ) + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + robot_owner, _ = parse_robot_username(auth_username) + + # need to get the owner here in case it wasn't found due to a non-existing user + owner = model.user.get_nonrobot_user(robot_owner) + + log_action( + "login_failure", + owner.username if owner else None, + { + "type": "v2auth", + "kind": "robot", + "robot": auth_username, + "username": robot_owner, + "useragent": request.user_agent.string, + "message": str(ire), + }, + ) + return ( ValidateResult(AuthKind.credentials, error_message=str(ire)), CredentialKind.robot, @@ -101,4 +225,18 @@ def validate_credentials(auth_username, auth_password_or_token): return ValidateResult(AuthKind.credentials, user=authenticated), CredentialKind.user else: logger.warning("Failed to validate credentials for user %s: %s", auth_username, err) + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + log_action( + "login_failure", + None, + { + "type": "v2auth", + "kind": "user", + "username": auth_username, + "useragent": request.user_agent.string, + "message": err, + }, + ) + return ValidateResult(AuthKind.credentials, error_message=err), CredentialKind.user diff --git a/auth/log.py b/auth/log.py new file mode 100644 index 000000000..48fda6337 --- /dev/null +++ b/auth/log.py @@ -0,0 +1,17 @@ +from flask import request + +from data.logs_model import logs_model + + +def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None, performer=None): + if not metadata: + metadata = {} + + logs_model.log_action( + kind, + user_or_orgname, + repository=repo, + performer=performer, + ip=request.remote_addr or None, + metadata=metadata, + ) diff --git a/auth/oauth.py b/auth/oauth.py index 15fe26882..6c1171daf 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -1,9 +1,11 @@ import logging from datetime import datetime +from flask import request from jwt import ExpiredSignatureError, InvalidTokenError from app import analytics, app, authentication, oauth_login +from auth.log import log_action from auth.scopes import scopes_from_scope_string from auth.validateresult import AuthKind, ValidateResult from data import model @@ -100,18 +102,72 @@ def validate_app_oauth_token(token): validated = model.oauth.validate_access_token(token) if not validated: logger.warning("OAuth access token could not be validated: %s", token) - return ValidateResult( - AuthKind.oauth, error_message="OAuth access token could not be validated" - ) + + error_message = "OAuth access token could not be validated" + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + log_action( + "login_failure", + None, + { + "type": "quayauth", + "kind": "oauth", + "useragent": request.user_agent.string, + "message": error_message, + }, + ) + + return ValidateResult(AuthKind.oauth, error_message=error_message) if validated.expires_at <= datetime.utcnow(): logger.warning("OAuth access with an expired token: %s", token) - return ValidateResult(AuthKind.oauth, error_message="OAuth access token has expired") + + error_message = "OAuth access token has expired" + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + log_action( + "login_failure", + validated.application.organization.username, + { + "type": "quayauth", + "kind": "oauth", + "token": validated.token_name, + "application_name": validated.application.name, + "oauth_token_id": validated.id, + "oauth_token_application_id": validated.application.client_id, + "oauth_token_application": validated.application.name, + "username": validated.authorized_user.username, + "useragent": request.user_agent.string, + "message": error_message, + }, + performer=validated, + ) + + return ValidateResult(AuthKind.oauth, error_message=error_message) # Don't allow disabled users to login. if not validated.authorized_user.enabled: + error_message = "Granter of the oauth access token is disabled" + + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + log_action( + "login_failure", + validated.application.organization.username, + { + "type": "quayauth", + "kind": "oauth", + "token": validated.token_name, + "application_name": validated.application.name, + "username": validated.authorized_user.username, + "useragent": request.user_agent.string, + "message": error_message, + }, + performer=validated.authorized_user, + ) + return ValidateResult( - AuthKind.oauth, error_message="Granter of the oauth access token is disabled" + AuthKind.oauth, + error_message=error_message, ) # We have a valid token diff --git a/auth/test/test_basic.py b/auth/test/test_basic.py index 610349da0..c664e40c4 100644 --- a/auth/test/test_basic.py +++ b/auth/test/test_basic.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from base64 import b64encode -from test.fixtures import * import pytest @@ -13,6 +12,7 @@ from auth.credentials import ( ) from auth.validateresult import AuthKind, ValidateResult from data import model +from test.fixtures import * def _token(username, password): diff --git a/auth/test/test_credentials.py b/auth/test/test_credentials.py index 9442ce5c9..b02c72325 100644 --- a/auth/test/test_credentials.py +++ b/auth/test/test_credentials.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -from test.fixtures import * - from auth.credential_consts import ( ACCESS_TOKEN_USERNAME, APP_SPECIFIC_TOKEN_USERNAME, @@ -10,6 +8,8 @@ from auth.credential_consts import ( from auth.credentials import CredentialKind, validate_credentials from auth.validateresult import AuthKind, ValidateResult from data import model +from data.database import RobotAccountToken +from test.fixtures import * def test_valid_user(app): @@ -27,14 +27,37 @@ def test_valid_robot(app): def test_valid_robot_for_disabled_user(app): user = model.user.get_user("devtable") + + robot, password = model.user.create_robot("somerobot", user) + user.enabled = False user.save() - robot, password = model.user.create_robot("somerobot", user) result, kind = validate_credentials(robot.username, password) assert kind == CredentialKind.robot - err = "This user has been disabled. Please contact your administrator." + err = "Robot %s owner %s is disabled" % (robot.username, user.username) + assert result == ValidateResult(AuthKind.credentials, error_message=err) + + +def test_valid_robot_with_invalid_password(app): + robot, _ = model.user.create_robot("somerobot", model.user.get_user("devtable")) + result, kind = validate_credentials(robot.username, "wrongpassword") + assert kind == CredentialKind.robot + + err = "Could not find robot with username: %s and supplied password." % robot.username + assert result == ValidateResult(AuthKind.credentials, error_message=err) + + +def test_valid_robot_with_invalid_token(app): + robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) + token = RobotAccountToken.get(robot_account=robot) + token.delete_instance() + + result, kind = validate_credentials(robot.username, password) + assert kind == CredentialKind.robot + + err = "Could not find robot with username: %s and supplied password." % robot.username assert result == ValidateResult(AuthKind.credentials, error_message=err) diff --git a/config.py b/config.py index c38fbcaa7..6b3e3c2c9 100644 --- a/config.py +++ b/config.py @@ -441,6 +441,12 @@ class DefaultConfig(ImmutableConfig): # Action logs configuration for advanced events ACTION_LOG_AUDIT_LOGINS = True + # Action logs configuration for failure tracking + ACTION_LOG_AUDIT_LOGIN_FAILURES = False + ACTION_LOG_AUDIT_PULL_FAILURES = False + ACTION_LOG_AUDIT_PUSH_FAILURES = False + ACTION_LOG_AUDIT_DELETE_FAILURES = False + # Action logs archive ACTION_LOG_ARCHIVE_LOCATION: Optional[str] = "local_us" ACTION_LOG_ARCHIVE_PATH: Optional[str] = "actionlogarchive/" diff --git a/data/migrations/versions/3f8e3657bb67_add_login_and_pull_failure_logentrykind.py b/data/migrations/versions/3f8e3657bb67_add_login_and_pull_failure_logentrykind.py new file mode 100644 index 000000000..deea98b50 --- /dev/null +++ b/data/migrations/versions/3f8e3657bb67_add_login_and_pull_failure_logentrykind.py @@ -0,0 +1,37 @@ +"""add login and pull failure logentrykind + +Revision ID: 3f8e3657bb67 +Revises: 8d47693829a0 +Create Date: 2023-05-06 14:21:16.580825 + +""" + +# revision identifiers, used by Alembic. +revision = "3f8e3657bb67" +down_revision = "8d47693829a0" + +import sqlalchemy as sa + + +def upgrade(op, tables, tester): + op.bulk_insert( + tables.logentrykind, + [ + {"name": "login_failure"}, + {"name": "push_repo_failed"}, + {"name": "pull_repo_failed"}, + {"name": "delete_tag_failed"}, + ], + ) + + +def downgrade(op, tables, tester): + op.execute( + tables.logentrykind.delete().where( + tables.logentrykind.name + == op.inline_literal("login_failure") | tables.logentrykind.name + == op.inline_literal("push_repo_failed") | tables.logentrykind.name + == op.inline_literal("pull_repo_failed") | tables.logentrykind.name + == op.inline_literal("delete_tag_failed") + ) + ) diff --git a/data/model/__init__.py b/data/model/__init__.py index 8ae55ca94..ae8f0b0bb 100644 --- a/data/model/__init__.py +++ b/data/model/__init__.py @@ -41,6 +41,18 @@ class InvalidRobotException(DataModelException): pass +class DeactivatedRobotOwnerException(InvalidRobotException): + pass + + +class InvalidRobotCredentialException(InvalidRobotException): + pass + + +class InvalidRobotOwnerException(InvalidRobotException): + pass + + class InvalidUsernameException(DataModelException): pass diff --git a/data/model/user.py b/data/model/user.py index 2fb80ab75..b896e4945 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -45,10 +45,13 @@ from data.database import ( from data.fields import Credential from data.model import ( DataModelException, + DeactivatedRobotOwnerException, InvalidEmailAddressException, InvalidNamespaceException, InvalidPasswordException, + InvalidRobotCredentialException, InvalidRobotException, + InvalidRobotOwnerException, InvalidUsernameException, TooManyLoginAttemptsException, _basequery, @@ -459,30 +462,27 @@ def verify_robot(robot_username, password): robot = lookup_robot(robot_username) assert robot.robot + # Find the owner user and ensure it is not disabled. + try: + owner = User.get(User.username == result[0]) + except User.DoesNotExist: + raise InvalidRobotOwnerException("Robot %s owner does not exist" % robot_username) + + if not owner.enabled: + raise DeactivatedRobotOwnerException( + "Robot %s owner %s is disabled" % (robot_username, owner.username) + ) # Lookup the token for the robot. try: token_data = RobotAccountToken.get(robot_account=robot) if not token_data.token.matches(password): msg = "Could not find robot with username: %s and supplied password." % robot_username - raise InvalidRobotException(msg) + raise InvalidRobotCredentialException(msg) except RobotAccountToken.DoesNotExist: msg = "Could not find robot with username: %s and supplied password." % robot_username - raise InvalidRobotException(msg) + raise InvalidRobotCredentialException(msg) - # Find the owner user and ensure it is not disabled. - try: - owner = User.get(User.username == result[0]) - except User.DoesNotExist: - raise InvalidRobotException("Robot %s owner does not exist" % robot_username) - - 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 @@ -790,6 +790,13 @@ def validate_reset_code(token): def find_user_by_email(email): + # Make sure we didn't get any unicode for the email. + try: + if email and isinstance(email, str): + email.encode("ascii") + except UnicodeEncodeError: + return None + try: return User.get(User.email == email) except User.DoesNotExist: @@ -797,6 +804,13 @@ def find_user_by_email(email): def get_nonrobot_user(username): + # Make sure we didn't get any unicode for the username. + try: + if username and isinstance(username, str): + username.encode("ascii") + except UnicodeEncodeError: + return None + try: return User.get(User.username == username, User.organization == False, User.robot == False) except User.DoesNotExist: @@ -804,6 +818,13 @@ def get_nonrobot_user(username): def get_user(username): + # Make sure we didn't get any unicode for the username. + try: + if username and isinstance(username, str): + username.encode("ascii") + except UnicodeEncodeError: + return None + try: return User.get(User.username == username, User.organization == False) except User.DoesNotExist: @@ -811,6 +832,13 @@ def get_user(username): def get_namespace_user(username): + # Make sure we didn't get any unicode for the username. + try: + if username and isinstance(username, str): + username.encode("ascii") + except UnicodeEncodeError: + return None + try: return User.get(User.username == username) except User.DoesNotExist: @@ -818,6 +846,13 @@ def get_namespace_user(username): def get_user_or_org(username): + # Make sure we didn't get any unicode for the username. + try: + if username and isinstance(username, str): + username.encode("ascii") + except UnicodeEncodeError: + return None + try: return User.get(User.username == username, User.robot == False) except User.DoesNotExist: @@ -986,7 +1021,8 @@ def verify_user(username_or_email, password): # Make sure we didn't get any unicode for the username. try: - username_or_email.encode("ascii") + if username_or_email and isinstance(username_or_email, str): + username_or_email.encode("ascii") except UnicodeEncodeError: return None diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index e405051c2..cf5ddd0ec 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -32,6 +32,7 @@ from auth.permissions import ( from data import model as data_model from data.database import RepositoryState from data.logs_model import logs_model +from digest import digest_tools from endpoints.csrf import csrf_protect from endpoints.decorators import ( check_anon_protection, @@ -400,6 +401,87 @@ require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER require_user_admin = require_user_permission(UserAdminPermission, scopes.ADMIN_USER) +def log_unauthorized(audit_event): + def inner(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except endpoints.v2.errors.Unauthorized as e: + + if ( + ( + app.config.get("ACTION_LOG_AUDIT_PUSH_FAILURES") + and audit_event == "push_repo_failed" + ) + or ( + app.config.get("ACTION_LOG_AUDIT_PULL_FAILURES") + and audit_event == "pull_repo_failed" + ) + or ( + app.config.get("ACTION_LOG_AUDIT_DELETE_FAILURES") + and audit_event == "delete_tag_failed" + ) + ): + if "namespace_name" in kwargs and "repo_name" in kwargs: + metadata = { + "namespace": kwargs["namespace_name"], + "repo": kwargs["repo_name"], + } + + if "manifest_ref" in kwargs: + try: + digest = digest_tools.Digest.parse_digest(kwargs["manifest_ref"]) + metadata["manifest_digest"] = str(digest) + except digest_tools.InvalidDigestException: + metadata["tag"] = kwargs["manifest_ref"] + + user_or_orgname = data_model.user.get_user_or_org(kwargs["namespace_name"]) + + if user_or_orgname is not None: + repo = data_model.repository.get_repository( + user_or_orgname.username, kwargs["repo_name"] + ) + else: + repo = None + + if user_or_orgname is None: + metadata["message"] = "Namespace does not exist" + log_action( + kind=audit_event, + user_or_orgname=None, + metadata=metadata, + ) + elif repo is None: + metadata["message"] = "Repository does not exist" + log_action( + kind=audit_event, + user_or_orgname=user_or_orgname.username, + metadata=metadata, + ) + else: + metadata["message"] = str(e) + log_action( + kind=audit_event, + user_or_orgname=user_or_orgname.username, + repo_name=repo.name, + metadata=metadata, + ) + + logger.debug("Unauthorized request: %s", e) + + raise e + + return wrapper + + return inner + + +log_unauthorized_pull = log_unauthorized("pull_repo_failed") +log_unauthorized_push = log_unauthorized("push_repo_failed") +log_unauthorized_delete = log_unauthorized("delete_tag_failed") + + def allow_if_superuser(): return app.config.get("FEATURE_SUPERUSERS_FULL_ACCESS", False) and SuperUserPermission().can() @@ -507,7 +589,7 @@ def request_error(exception=None, **kwargs): raise InvalidRequest(message, data) -def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None): +def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None, performer=None): if not metadata: metadata = {} @@ -517,7 +599,8 @@ def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None): metadata["oauth_token_application_id"] = oauth_token.application.client_id metadata["oauth_token_application"] = oauth_token.application.name - performer = get_authenticated_user() + if performer is None: + performer = get_authenticated_user() if repo_name is not None: repo = data_model.repository.get_repository(user_or_orgname, repo_name) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 3eea21260..a45c5e064 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -720,6 +720,22 @@ def conduct_signin(username_or_email, password, invite_code=None): needs_email_verification = True else: + if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"): + possible_user = model.user.get_nonrobot_user( + username_or_email + ) or model.user.find_user_by_email(username_or_email) + log_action( + "login_failure", + possible_user.username if possible_user else None, + { + "type": "quayauth", + "kind": "user", + "useragent": request.user_agent.string, + "username": username_or_email, + "message": error_message, + }, + performer=possible_user, + ) invalid_credentials = True return ( diff --git a/endpoints/v1/index.py b/endpoints/v1/index.py index 31c378662..1abc19585 100644 --- a/endpoints/v1/index.py +++ b/endpoints/v1/index.py @@ -5,7 +5,6 @@ from functools import wraps from flask import jsonify, make_response, request, session -import features from app import app, docker_v2_signing_key, storage, userevents from auth.auth_context import get_authenticated_context, get_authenticated_user from auth.credentials import CredentialKind, validate_credentials diff --git a/endpoints/v2/blob.py b/endpoints/v2/blob.py index c4e55977f..9a51e8865 100644 --- a/endpoints/v2/blob.py +++ b/endpoints/v2/blob.py @@ -22,6 +22,7 @@ from data.registry_model.blobuploader import ( retrieve_blob_upload_manager, ) from digest import digest_tools +from endpoints.api import log_unauthorized from endpoints.decorators import ( anon_allowed, anon_protect, @@ -240,6 +241,7 @@ def _try_to_mount_blob(repository_ref, mount_blob_digest): @disallow_for_account_recovery_mode @parse_repository_name() @process_registry_jwt_auth(scopes=["pull", "push"]) +@log_unauthorized("push_repo_failed") @require_repo_write(allow_for_superuser=True, disallow_for_restricted_users=True) @anon_protect @check_readonly diff --git a/endpoints/v2/manifest.py b/endpoints/v2/manifest.py index 57b2fd4d7..e547d12f1 100644 --- a/endpoints/v2/manifest.py +++ b/endpoints/v2/manifest.py @@ -13,12 +13,16 @@ from data.model import ( RepositoryDoesNotExist, TagDoesNotExist, namespacequota, - repository, ) from data.model.oci.manifest import CreateManifestException from data.model.oci.tag import RetargetTagException from data.registry_model import registry_model from digest import digest_tools +from endpoints.api import ( + log_unauthorized_delete, + log_unauthorized_pull, + log_unauthorized_push, +) from endpoints.decorators import ( anon_protect, check_readonly, @@ -62,6 +66,7 @@ MANIFEST_TAGNAME_ROUTE = BASE_MANIFEST_ROUTE.format(VALID_TAG_PATTERN) @disallow_for_account_recovery_mode @parse_repository_name() @process_registry_jwt_auth(scopes=["pull"]) +@log_unauthorized_pull @require_repo_read(allow_for_superuser=True) @anon_protect @inject_registry_model() @@ -135,6 +140,7 @@ def fetch_manifest_by_tagname(namespace_name, repo_name, manifest_ref, registry_ @disallow_for_account_recovery_mode @parse_repository_name() @process_registry_jwt_auth(scopes=["pull"]) +@log_unauthorized_pull @require_repo_read(allow_for_superuser=True) @anon_protect @inject_registry_model() @@ -262,6 +268,7 @@ def _doesnt_accept_schema_v1(): @parse_repository_name() @_reject_manifest2_schema2 @process_registry_jwt_auth(scopes=["pull", "push"]) +@log_unauthorized_push @require_repo_write(allow_for_superuser=True, disallow_for_restricted_users=True) @anon_protect @check_readonly @@ -285,6 +292,7 @@ def _enqueue_blobs_for_replication(manifest, storage, namespace_name): @parse_repository_name() @_reject_manifest2_schema2 @process_registry_jwt_auth(scopes=["pull", "push"]) +@log_unauthorized_push @require_repo_write(allow_for_superuser=True, disallow_for_restricted_users=True) @anon_protect @check_readonly @@ -361,6 +369,7 @@ def _parse_manifest(content_type, request_data): @disallow_for_account_recovery_mode @parse_repository_name() @process_registry_jwt_auth(scopes=["pull", "push"]) +@log_unauthorized_delete @require_repo_write(allow_for_superuser=True, disallow_for_restricted_users=True) @anon_protect @check_readonly diff --git a/initdb.py b/initdb.py index 456c6f97b..f9138a7f5 100644 --- a/initdb.py +++ b/initdb.py @@ -354,11 +354,14 @@ def initialize_database(): LogEntryKind.create(name="create_repo") LogEntryKind.create(name="push_repo") + LogEntryKind.create(name="push_repo_failed") LogEntryKind.create(name="pull_repo") + LogEntryKind.create(name="pull_repo_failed") LogEntryKind.create(name="delete_repo") LogEntryKind.create(name="create_tag") LogEntryKind.create(name="move_tag") LogEntryKind.create(name="delete_tag") + LogEntryKind.create(name="delete_tag_failed") LogEntryKind.create(name="revert_tag") LogEntryKind.create(name="add_repo_permission") LogEntryKind.create(name="change_repo_permission") @@ -451,6 +454,7 @@ def initialize_database(): LogEntryKind.create(name="cancel_build") LogEntryKind.create(name="login_success") + LogEntryKind.create(name="login_failure") LogEntryKind.create(name="logout_success") LogEntryKind.create(name="permanently_delete_tag") diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js index e023550ba..7da7ee610 100644 --- a/static/js/directives/ui/logs-view.js +++ b/static/js/directives/ui/logs-view.js @@ -21,9 +21,9 @@ angular.module('quay').directive('logsView', function () { 'repository': '=repository', 'allLogs': '@allLogs' }, - controller: function($scope, $element, $sce, Restangular, ApiService, TriggerService, - StringBuilderService, ExternalNotificationData, UtilService, - Features, humanizeIntervalFilter, humanizeDateFilter, StateService) { + controller: function ($scope, $element, $sce, Restangular, ApiService, TriggerService, + StringBuilderService, ExternalNotificationData, UtilService, + Features, humanizeIntervalFilter, humanizeDateFilter, StateService) { $scope.inReadOnlyMode = StateService.inReadOnlyMode(); $scope.Features = Features; $scope.loading = true; @@ -43,18 +43,18 @@ angular.module('quay').directive('logsView', function () { $scope.options.monthAgo = moment().subtract(1, 'month').calendar(); $scope.options.now = new Date(datetime.getUTCFullYear(), datetime.getUTCMonth(), datetime.getUTCDate()); - var getOffsetDate = function(date, days) { + var getOffsetDate = function (date, days) { return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); }; - var defaultPermSuffix = function(metadata) { + var defaultPermSuffix = function (metadata) { if (metadata.activating_username) { return ', when creating user is {activating_username}'; } return ''; }; - var getServiceKeyTitle = function(metadata) { + var getServiceKeyTitle = function (metadata) { if (metadata.name) { return metadata.name; } @@ -63,59 +63,59 @@ angular.module('quay').directive('logsView', function () { }; var logDescriptions = { - 'user_create': function(metadata) { - if(metadata.superuser) { + 'user_create': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} created user {username}'; } else { return 'User {username} created'; } }, - 'user_delete': function(metadata) { - if(metadata.superuser) { + 'user_delete': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} deleted user {username}'; } else { return 'User {username} deleted'; } }, - 'user_enable': function(metadata) { - if(metadata.superuser) { + 'user_enable': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} enabled user {username}'; } else { return 'User {username} enabled'; } }, - 'user_disable': function(metadata) { - if(metadata.superuser) { + 'user_disable': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} disabled user {username}'; } else { return 'User {username} disabled'; } }, - 'user_change_password': function(metadata) { - if(metadata.superuser) { + 'user_change_password': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} changed password of user {username}'; } else { return 'User {username} changed password'; } }, - 'user_change_email': function(metadata) { - if(metadata.superuser) { + 'user_change_email': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} changed email from {old_email} to {email}'; } else { return 'Changed email from {old_email} to {email}'; } }, - 'user_change_name': function(metadata) { - if(metadata.superuser) { + 'user_change_name': function (metadata) { + if (metadata.superuser) { return 'Superuser {superuser} renamed user {old_username} to {username}'; } else { return 'User {old_username} changed name to {username}'; } }, - 'user_change_invoicing': function(metadata) { + 'user_change_invoicing': function (metadata) { if (metadata.invoice_email) { return 'Enabled email invoicing'; - } else if(metadata.invoice_email_address) { + } else if (metadata.invoice_email_address) { return 'Set email invoicing address to {invoice_email_address}'; } else { return 'Disabled email invoicing'; @@ -136,7 +136,7 @@ angular.module('quay').directive('logsView', function () { 'repo_mirror_sync_failed': 'Mirror finished unsuccessfully for {message}[[{tags}]][[ {stdout}]][[ {stderr}]]', 'repo_mirror_sync_tag_success': 'Mirror of {tag} successful[[ to repository {namespace}/{repo}]][[ {message}]][[ {stdout}]][[ {stderr}]]', 'repo_mirror_sync_tag_failed': 'Mirror of {tag} failure[[ to repository {namespace}/{repo}]][[ {message}]][[ {stdout}]][[ {stderr}]]', - 'repo_mirror_config_changed': function(metadata) { + 'repo_mirror_config_changed': function (metadata) { switch (metadata.changed) { case 'sync_status': if (metadata.to === 'SYNC_CANCEL') { @@ -192,10 +192,10 @@ angular.module('quay').directive('logsView', function () { return 'Mirror {changed} changed to {to}'; default: return 'Mirror {changed} changed to {to}'; - } + } }, 'change_repo_state': 'Repository state changed to {state_changed}', - 'push_repo': function(metadata) { + 'push_repo': function (metadata) { if (metadata.tag) { return 'Push of {tag}[[ to repository {namespace}/{repo}]]'; } else if (metadata.release) { @@ -204,7 +204,7 @@ angular.module('quay').directive('logsView', function () { return 'Repository push[[ to {namespace}/{repo}]]'; } }, - 'repo_verb': function(metadata) { + 'repo_verb': function (metadata) { var prefix = ''; if (metadata.verb == 'squash') { prefix = 'Pull of squashed tag {tag}[[ from {namespace}/{repo}]]' @@ -228,7 +228,7 @@ angular.module('quay').directive('logsView', function () { return prefix; }, - 'pull_repo': function(metadata) { + 'pull_repo': function (metadata) { var description = 'repository {namespace}/{repo}'; if (metadata.tag) { description = 'tag {tag}[[ from repository {namespace}/{repo}]]'; @@ -259,25 +259,25 @@ angular.module('quay').directive('logsView', function () { } }, 'delete_repo': 'Delete repository {repo}', - 'change_repo_permission': function(metadata) { + 'change_repo_permission': function (metadata) { if (metadata.username) { return 'Change permission for [[user ]]{username}[[ in repository {namespace}/{repo}]] to {role}'; } else if (metadata.team) { - return 'Change permission for [[team ]]{team}[[ in repository {namespace}/{repo}]] to {role}'; + return 'Change permission for [[team ]]{team}[[ in repository {namespace}/{repo}]] to {role}'; } else if (metadata.token) { return 'Change permission for [[token ]]{token}[[ in repository {namespace}/{repo}]] to {role}'; } }, - 'delete_repo_permission': function(metadata) { + 'delete_repo_permission': function (metadata) { if (metadata.username) { return 'Remove permission for [[user ]]{username}[[ from repository {namespace}/{repo}]]'; } else if (metadata.team) { - return 'Remove permission for [[team ]]{team}[[ from repository {namespace}/{repo}]]'; + return 'Remove permission for [[team ]]{team}[[ from repository {namespace}/{repo}]]'; } else if (metadata.token) { return 'Remove permission for [[token ]]{token}[[ from repository {namespace}/{repo}]]'; } }, - 'revert_tag': function(metadata) { + 'revert_tag': function (metadata) { if (metadata.manifest_digest) { return 'Tag {tag} restored to {manifest_digest}'; } else { @@ -286,15 +286,15 @@ angular.module('quay').directive('logsView', function () { }, 'autoprune_tag_delete': 'Tag {tag} pruned[[ in repository {namespace}/{repo} by {performer}]]', 'delete_tag': 'Tag {tag} deleted[[ in repository {namespace}/{repo} by user {username}]]', - 'permanently_delete_tag': function(metadata){ - if (metadata.manifest_digest){ + 'permanently_delete_tag': function (metadata) { + if (metadata.manifest_digest) { return 'Tag {tag} referencing {manifest_digest} permanently deleted[[ in repository {namespace}/{repo} by user {username}]]'; } else { return 'Tag {tag} permanently deleted[[ in repository {namespace}/{repo} by user {username}]]' } }, 'create_tag': 'Tag {tag} created[[ in repository {namespace}/{repo} on image {image} by user {username}]]', - 'move_tag': function(metadata) { + 'move_tag': function (metadata) { if (metadata.manifest_digest) { return 'Tag {tag} moved[[ from {original_manifest_digest}]] to {manifest_digest}[[ in repository {namespace}/{repo} by user {username}]]'; } else { @@ -302,7 +302,7 @@ angular.module('quay').directive('logsView', function () { } }, 'change_repo_visibility': 'Change visibility[[ for repository {namespace}/{repo}]] to {visibility}', - 'change_repo_trust': function(metadata) { + 'change_repo_trust': function (metadata) { if (metadata.trust_enabled) { return 'Trust enabled[[ for {namespace}/{repo}]]'; } else { @@ -312,7 +312,7 @@ angular.module('quay').directive('logsView', function () { 'add_repo_accesstoken': 'Create access token {token}[[ in repository {repo}]]', 'delete_repo_accesstoken': 'Delete access token {token}[[ in repository {repo}]]', 'set_repo_description': 'Change description[[ for repository {namespace}/{repo} to {description}]]', - 'build_dockerfile': function(metadata) { + 'build_dockerfile': function (metadata) { if (metadata.trigger_id) { var triggerDescription = TriggerService.getDescription( metadata['service'], metadata['config']); @@ -323,17 +323,17 @@ angular.module('quay').directive('logsView', function () { 'org_create': 'Organization {namespace} created', 'org_delete': 'Organization {namespace} deleted', 'org_change_email': 'Change organization email from {old_email} to {email}', - 'org_change_invoicing': function(metadata) { + 'org_change_invoicing': function (metadata) { if (metadata.invoice_email) { return 'Enabled email invoicing'; - } else if(metadata.invoice_email_address) { + } else if (metadata.invoice_email_address) { return 'Set email invoicing address to {invoice_email_address}'; } else { return 'Disabled email invoicing'; } }, 'org_change_tag_expiration': 'Change time machine window to {tag_expiration}', - 'org_change_name': function(metadata) { + 'org_change_name': function (metadata) { if (metadata.superuser) { return 'Superuser {superuser} renamed organization from {old_name} to {new_name}'; } else { @@ -344,14 +344,14 @@ angular.module('quay').directive('logsView', function () { 'org_delete_team': 'Delete team {team}', 'org_add_team_member': 'Add member {member} to team {team}', 'org_remove_team_member': 'Remove member {member} from team {team}', - 'org_invite_team_member': function(metadata) { + 'org_invite_team_member': function (metadata) { if (metadata.user) { return 'Invite {user} to team {team}'; } else { return 'Invite {email} to team {team}'; } }, - 'org_delete_team_member_invite': function(metadata) { + 'org_delete_team_member_invite': function (metadata) { if (metadata.user) { return 'Rescind invite of {user} to team {team}'; } else { @@ -364,38 +364,38 @@ angular.module('quay').directive('logsView', function () { 'org_set_team_description': 'Change description of team {team}[[ to {description}]]', 'org_set_team_role': 'Change permission of team {team} to {role}', - 'create_prototype_permission': function(metadata) { + 'create_prototype_permission': function (metadata) { if (metadata.delegate_user) { return 'Create default permission: {role} for {delegate_user}' + defaultPermSuffix(metadata); } else if (metadata.delegate_team) { return 'Create default permission: {role} for {delegate_team}' + defaultPermSuffix(metadata); } }, - 'modify_prototype_permission': function(metadata) { + 'modify_prototype_permission': function (metadata) { if (metadata.delegate_user) { return 'Modify default permission: {role} (from {original_role}) for {delegate_user}' + defaultPermSuffix(metadata); } else if (metadata.delegate_team) { return 'Modify default permission: {role} (from {original_role}) for {delegate_team}' + defaultPermSuffix(metadata); } }, - 'delete_prototype_permission': function(metadata) { + 'delete_prototype_permission': function (metadata) { if (metadata.delegate_user) { return 'Delete default permission: {role} for {delegate_user}' + defaultPermSuffix(metadata); } else if (metadata.delegate_team) { return 'Delete default permission: {role} for {delegate_team}' + defaultPermSuffix(metadata); } }, - 'setup_repo_trigger': function(metadata) { + 'setup_repo_trigger': function (metadata) { var triggerDescription = TriggerService.getDescription( metadata['service'], metadata['config']); return 'Setup build trigger[[ - ' + triggerDescription + ']]'; }, - 'delete_repo_trigger': function(metadata) { + 'delete_repo_trigger': function (metadata) { var triggerDescription = TriggerService.getDescription( metadata['service'], metadata['config']); return 'Delete build trigger[[ - ' + triggerDescription + ']]'; }, - 'toggle_repo_trigger': function(metadata) { + 'toggle_repo_trigger': function (metadata) { var triggerDescription = TriggerService.getDescription( metadata['service'], metadata['config']); if (metadata.enabled) { @@ -410,24 +410,24 @@ angular.module('quay').directive('logsView', function () { 'reset_application_client_secret': 'Reset the client secret of application {application_name}[[ ' + 'with client ID {client_id}]]', - 'add_repo_notification': function(metadata) { + 'add_repo_notification': function (metadata) { var eventData = ExternalNotificationData.getEventInfo(metadata.event); return 'Add notification of event "' + eventData['title'] + '"[[ for repository {namespace}/{repo}]]'; }, - 'delete_repo_notification': function(metadata) { + 'delete_repo_notification': function (metadata) { var eventData = ExternalNotificationData.getEventInfo(metadata.event); return 'Delete notification of event "' + eventData['title'] + '"[[ for repository {namespace}/{repo}]]'; }, - 'reset_repo_notification': function(metadata) { + 'reset_repo_notification': function (metadata) { var eventData = ExternalNotificationData.getEventInfo(metadata.event); return 'Re-enable notification of event "' + eventData['title'] + '"[[ for repository {namespace}/{repo}]]'; }, 'regenerate_robot_token': 'Regenerated token for robot {robot}', - 'service_key_create': function(metadata) { + 'service_key_create': function (metadata) { if (metadata.preshared) { return 'Manual creation of[[ preshared service]] key {kid}[[ for service {service}]]'; } else { @@ -441,7 +441,7 @@ angular.module('quay').directive('logsView', function () { 'service_key_extend': 'Change of expiration of service key {kid}[[ from {old_expiration_date}] to {expiration_date}', 'service_key_rotate': 'Automatic rotation of service key {kid} by {user_agent}', - 'take_ownership': function(metadata) { + 'take_ownership': function (metadata) { if (metadata.was_user) { return 'Superuser {superuser} took ownership of user namespace {namespace}'; } else { @@ -452,7 +452,7 @@ angular.module('quay').directive('logsView', function () { 'manifest_label_add': 'Label {key} added to[[ manifest]] {manifest_digest}[[ under repository {namespace}/{repo}]]', 'manifest_label_delete': 'Label {key} deleted from[[ manifest]] {manifest_digest}[[ under repository {namespace}/{repo}]]', - 'change_tag_expiration': function(metadata) { + 'change_tag_expiration': function (metadata) { if (metadata.expiration_date && metadata.old_expiration_date) { return 'Tag {tag} set to expire on {expiration_date}[[ (previously {old_expiration_date})]]'; } else if (metadata.expiration_date) { @@ -466,7 +466,7 @@ angular.module('quay').directive('logsView', function () { 'create_app_specific_token': 'Created external application token {app_specific_token_title}', 'revoke_app_specific_token': 'Revoked external application token {app_specific_token_title}', - 'repo_mirror': function(metadata) { + 'repo_mirror': function (metadata) { if (metadata.message) { return 'Repository mirror {verb} by Skopeo: {message}'; } else { @@ -481,14 +481,14 @@ angular.module('quay').directive('logsView', function () { 'create_proxy_cache_config': 'Create proxy cache for {namespace}[[ with upstream {upstream_registry}]]', 'delete_proxy_cache_config': 'Create proxy cache for {namespace}', - 'start_build_trigger': function(metadata) { + 'start_build_trigger': function (metadata) { var triggerDescription = TriggerService.getDescription( metadata['service'], metadata['config']); return 'Manually start build from trigger[[ - ' + triggerDescription + ']]'; }, 'cancel_build': 'Cancel build {build_uuid}', - 'login_success': function(metadata) { - metadata["useragent"] = metadata.useragent.substring(0, 64)+ '...'; + 'login_success': function (metadata) { + metadata["useragent"] = metadata.useragent.length > 64 ? metadata.useragent.slice(0, 64) + '...' : metadata.useragent; if (metadata.type == 'v2auth') { var message = 'Login to registry[[ with'; @@ -505,11 +505,107 @@ angular.module('quay').directive('logsView', function () { return 'Login to Quay[[ with user-agent {useragent}]]'; } }, + 'login_failure': function (metadata) { + metadata["useragent"] = metadata.useragent.length > 64 ? metadata.useragent.slice(0, 64) + '...' : metadata.useragent; + + if (metadata.type == 'v2auth') { + var message = 'Login to registry failed[[ with'; + + if (metadata.kind == 'app_specific_token') { + message += ' app-specific token'; + + if (metadata.app_specific_token_title) { + message += ' {app_specific_token_title}'; + } + + if (metadata.username) { + message += ' (owned by {username})'; + } + + message += ' and' + } + else if (metadata.kind == 'robot') { + message += ' robot {robot} (owned by {username}) and'; + } + else if (metadata.kind == 'user') { + message += ' user {username} and'; + } + + message += ' user-agent {useragent}'; + + if (metadata.message) { + message += ' with message: {message}'; + } + + return message + ']]'; + } else if (metadata.type == 'quayauth') { + + if (metadata.kind == 'user') { + return 'Login to Quay failed[[ with username {username} and user-agent {useragent} and message: {message}]]'; + } + else if (metadata.kind == 'oauth') { + var message = 'API access to Quay failed[[ with'; + + if (metadata.token) { + message += ' token {token} (owned by {username} in application {application_name}) and'; + } + + message += ' user-agent {useragent}'; + + if (metadata.message) { + message += ' with message: {message}'; + } + + return message + ']]'; + } + } else { + return 'Login to Quay failed[[ with user-agent {useragent}]]'; + } + }, 'logout_success': 'Logout from Quay', 'create_namespace_autoprune_policy': 'Autoprune policy created for namespace {namespace}[[ with policy method {method} and value {value}]]', 'update_namespace_autoprune_policy': 'Autoprune policy updated for namespace {namespace}[[ with policy method {method} and value {value}]]', 'delete_namespace_autoprune_policy': 'Autoprune policy deleted for namespace {namespace}[[ with uuid {policy_uuid}]]', -}; + 'pull_repo_failed': function (metadata) { + var message = 'Pull from repository {namespace}/{repo} failed'; + + if (metadata.tag) { + message += '[[ for tag {tag} with message {message}]]'; + } else if (metadata.manifest_digest) { + message += '[[ for manifest {manifest_digest} with message {message}]]'; + } else if (metadata.message) { + message += '[[ with message {message}]]' + } + + return message; + }, + 'push_repo_failed': function (metadata) { + var message = 'Push to repository {namespace}/{repo} failed'; + + if (metadata.tag) { + message += '[[ for tag {tag} with message {message}]]'; + } else if (metadata.manifest_digest) { + message += '[[ for manifest {manifest_digest} with message {message}]]'; + } else if (metadata.message) { + message += '[[ with message {message}]]' + } + + return message; + }, + 'delete_tag_failed': function (metadata) { + var message = 'Delete tag {namespace}/{repo} failed'; + + if (metadata.tag) { + message += '[[ for tag {tag} with message {message}]]'; + } else if (metadata.manifest_digest) { + message += '[[ for manifest {manifest_digest} with message {message}]]'; + } else if (metadata.message) { + message += '[[ with message {message}]]' + } + + return message; + } + }; var logKinds = { 'user_create': 'Create user', @@ -531,8 +627,10 @@ angular.module('quay').directive('logsView', function () { 'delete_robot': 'Delete Robot Account', 'create_repo': 'Create Repository', 'push_repo': 'Push to repository', + 'push_repo_failed': 'Push to repository failed', 'repo_verb': 'Pull Repo Verb', 'pull_repo': 'Pull repository', + 'pull_repo_failed': 'Pull repository failed', 'delete_repo': 'Delete repository', 'change_repo_permission': 'Change repository permission', 'delete_repo_permission': 'Remove user permission from repository', @@ -543,9 +641,10 @@ angular.module('quay').directive('logsView', function () { 'set_repo_description': 'Change repository description', 'build_dockerfile': 'Build image from Dockerfile', 'delete_tag': 'Delete Tag', + 'delete_tag_failed': 'Delete Tag Failed', 'create_tag': 'Create Tag', 'move_tag': 'Move Tag', - 'revert_tag':'Restore Tag', + 'revert_tag': 'Restore Tag', 'org_create': 'Create organization', 'org_delete': 'Delete organization', 'org_change_email': 'Change organization email', @@ -606,6 +705,7 @@ angular.module('quay').directive('logsView', function () { 'cancel_build': 'Cancel build', 'login_success': 'Login success', 'permanently_delete_tag': 'Permanently Delete Tag', + 'login_failure': 'Login failure', 'autoprune_tag_delete': 'Autoprune worker tag deletion', // Note: these are deprecated. @@ -613,11 +713,11 @@ angular.module('quay').directive('logsView', function () { 'delete_repo_webhook': 'Delete webhook' }; - var getDateString = function(date) { + var getDateString = function (date) { return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear(); }; - var getUrl = function(suffix) { + var getUrl = function (suffix) { var url = UtilService.getRestUrl('user/' + suffix); if ($scope.organization) { url = UtilService.getRestUrl('organization', $scope.organization.name, suffix); @@ -635,7 +735,7 @@ angular.module('quay').directive('logsView', function () { return url; }; - var update = function() { + var update = function () { var hasValidUser = !!$scope.user; var hasValidOrg = !!$scope.organization; var hasValidRepo = $scope.repository && $scope.repository.namespace; @@ -655,14 +755,14 @@ angular.module('quay').directive('logsView', function () { var aggregateUrl = getUrl('aggregatelogs').toString(); var loadAggregate = Restangular.one(aggregateUrl); - loadAggregate.customGET().then(function(resp) { + loadAggregate.customGET().then(function (resp) { $scope.chart = new LogUsageChart(logKinds); - $($scope.chart).bind('filteringChanged', function(e) { - $scope.$apply(function() { $scope.kindsAllowed = e.allowed; }); + $($scope.chart).bind('filteringChanged', function (e) { + $scope.$apply(function () { $scope.kindsAllowed = e.allowed; }); }); $scope.chart.draw('bar-chart', resp.aggregated, $scope.options.logStartDate, - $scope.options.logEndDate); + $scope.options.logEndDate); $scope.chartLoading = false; }).catch(function (resp) { if (resp.status === 501) { @@ -679,7 +779,7 @@ angular.module('quay').directive('logsView', function () { $scope.nextPage(); }; - $scope.nextPage = function() { + $scope.nextPage = function () { if ($scope.loading || !$scope.hasAdditional) { return; } $scope.loading = true; @@ -690,7 +790,7 @@ angular.module('quay').directive('logsView', function () { url.setQueryParameter('next_page', $scope.nextPageToken); var loadLogs = Restangular.one(url.toString()); - loadLogs.customGET().then(function(resp) { + loadLogs.customGET().then(function (resp) { if ($scope.loadCounter != currentCounter) { // Loaded logs data is out of date. return; @@ -704,7 +804,7 @@ angular.module('quay').directive('logsView', function () { $scope.nextPage(); } - resp.logs.forEach(function(log) { + resp.logs.forEach(function (log) { $scope.logs.push(log); }); @@ -718,24 +818,24 @@ angular.module('quay').directive('logsView', function () { }); }; - $scope.toggleChart = function() { + $scope.toggleChart = function () { $scope.chartVisible = !$scope.chartVisible; }; - $scope.isVisible = function(allowed, kind) { + $scope.isVisible = function (allowed, kind) { return allowed == null || allowed.hasOwnProperty(kind); }; - $scope.toggleExpanded = function(log) { + $scope.toggleExpanded = function (log) { log._expanded = !log._expanded; }; - $scope.getColor = function(kind, chart) { + $scope.getColor = function (kind, chart) { if (!chart) { return 'gray'; } return chart.getColor(kind); }; - $scope.getDescription = function(log, full_description) { + $scope.getDescription = function (log, full_description) { log.metadata['_ip'] = log.ip ? log.ip : null; // Note: This is for back-compat for logs that previously did not have namespaces. @@ -746,14 +846,14 @@ angular.module('quay').directive('logsView', function () { log.metadata['namespace'] = log.metadata['namespace'] || namespace || ''; return StringBuilderService.buildString(logDescriptions[log.kind] || log.kind, log.metadata, - null, !full_description); + null, !full_description); }; - $scope.showExportLogs = function() { + $scope.showExportLogs = function () { $scope.exportLogsInfo = {}; }; - $scope.exportLogs = function(exportLogsInfo, callback) { + $scope.exportLogs = function (exportLogsInfo, callback) { if (!exportLogsInfo.urlOrEmail) { callback(false); return; @@ -770,7 +870,7 @@ angular.module('quay').directive('logsView', function () { data['callback_email'] = urlOrEmail; } - runExport.customPOST(data).then(function(resp) { + runExport.customPOST(data).then(function (resp) { bootbox.alert('Usage logs export queued with ID `' + resp['export_id'] + '`') callback(true); }, ApiService.errorDisplay('Could not start logs export', callback)); diff --git a/static/js/services/string-builder-service.js b/static/js/services/string-builder-service.js index c4bcb8d72..215dfebe0 100644 --- a/static/js/services/string-builder-service.js +++ b/static/js/services/string-builder-service.js @@ -37,6 +37,7 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f 'new_name': 'sitemap', 'app_specific_token_title': 'key', 'useragent': 'user-secret', + 'message': 'exclamation-triangle', }; var allowMarkdown = { diff --git a/test/fixtures.py b/test/fixtures.py index 53b043b5d..fa0745b02 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -3,7 +3,6 @@ import inspect import os import shutil from collections import namedtuple -from test.testconfig import FakeTransaction import pytest from flask import Flask, jsonify @@ -34,6 +33,7 @@ from path_converters import ( RepositoryPathRedirectConverter, V1CreateRepositoryPathConverter, ) +from test.testconfig import FakeTransaction INIT_DB_PATH = 0 @@ -182,6 +182,11 @@ def appconfig(database_uri): "MAIL_DEFAULT_SENDER": "admin@example.com", "DATABASE_SECRET_KEY": "anothercrazykey!", "FEATURE_PROXY_CACHE": True, + "ACTION_LOG_AUDIT_LOGINS": True, + "ACTION_LOG_AUDIT_LOGIN_FAILURES": True, + "ACTION_LOG_AUDIT_PULL_FAILURES": True, + "ACTION_LOG_AUDIT_PUSH_FAILURES": True, + "ACTION_LOG_AUDIT_DELETE_FAILURES": True, } return conf diff --git a/test/testconfig.py b/test/testconfig.py index a89196c07..8d1b54f0a 100644 --- a/test/testconfig.py +++ b/test/testconfig.py @@ -116,5 +116,10 @@ class TestConfig(DefaultConfig): FEATURE_RH_MARKETPLACE = True FEATURE_AUTO_PRUNE = True + ACTION_LOG_AUDIT_LOGINS = True + ACTION_LOG_AUDIT_LOGIN_FAILURES = True + ACTION_LOG_AUDIT_PULL_FAILURES = True + ACTION_LOG_AUDIT_PUSH_FAILURES = True + ACTION_LOG_AUDIT_DELETE_FAILURES = True AUTOPRUNE_FETCH_TAGS_PAGE_LIMIT = 2 AUTOPRUNE_FETCH_REPOSITORIES_PAGE_LIMIT = 2 diff --git a/util/config/schema.py b/util/config/schema.py index ec35a3388..917ea8598 100644 --- a/util/config/schema.py +++ b/util/config/schema.py @@ -347,6 +347,26 @@ CONFIG_SCHEMA = { "description": "Whether to log all registry API and Quay API/UI logins event to the action log. Defaults to True", "x-example": False, }, + "ACTION_LOG_AUDIT_LOGIN_FAILURES": { + "type": "boolean", + "description": "Whether logging of failed logins attempts is enabled. Defaults to False", + "x-example": True, + }, + "ACTION_LOG_AUDIT_PULL_FAILURES": { + "type": "boolean", + "description": "Whether logging of failed image pull attempts is enabled. Defaults to False", + "x-example": True, + }, + "ACTION_LOG_AUDIT_PUSH_FAILURES": { + "type": "boolean", + "description": "Whether logging of failed image push attempts is enabled. Defaults to False", + "x-example": True, + }, + "ACTION_LOG_AUDIT_DELETE_FAILURES": { + "type": "boolean", + "description": "Whether logging of failed image delete attempts is enabled. Defaults to False", + "x-example": True, + }, "ACTION_LOG_ARCHIVE_LOCATION": { "type": "string", "description": "If action log archiving is enabled, the storage engine in which to place the " diff --git a/web/cypress/test/quay-db-data.txt b/web/cypress/test/quay-db-data.txt index a5f8c9c5c..245557a9d 100644 --- a/web/cypress/test/quay-db-data.txt +++ b/web/cypress/test/quay-db-data.txt @@ -5569,7 +5569,7 @@ COPY public.accesstokenkind (id, name) FROM stdin; -- COPY public.alembic_version (version_num) FROM stdin; -8d47693829a0 +3f8e3657bb67 \. @@ -6237,6 +6237,10 @@ COPY public.logentrykind (id, name) FROM stdin; 101 create_namespace_autoprune_policy 102 update_namespace_autoprune_policy 103 delete_namespace_autoprune_policy +104 login_failure +105 push_repo_failed +106 pull_repo_failed +107 delete_tag_failed \. @@ -8079,7 +8083,8 @@ SELECT pg_catalog.setval('public.logentry_id_seq', 1, false); -- Name: logentrykind_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.logentrykind_id_seq', 103, true); + +SELECT pg_catalog.setval('public.logentrykind_id_seq', 107, true); -- @@ -8163,7 +8168,7 @@ SELECT pg_catalog.setval('public.namespacegeorestriction_id_seq', 1, false); -- Name: notification_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.notification_id_seq', 1, true); +SELECT pg_catalog.setval('public.notification_id_seq', 1, false); -- @@ -8401,14 +8406,14 @@ SELECT pg_catalog.setval('public.role_id_seq', 3, true); -- Name: servicekey_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.servicekey_id_seq', 1, true); +SELECT pg_catalog.setval('public.servicekey_id_seq', 1, false); -- -- Name: servicekeyapproval_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.servicekeyapproval_id_seq', 1, true); +SELECT pg_catalog.setval('public.servicekeyapproval_id_seq', 1, false); --