diff --git a/docs/users/database-changes.rst b/docs/users/database-changes.rst index adc0124a8..cee4adab1 100644 --- a/docs/users/database-changes.rst +++ b/docs/users/database-changes.rst @@ -103,7 +103,7 @@ Here is an example of a JSON response:: { "request_meta": { - "HTTP-client-IP": "127.0.0.1", + "HTTP-Client-IP": "127.0.0.1", "HTTP-User-Agent": "user-agent" }, "summary": { diff --git a/irrd/__init__.py b/irrd/__init__.py index 35f089f1e..69d5f5db5 100644 --- a/irrd/__init__.py +++ b/irrd/__init__.py @@ -1,2 +1,3 @@ __version__ = "4.4-dev" ENV_MAIN_PROCESS_PID = "IRRD_MAIN_PROCESS_PID" +META_KEY_HTTP_CLIENT_IP = "HTTP-Client-IP" diff --git a/irrd/rpki/notifications.py b/irrd/rpki/notifications.py index c064744bc..b4e589be2 100644 --- a/irrd/rpki/notifications.py +++ b/irrd/rpki/notifications.py @@ -28,7 +28,7 @@ def notify_rpki_invalid_owners( if not get_setting("rpki.notify_invalid_enabled"): return 0 - rpsl_objs = [] + rpsl_objs: List[RPSLObject] = [] for obj in rpsl_dicts_now_invalid: source = obj["source"] authoritative = get_setting(f"sources.{source}.authoritative") diff --git a/irrd/rpsl/auth.py b/irrd/rpsl/auth.py index a3fa16cd2..5f76e8ccc 100644 --- a/irrd/rpsl/auth.py +++ b/irrd/rpsl/auth.py @@ -44,25 +44,28 @@ def get_password_hashers(permit_legacy=True): def verify_auth_lines( auth_lines: List[str], passwords: List[str], keycert_obj_pk: Optional[str] = None -) -> bool: +) -> Optional[str]: """ Verify whether one of a given list of passwords matches any of the auth lines in the provided list, or match the keycert object PK. + Returns None for auth failed, a scheme or PGP key PK + for success. """ hashers = get_password_hashers(permit_legacy=True) for auth in auth_lines: if keycert_obj_pk and auth.upper() == keycert_obj_pk.upper(): - return True + return keycert_obj_pk.upper() if " " not in auth: continue scheme, hash = auth.split(" ", 1) - hasher = hashers.get(scheme.upper()) + scheme = scheme.upper() + hasher = hashers.get(scheme) if hasher: for password in passwords: try: if hasher.verify(password, hash): - return True + return scheme except ValueError: pass - return False + return None diff --git a/irrd/rpsl/rpsl_objects.py b/irrd/rpsl/rpsl_objects.py index e6f479611..b5ab77fa9 100644 --- a/irrd/rpsl/rpsl_objects.py +++ b/irrd/rpsl/rpsl_objects.py @@ -435,7 +435,7 @@ def clean(self): "Either all password auth hashes in a submitted mntner must be dummy objects, or none." ) - def verify_auth(self, passwords: List[str], keycert_obj_pk: Optional[str] = None) -> bool: + def verify_auth(self, passwords: List[str], keycert_obj_pk: Optional[str] = None) -> Optional[str]: return verify_auth_lines(self.parsed_data["auth"], passwords, keycert_obj_pk) def has_dummy_auth_value(self) -> bool: diff --git a/irrd/rpsl/tests/test_rpsl_objects.py b/irrd/rpsl/tests/test_rpsl_objects.py index 2a22fecae..a10de8929 100644 --- a/irrd/rpsl/tests/test_rpsl_objects.py +++ b/irrd/rpsl/tests/test_rpsl_objects.py @@ -405,13 +405,13 @@ def test_verify(self, tmp_gpg_dir): rpsl_text + "auth: UNKNOWN_HASH foo\nauth: MD5-PW 💩", strict_validation=False ) - assert obj.verify_auth(["crypt-password"]) - assert obj.verify_auth(["md5-password"]) - assert obj.verify_auth(["bcrypt-password"]) - assert obj.verify_auth(["md5-password"], "PGPKey-80F238C6") - assert not obj.verify_auth(["other-password"]) - assert not obj.verify_auth([KEY_CERT_SIGNED_MESSAGE_CORRUPT]) - assert not obj.verify_auth([KEY_CERT_SIGNED_MESSAGE_WRONG_KEY]) + assert obj.verify_auth(["crypt-password"]) == "CRYPT-PW" + assert obj.verify_auth(["md5-password"]) == "MD5-PW" + assert obj.verify_auth(["bcrypt-password"]) == "BCRYPT-PW" + assert obj.verify_auth(["md5-password"], "PGPKey-80F238C6") == "PGPKEY-80F238C6" + assert obj.verify_auth(["other-password"]) is None + assert obj.verify_auth([KEY_CERT_SIGNED_MESSAGE_CORRUPT]) is None + assert obj.verify_auth([KEY_CERT_SIGNED_MESSAGE_WRONG_KEY]) is None class TestRPSLPeeringSet: diff --git a/irrd/server/http/endpoints_api.py b/irrd/server/http/endpoints_api.py index 85adf50c7..b08d70728 100644 --- a/irrd/server/http/endpoints_api.py +++ b/irrd/server/http/endpoints_api.py @@ -14,6 +14,7 @@ from irrd.updates.handler import ChangeSubmissionHandler from irrd.utils.validators import RPSLChangeSubmission, RPSLSuspensionSubmission +from ... import META_KEY_HTTP_CLIENT_IP from ...storage.models import AuthoritativeChangeOrigin from ..whois.query_parser import WhoisQueryParser from ..whois.query_response import WhoisQueryResponseType @@ -84,7 +85,7 @@ async def _handle_submission(self, request: Request, delete=False): except (JSONDecodeError, KeyError): request_meta = {} - request_meta["HTTP-client-IP"] = request.client.host + request_meta[META_KEY_HTTP_CLIENT_IP] = request.client.host request_meta["HTTP-User-Agent"] = request.headers.get("User-Agent") try: remote_ip = IP(request.client.host) @@ -113,7 +114,7 @@ async def post(self, request: Request) -> Response: return PlainTextResponse(str(error), status_code=400) request_meta = { - "HTTP-client-IP": request.client.host, + META_KEY_HTTP_CLIENT_IP: request.client.host, "HTTP-User-Agent": request.headers.get("User-Agent"), } handler = ChangeSubmissionHandler() diff --git a/irrd/server/http/tests/test_endpoints.py b/irrd/server/http/tests/test_endpoints.py index af5ac2434..7e085d0f2 100644 --- a/irrd/server/http/tests/test_endpoints.py +++ b/irrd/server/http/tests/test_endpoints.py @@ -4,6 +4,7 @@ from starlette.requests import HTTPConnection from starlette.testclient import TestClient +from irrd import META_KEY_HTTP_CLIENT_IP from irrd.storage.database_handler import DatabaseHandler from irrd.storage.models import AuthoritativeChangeOrigin from irrd.storage.preload import Preloader @@ -186,7 +187,7 @@ def test_endpoint(self, monkeypatch): data=expected_data, origin=AuthoritativeChangeOrigin.webapi, delete=False, - request_meta={"HTTP-client-IP": "testclient", "HTTP-User-Agent": "testclient", "meta": 2}, + request_meta={META_KEY_HTTP_CLIENT_IP: "testclient", "HTTP-User-Agent": "testclient", "meta": 2}, remote_ip=None, ) mock_handler.send_notification_target_reports.assert_called_once() @@ -199,7 +200,7 @@ def test_endpoint(self, monkeypatch): data=expected_data, origin=AuthoritativeChangeOrigin.webapi, delete=True, - request_meta={"HTTP-client-IP": "testclient", "HTTP-User-Agent": "testclient"}, + request_meta={META_KEY_HTTP_CLIENT_IP: "testclient", "HTTP-User-Agent": "testclient"}, remote_ip=None, ) mock_handler.send_notification_target_reports.assert_called_once() @@ -236,7 +237,7 @@ def test_endpoint(self, monkeypatch): assert response_post.text == '{"response":true}' mock_handler.load_suspension_submission.assert_called_once_with( data=expected_data, - request_meta={"HTTP-client-IP": "testclient", "HTTP-User-Agent": "testclient"}, + request_meta={META_KEY_HTTP_CLIENT_IP: "testclient", "HTTP-User-Agent": "testclient"}, ) mock_handler.reset_mock() diff --git a/irrd/storage/alembic/versions/5d942647566e_add_changelog.py b/irrd/storage/alembic/versions/5d942647566e_add_changelog.py new file mode 100644 index 000000000..9e1aa51b5 --- /dev/null +++ b/irrd/storage/alembic/versions/5d942647566e_add_changelog.py @@ -0,0 +1,78 @@ +"""add_changelog + +Revision ID: 5d942647566e +Revises: 500027f85a55 +Create Date: 2023-06-27 23:04:08.424619 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "5d942647566e" +down_revision = "500027f85a55" +branch_labels = None +depends_on = None + + +def upgrade(): + updaterequesttype = postgresql.ENUM( + "CREATE", "MODIFY", "DELETE", name="updaterequesttype", create_type=False + ) + updaterequesttype.create(op.get_bind(), checkfirst=True) + + op.create_table( + "change_log", + sa.Column( + "pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("auth_by_user_id", postgresql.UUID(), nullable=True), + sa.Column("auth_by_user_email", sa.String(), nullable=True), + sa.Column("auth_by_api_key_id", postgresql.UUID(), nullable=True), + sa.Column("auth_by_api_key_id_fixed", postgresql.UUID(), nullable=True), + sa.Column("auth_through_mntner_id", postgresql.UUID(), nullable=True), + sa.Column("auth_through_rpsl_mntner_pk", sa.String(), nullable=True), + sa.Column("auth_by_rpsl_mntner_password", sa.Boolean(), nullable=False), + sa.Column("auth_by_rpsl_mntner_pgp_key", sa.Boolean(), nullable=False), + sa.Column("auth_by_override", sa.Boolean(), nullable=True), + sa.Column("from_email", sa.String(), nullable=True), + sa.Column("from_ip", postgresql.INET(), nullable=True), + sa.Column("auth_change_descr", sa.String(), nullable=True), + sa.Column("auth_affected_user_id", postgresql.UUID(), nullable=True), + sa.Column("auth_affected_mntner_id", postgresql.UUID(), nullable=True), + sa.Column("rpsl_target_request_type", updaterequesttype, nullable=True), + sa.Column("rpsl_target_pk", sa.String(), nullable=True), + sa.Column("rpsl_target_source", sa.String(), nullable=True), + sa.Column("rpsl_target_object_class", sa.String(), nullable=True), + sa.Column("rpsl_target_object_text_old", sa.Text(), nullable=True), + sa.Column("rpsl_target_object_text_new", sa.Text(), nullable=True), + sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["auth_affected_mntner_id"], ["auth_mntner.pk"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["auth_affected_user_id"], ["auth_user.pk"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["auth_by_api_key_id"], ["auth_api_token.pk"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["auth_by_user_id"], ["auth_user.pk"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["auth_through_mntner_id"], ["auth_mntner.pk"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("pk"), + ) + op.create_index( + op.f("ix_change_log_auth_affected_mntner_id"), "change_log", ["auth_affected_mntner_id"], unique=False + ) + op.create_index( + op.f("ix_change_log_auth_through_mntner_id"), "change_log", ["auth_through_mntner_id"], unique=False + ) + op.create_index( + op.f("ix_change_log_auth_through_rpsl_mntner_pk"), + "change_log", + ["auth_through_rpsl_mntner_pk"], + unique=False, + ) + op.create_index(op.f("ix_change_log_rpsl_target_pk"), "change_log", ["rpsl_target_pk"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_change_log_rpsl_target_pk"), table_name="change_log") + op.drop_index(op.f("ix_change_log_auth_through_rpsl_mntner_pk"), table_name="change_log") + op.drop_index(op.f("ix_change_log_auth_through_mntner_id"), table_name="change_log") + op.drop_index(op.f("ix_change_log_auth_affected_mntner_id"), table_name="change_log") + op.drop_table("change_log") diff --git a/irrd/storage/models.py b/irrd/storage/models.py index 1651c72a6..ef51bed99 100644 --- a/irrd/storage/models.py +++ b/irrd/storage/models.py @@ -10,6 +10,7 @@ from irrd.rpki.status import RPKIStatus from irrd.rpsl.rpsl_objects import lookup_field_names from irrd.scopefilter.status import ScopeFilterStatus +from irrd.updates.parser_state import UpdateRequestType class DatabaseOperation(enum.Enum): @@ -478,6 +479,75 @@ def __repr__(self): return f"AuthMntner<{self.pk}, {self.rpsl_mntner_pk}>" +class ChangeLog(Base): # type: ignore + __tablename__ = "change_log" + + pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) + auth_by_user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="SET NULL"), nullable=True) + auth_by_user_email = sa.Column(sa.String, nullable=True) + auth_by_api_key_id = sa.Column( + pg.UUID, sa.ForeignKey("auth_api_token.pk", ondelete="SET NULL"), nullable=True + ) + auth_by_api_key = relationship( + "AuthApiToken", + foreign_keys="ChangeLog.auth_by_api_key_id", + ) + auth_by_api_key_id_fixed = sa.Column(pg.UUID, nullable=True) + auth_through_mntner_id = sa.Column( + pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="SET NULL"), index=True, nullable=True + ) + auth_through_mntner = relationship( + "AuthMntner", + foreign_keys="ChangeLog.auth_through_mntner_id", + ) + auth_through_rpsl_mntner_pk = sa.Column(sa.String, index=True, nullable=True) + auth_by_rpsl_mntner_password = sa.Column(sa.Boolean, nullable=False, default=False) + auth_by_rpsl_mntner_pgp_key = sa.Column(sa.Boolean, nullable=False, default=False) + auth_by_override = sa.Column(sa.Boolean, default=False) + + from_email = sa.Column(sa.String, nullable=True) + from_ip = sa.Column(pg.INET, nullable=True) + + auth_change_descr = sa.Column(sa.String, nullable=True) + auth_affected_user_id = sa.Column( + pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="SET NULL"), nullable=True + ) + auth_affected_mntner_id = sa.Column( + pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="SET NULL"), index=True, nullable=True + ) + auth_affected_mntner = relationship( + "AuthMntner", + foreign_keys="ChangeLog.auth_affected_mntner_id", + ) + auth_affected_user = relationship( + "AuthUser", + foreign_keys="ChangeLog.auth_affected_user_id", + ) + + rpsl_target_request_type = sa.Column(sa.Enum(UpdateRequestType), nullable=True) + rpsl_target_pk = sa.Column(sa.String, index=True, nullable=True) + rpsl_target_source = sa.Column(sa.String, nullable=True) + rpsl_target_object_class = sa.Column(sa.String, nullable=True) + rpsl_target_object_text_old = sa.Column(sa.Text, nullable=True) + rpsl_target_object_text_new = sa.Column(sa.Text, nullable=True) + + timestamp = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + + def __repr__(self): + return f"<{self.pk}/{self.description()}>" + + def description(self) -> str: + if self.rpsl_target_pk: + return ( + f"{self.rpsl_target_request_type.value} of" + f" {self.rpsl_target_object_class} {self.rpsl_target_pk} in {self.rpsl_target_source}" + ) + elif self.auth_change_descr: + return self.auth_change_descr + else: # pragma: no cover + return "" + + # Before you update this, please check the storage documentation for changing lookup fields. expected_lookup_field_names = { "admin-c", diff --git a/irrd/updates/handler.py b/irrd/updates/handler.py index d91a77240..dea13b779 100644 --- a/irrd/updates/handler.py +++ b/irrd/updates/handler.py @@ -46,7 +46,11 @@ def load_text_blob( self.database_handler, origin, self._pgp_key_id, internal_authenticated_user ) change_requests = parse_change_requests( - object_texts_blob, self.database_handler, auth_validator, reference_validator + object_texts_blob, + self.database_handler, + auth_validator, + reference_validator, + self.request_meta, ) self._handle_change_requests(change_requests, reference_validator, auth_validator) @@ -90,7 +94,12 @@ def load_change_submission( assert object_text # enforced by pydantic change_requests.append( ChangeRequest( - object_text, self.database_handler, auth_validator, reference_validator, delete_reason + object_text, + self.database_handler, + auth_validator, + reference_validator, + delete_reason, + self.request_meta, ) ) diff --git a/irrd/updates/parser.py b/irrd/updates/parser.py index 61843def0..3fd202c2e 100644 --- a/irrd/updates/parser.py +++ b/irrd/updates/parser.py @@ -2,6 +2,8 @@ import logging from typing import Dict, List, Optional, Set, Union +import sqlalchemy.orm as saorm + from irrd.conf import get_setting from irrd.rpki.status import RPKIStatus from irrd.rpki.validators import SingleRouteROAValidator @@ -14,9 +16,15 @@ from irrd.storage.queries import RPSLDatabaseQuery from irrd.utils.text import remove_auth_hashes, splitline_unicodesafe +from .. import META_KEY_HTTP_CLIENT_IP from .parser_state import SuspensionRequestType, UpdateRequestStatus, UpdateRequestType from .suspension import reactivate_for_mntner, suspend_for_mntner -from .validators import AuthValidator, ReferenceValidator, RulesValidator +from .validators import ( + AuthValidator, + ReferenceValidator, + RulesValidator, + ValidatorResult, +) logger = logging.getLogger(__name__) @@ -33,7 +41,6 @@ class ChangeRequest: rpsl_obj_current: Optional[RPSLObject] = None status = UpdateRequestStatus.PROCESSING request_type: Optional[UpdateRequestType] = None - mntners_notify: List[RPSLMntner] error_messages: List[str] info_messages: List[str] @@ -44,7 +51,8 @@ def __init__( database_handler: DatabaseHandler, auth_validator: AuthValidator, reference_validator: ReferenceValidator, - delete_reason=Optional[str], + delete_reason: Optional[str], + request_meta: Dict[str, Optional[str]], ) -> None: """ Initialise a new change request for a single RPSL object. @@ -68,12 +76,12 @@ def __init__( self.auth_validator = auth_validator self.reference_validator = reference_validator self.rpsl_text_submitted = rpsl_text_submitted - self.mntners_notify = [] - self.used_override = False + self._auth_result: Optional[ValidatorResult] = None self._cached_roa_validity: Optional[bool] = None self.roa_validator = SingleRouteROAValidator(database_handler) self.scopefilter_validator = ScopeFilterValidator() self.rules_validator = RulesValidator(database_handler) + self.request_meta = request_meta try: self.rpsl_obj_new = rpsl_object_from_text(rpsl_text_submitted, strict_validation=True) @@ -159,6 +167,24 @@ def save(self) -> None: f"{id(self)}: Saving change for {self.rpsl_obj_new}: inserting/updating current object" ) self.database_handler.upsert_rpsl_object(self.rpsl_obj_new, JournalEntryOrigin.auth_change) + + if self._auth_result: + session = saorm.Session(bind=self.database_handler._connection) + change_log = self._auth_result.to_change_log() + # TODO: extract constant + change_log.from_ip = self.request_meta.get(META_KEY_HTTP_CLIENT_IP, None) + change_log.from_email = self.request_meta.get("From", None) + change_log.rpsl_target_request_type = self.request_type + change_log.rpsl_target_pk = self.rpsl_obj_new.pk() + change_log.rpsl_target_source = self.rpsl_obj_new.source() + change_log.rpsl_target_object_class = self.rpsl_obj_new.rpsl_object_class + if self.rpsl_obj_current: + change_log.rpsl_target_object_text_old = self.rpsl_obj_current.render_rpsl_text() + if self.request_type != UpdateRequestType.DELETE: + change_log.rpsl_target_object_text_new = self.rpsl_obj_new.render_rpsl_text() + session.add(change_log) + session.flush() + self.status = UpdateRequestStatus.SAVED def is_valid(self) -> bool: @@ -247,13 +273,15 @@ def notification_targets(self) -> Set[str]: """ targets: Set[str] = set() status_qualifies_notification = self.is_valid() or self.status == UpdateRequestStatus.ERROR_AUTH - if self.used_override or not status_qualifies_notification: + used_override = self._auth_result and self._auth_result.auth_method.used_override() + if used_override or not status_qualifies_notification: return targets mntner_attr = "upd-to" if self.status == UpdateRequestStatus.ERROR_AUTH else "mnt-nfy" - for mntner in self.mntners_notify: - for email in mntner.parsed_data.get(mntner_attr, []): - targets.add(email) + if self._auth_result: + for mntner in self._auth_result.mntners_notify: + for email in mntner.parsed_data.get(mntner_attr, []): + targets.add(email) if self.rpsl_obj_current: for email in self.rpsl_obj_current.parsed_data.get("notify", []): @@ -286,18 +314,15 @@ def validate(self) -> bool: def _check_auth(self) -> bool: assert self.rpsl_obj_new - auth_result = self.auth_validator.process_auth(self.rpsl_obj_new, self.rpsl_obj_current) - self.info_messages += auth_result.info_messages - self.mntners_notify = auth_result.mntners_notify + self._auth_result = self.auth_validator.process_auth(self.rpsl_obj_new, self.rpsl_obj_current) + self.info_messages += self._auth_result.info_messages - if not auth_result.is_valid(): + if not self._auth_result.is_valid(): self.status = UpdateRequestStatus.ERROR_AUTH - self.error_messages += auth_result.error_messages - logger.debug(f"{id(self)}: Authentication check failed: {list(auth_result.error_messages)}") + self.error_messages += self._auth_result.error_messages + logger.debug(f"{id(self)}: Authentication check failed: {list(self._auth_result.error_messages)}") return False - self.used_override = auth_result.used_override - logger.debug(f"{id(self)}: Authentication check succeeded") return True @@ -536,7 +561,8 @@ def notification_targets(self) -> Set[str]: return set() def validate(self) -> bool: - if not self.auth_validator.check_override(): + override_method = self.auth_validator.check_override() + if not override_method: self.status = UpdateRequestStatus.ERROR_AUTH self.error_messages.append("Invalid authentication: override password invalid or missing") logger.debug(f"{id(self)}: Authentication check failed: override did not pass") @@ -550,6 +576,7 @@ def parse_change_requests( database_handler: DatabaseHandler, auth_validator: AuthValidator, reference_validator: ReferenceValidator, + request_meta: Dict[str, Optional[str]], ) -> List[Union[ChangeRequest, SuspensionRequest]]: """ Parse change requests, a text of RPSL objects along with metadata like @@ -614,6 +641,7 @@ def parse_change_requests( auth_validator, reference_validator, delete_reason=delete_reason, + request_meta=request_meta, ) ) diff --git a/irrd/updates/parser_state.py b/irrd/updates/parser_state.py index 447650f37..75187000e 100644 --- a/irrd/updates/parser_state.py +++ b/irrd/updates/parser_state.py @@ -1,4 +1,4 @@ -from enum import Enum, unique +from enum import Enum, auto, unique from irrd.conf import AUTH_SET_CREATION_COMMON_KEY, get_setting @@ -41,3 +41,20 @@ def for_set_name(set_name: str): if not setting: setting = get_setting(f"auth.set_creation.{AUTH_SET_CREATION_COMMON_KEY}.autnum_authentication") return getattr(RPSLSetAutnumAuthenticationMode, setting.upper()) + + +class AuthMethod(Enum): + OVERRIDE_PASSWORD = auto() + OVERRIDE_INTERNAL_AUTH = auto() + MNTNER_PASSWORD = auto() + MNTNER_PGP_KEY = auto() + MNTNER_INTERNAL_AUTH = auto() + MNTNER_API_KEY = auto() + MNTNER_IN_SAME_REQUEST = auto() + NONE = auto() + + def __bool__(self): + return self != AuthMethod.NONE + + def used_override(self) -> bool: + return self in [AuthMethod.OVERRIDE_PASSWORD, AuthMethod.OVERRIDE_INTERNAL_AUTH] diff --git a/irrd/updates/tests/test_handler.py b/irrd/updates/tests/test_handler.py index cab49822f..0a849eaa7 100644 --- a/irrd/updates/tests/test_handler.py +++ b/irrd/updates/tests/test_handler.py @@ -10,6 +10,7 @@ from irrd.utils.test_utils import flatten_mock_calls from ...utils.validators import RPSLChangeSubmission, RPSLSuspensionSubmission +from ...vendor.mock_alchemy.mocking import UnifiedAlchemyMagicMock from ..handler import ChangeSubmissionHandler from ..parser_state import SuspensionRequestType @@ -47,9 +48,11 @@ class TestChangeSubmissionHandler: # NOTE: the scope of this test also includes ChangeRequest, ReferenceValidator and AuthValidator - # this is more of an update handler integration test. - def test_parse_valid_new_objects_with_override(self, prepare_mocks): + def test_parse_valid_new_objects_with_override(self, prepare_mocks, monkeypatch): mock_dq, mock_dh, mock_email = prepare_mocks mock_dh.execute_query = lambda query: [] + mock_sa_session = UnifiedAlchemyMagicMock() + monkeypatch.setattr("irrd.updates.validators.saorm.Session", lambda bind: mock_sa_session) rpsl_text = textwrap.dedent(""" person: Placeholder Person Object @@ -153,8 +156,10 @@ def test_parse_valid_new_objects_with_override(self, prepare_mocks): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """) - def test_parse_valid_new_person_existing_mntner_pgp_key(self, prepare_mocks): + def test_parse_valid_new_person_existing_mntner_pgp_key(self, prepare_mocks, monkeypatch): mock_dq, mock_dh, mock_email = prepare_mocks + mock_sa_session = UnifiedAlchemyMagicMock() + monkeypatch.setattr("irrd.updates.validators.saorm.Session", lambda bind: mock_sa_session) person_text = textwrap.dedent(""" person: Placeholder Person Object @@ -366,8 +371,10 @@ def test_parse_invalid_new_objects_pgp_key_does_not_exist(self, prepare_mocks): assert mock_dh.mock_calls[0][0] == "commit" assert mock_dh.mock_calls[1][0] == "close" - def test_parse_valid_delete(self, prepare_mocks): + def test_parse_valid_delete(self, prepare_mocks, monkeypatch): mock_dq, mock_dh, mock_email = prepare_mocks + mock_sa_session = UnifiedAlchemyMagicMock() + monkeypatch.setattr("irrd.updates.validators.saorm.Session", lambda bind: mock_sa_session) rpsl_person = textwrap.dedent(""" person: Placeholder Person Object diff --git a/irrd/updates/tests/test_parser.py b/irrd/updates/tests/test_parser.py index f68d86d68..b1b454616 100644 --- a/irrd/updates/tests/test_parser.py +++ b/irrd/updates/tests/test_parser.py @@ -24,7 +24,12 @@ from irrd.utils.text import splitline_unicodesafe from ..parser import parse_change_requests -from ..parser_state import SuspensionRequestType, UpdateRequestStatus, UpdateRequestType +from ..parser_state import ( + AuthMethod, + SuspensionRequestType, + UpdateRequestStatus, + UpdateRequestType, +) from ..validators import ( AuthValidator, ReferenceValidator, @@ -76,7 +81,7 @@ def test_parse(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_inetnum, result_as_set, result_unknown, result_invalid = parse_change_requests( - self._request_text(), mock_dh, auth_validator, None + self._request_text(), mock_dh, auth_validator, None, {} ) assert result_inetnum.status == UpdateRequestStatus.PROCESSING, result_inetnum.error_messages @@ -139,9 +144,9 @@ def test_non_authorative_source(self, prepare_mocks): mock_dh.execute_query = lambda query: [] auth_validator = AuthValidator(mock_dh) - result = parse_change_requests(SAMPLE_MNTNER.replace("TEST", "TEST2"), mock_dh, auth_validator, None)[ - 0 - ] + result = parse_change_requests( + SAMPLE_MNTNER.replace("TEST", "TEST2"), mock_dh, auth_validator, None, {} + )[0] assert result.status == UpdateRequestStatus.ERROR_NON_AUTHORITIVE assert not result.is_valid() @@ -158,7 +163,7 @@ def test_validates_for_create(self, prepare_mocks): auth_validator.process_auth = lambda new, cur: invalid_auth_result invalid_create_text = SAMPLE_AS_SET.replace("AS65537:AS-SETTEST", "AS-SETTEST") - result = parse_change_requests(invalid_create_text, mock_dh, auth_validator, None)[0] + result = parse_change_requests(invalid_create_text, mock_dh, auth_validator, None, {})[0] assert not result.validate() assert result.status == UpdateRequestStatus.ERROR_PARSING @@ -167,7 +172,7 @@ def test_validates_for_create(self, prepare_mocks): # Test again with an UPDATE (which then fails on auth to stop) mock_dh.execute_query = lambda query: [{"object_text": SAMPLE_AS_SET}] - result = parse_change_requests(invalid_create_text, mock_dh, auth_validator, None)[0] + result = parse_change_requests(invalid_create_text, mock_dh, auth_validator, None, {})[0] assert not result.validate() assert result.error_messages == ["error catch"] @@ -181,7 +186,7 @@ def test_calls_rules_validator(self, prepare_mocks): invalid_auth_result.error_messages.add("error catch") auth_validator.process_auth = lambda new, cur: invalid_auth_result - result = parse_change_requests(SAMPLE_AS_SET, mock_dh, auth_validator, None)[0] + result = parse_change_requests(SAMPLE_AS_SET, mock_dh, auth_validator, None, {})[0] invalid_rules_result = ValidatorResult() invalid_rules_result.error_messages.add("rules fault") result.rules_validator.validate.return_value = invalid_rules_result @@ -195,7 +200,9 @@ def test_save_nonexistent_object(self, prepare_mocks): mock_dq, mock_dh = prepare_mocks mock_dh.execute_query = lambda query: [] - result_inetnum = parse_change_requests(self._request_text(), mock_dh, AuthValidator(mock_dh), None)[0] + result_inetnum = parse_change_requests( + self._request_text(), mock_dh, AuthValidator(mock_dh), None, {} + )[0] assert result_inetnum.status == UpdateRequestStatus.ERROR_PARSING assert not result_inetnum.is_valid() @@ -229,7 +236,13 @@ def test_check_references_valid(self, prepare_mocks): validator = ReferenceValidator(mock_dh) - result_inetnum = parse_change_requests(SAMPLE_INETNUM, mock_dh, AuthValidator(mock_dh), validator)[0] + result_inetnum = parse_change_requests( + SAMPLE_INETNUM, + mock_dh, + AuthValidator(mock_dh), + validator, + {}, + )[0] assert result_inetnum._check_references() assert result_inetnum.is_valid() assert flatten_mock_calls(mock_dq) == [ @@ -254,7 +267,13 @@ def test_check_references_invalid_referred_objects_dont_exist(self, prepare_mock mock_dh.execute_query = lambda query: next(query_results) validator = ReferenceValidator(mock_dh) - result_inetnum = parse_change_requests(SAMPLE_INETNUM, mock_dh, AuthValidator(mock_dh), validator)[0] + result_inetnum = parse_change_requests( + SAMPLE_INETNUM, + mock_dh, + AuthValidator(mock_dh), + validator, + {}, + )[0] assert not result_inetnum._check_references() assert not result_inetnum.is_valid() assert not result_inetnum.notification_targets() @@ -293,12 +312,22 @@ def test_check_references_valid_preload_references(self, prepare_mocks): validator = ReferenceValidator(mock_dh) preload = parse_change_requests( - SAMPLE_PERSON + "\n" + SAMPLE_MNTNER, mock_dh, AuthValidator(mock_dh), validator + SAMPLE_PERSON + "\n" + SAMPLE_MNTNER, + mock_dh, + AuthValidator(mock_dh), + validator, + {}, ) mock_dq.reset_mock() validator.preload(preload) - result_inetnum = parse_change_requests(SAMPLE_INETNUM, mock_dh, AuthValidator(mock_dh), validator)[0] + result_inetnum = parse_change_requests( + SAMPLE_INETNUM, + mock_dh, + AuthValidator(mock_dh), + validator, + {}, + )[0] assert result_inetnum._check_references() assert result_inetnum.is_valid() assert flatten_mock_calls(mock_dq) == [ @@ -327,7 +356,11 @@ def test_check_references_valid_deleting_object_with_no_inbound_refs(self, prepa mock_dh.execute_query = lambda query: next(query_results) result = parse_change_requests( - SAMPLE_ROUTE + "delete: delete", mock_dh, AuthValidator(mock_dh), validator + SAMPLE_ROUTE + "delete: delete", + mock_dh, + AuthValidator(mock_dh), + validator, + {}, )[0] result._check_references() assert result.is_valid(), result.error_messages @@ -363,7 +396,11 @@ def test_check_references_invalid_deleting_object_with_refs_in_db(self, prepare_ mock_dh.execute_query = lambda query: next(query_results) result = parse_change_requests( - SAMPLE_PERSON + "delete: delete", mock_dh, AuthValidator(mock_dh), validator + SAMPLE_PERSON + "delete: delete", + mock_dh, + AuthValidator(mock_dh), + validator, + {}, )[0] result._check_references() assert not result.is_valid() @@ -406,6 +443,7 @@ def test_check_references_invalid_deleting_object_with_refs_in_update_message(se mock_dh, AuthValidator(mock_dh), validator, + {}, ) validator.preload(results) result_inetnum = results[1] @@ -444,7 +482,11 @@ def test_check_references_valid_deleting_object_referencing_to_be_deleted_object validator = ReferenceValidator(mock_dh) mock_dh.execute_query = lambda query: [] result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "delete: delete", mock_dh, AuthValidator(mock_dh), validator + SAMPLE_INETNUM + "delete: delete", + mock_dh, + AuthValidator(mock_dh), + validator, + {}, ) validator.preload(result_inetnum) mock_dq.reset_mock() @@ -465,7 +507,11 @@ def test_check_references_valid_deleting_object_referencing_to_be_deleted_object mock_dh.execute_query = lambda query: next(query_results) result = parse_change_requests( - SAMPLE_PERSON + "delete: delete" + "\n", mock_dh, AuthValidator(mock_dh), validator + SAMPLE_PERSON + "delete: delete" + "\n", + mock_dh, + AuthValidator(mock_dh), + validator, + {}, )[0] result._check_references() assert result.is_valid(), result.error_messages @@ -494,6 +540,7 @@ def test_check_auth_valid_update_mntner(self, prepare_mocks): mock_dh, auth_validator, reference_validator, + {}, )[0] assert result_inetnum._check_auth() assert not result_inetnum.error_messages @@ -509,7 +556,11 @@ def test_check_auth_valid_update_mntner(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_inetnum._check_auth() assert not result_inetnum.error_messages @@ -520,9 +571,13 @@ def test_check_auth_valid_update_mntner(self, prepare_mocks): } auth_validator = AuthValidator(mock_dh, keycert_obj_pk="PGPKEY-80F238C6") - result_inetnum = parse_change_requests(SAMPLE_INETNUM, mock_dh, auth_validator, reference_validator)[ - 0 - ] + result_inetnum = parse_change_requests( + SAMPLE_INETNUM, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert not result_inetnum.error_messages assert result_inetnum._check_auth() @@ -535,7 +590,11 @@ def test_check_auth_valid_create_mntner_referencing_self(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_mntner = parse_change_requests( - SAMPLE_MNTNER + "override: override-password", mock_dh, auth_validator, reference_validator + SAMPLE_MNTNER + "override: override-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] auth_validator.pre_approve([result_mntner.rpsl_obj_new]) @@ -557,7 +616,11 @@ def test_check_auth_invalid_create_mntner_referencing_self_wrong_override_passwo auth_validator = AuthValidator(mock_dh) result_mntner = parse_change_requests( - SAMPLE_MNTNER + "override: invalid-password", mock_dh, auth_validator, reference_validator + SAMPLE_MNTNER + "override: invalid-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] auth_validator.pre_approve([result_mntner.rpsl_obj_new]) @@ -593,7 +656,11 @@ def test_check_auth_valid_update_mntner_submits_new_object_with_all_dummy_hash_v "$2b$12$RMrlONJ0tasnpo.zHDF.yuYm/Gb1ARmIjP097ZoIWBn9YLIM2ao5W", PASSWORD_HASH_DUMMY_VALUE ) result_mntner = parse_change_requests( - data + "password: crypt-password", mock_dh, auth_validator, reference_validator + data + "password: crypt-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] auth_validator.pre_approve([result_mntner.rpsl_obj_new]) assert result_mntner._check_auth() @@ -635,7 +702,11 @@ def test_check_auth_invalid_update_mntner_submits_new_object_with_mixed_dummy_ha # but a password attribute that is valid for the current DB object. data = SAMPLE_MNTNER.replace("LEuuhsBJNFV0Q", PASSWORD_HASH_DUMMY_VALUE) result_mntner = parse_change_requests( - data + "password: md5-password", mock_dh, auth_validator, reference_validator + data + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] auth_validator.pre_approve([result_mntner.rpsl_obj_new]) assert not result_mntner.is_valid() @@ -665,6 +736,7 @@ def test_check_auth_invalid_update_mntner_submits_new_object_with_dummy_hash_mul mock_dh, auth_validator, reference_validator, + {}, )[0] # This also tests whether API keys are passed to the validator. assert auth_validator.passwords == ["md5-password", "other-password"] @@ -689,7 +761,11 @@ def test_check_auth_invalid_update_mntner_wrong_password_current_db_object(self, # This password is valid for the new object, but invalid for the current version in the DB result_mntner = parse_change_requests( - SAMPLE_MNTNER + "password: crypt-password", mock_dh, auth_validator, reference_validator + SAMPLE_MNTNER + "password: crypt-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_mntner._check_auth() assert result_mntner.error_messages == [ @@ -720,7 +796,11 @@ def test_check_auth_invalid_create_with_incorrect_password_referenced_mntner(sel auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "password: wrong-pw", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "password: wrong-pw", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_inetnum._check_auth() assert "Authorisation for inetnum 192.0.2.0 - 192.0.2.255 failed" in result_inetnum.error_messages[0] @@ -753,7 +833,11 @@ def test_check_auth_invalid_update_with_nonexistent_referenced_mntner(self, prep auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_inetnum._check_auth(), result_inetnum assert "Authorisation for inetnum 192.0.2.0 - 192.0.2.255 failed" in result_inetnum.error_messages[0] @@ -784,7 +868,11 @@ def test_check_auth_valid_update_mntner_using_override(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "override: override-password", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "override: override-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_inetnum._check_auth() assert not result_inetnum.error_messages @@ -802,7 +890,11 @@ def test_check_auth_invalid_update_mntner_using_incorrect_override(self, prepare auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "override: wrong-override", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "override: wrong-override", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_inetnum._check_auth() assert result_inetnum.error_messages == [ @@ -828,7 +920,11 @@ def test_check_auth_invalid_update_mntner_override_hash_misconfigured( auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "override: override-password", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "override: override-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_inetnum._check_auth() assert result_inetnum.error_messages == [ @@ -853,7 +949,11 @@ def test_check_auth_invalid_update_mntner_override_hash_empty(self, prepare_mock auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "override: override-password", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "override: override-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_inetnum._check_auth() assert result_inetnum.error_messages == [ @@ -881,7 +981,11 @@ def test_check_valid_related_mntners_disabled(self, prepare_mocks, config_overri auth_validator = AuthValidator(mock_dh) result_route = parse_change_requests( - SAMPLE_ROUTE + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_ROUTE + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_route._check_auth() assert not result_route.error_messages @@ -922,7 +1026,11 @@ def test_check_invalid_related_mntners_inetnum_exact(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_route = parse_change_requests( - SAMPLE_ROUTE + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_ROUTE + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert not result_route._check_auth() assert ( @@ -973,7 +1081,11 @@ def test_check_valid_related_mntners_inet6num_exact(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_route = parse_change_requests( - SAMPLE_ROUTE6 + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_ROUTE6 + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_route._check_auth() assert result_route._check_auth() # should be cached, no extra db queries @@ -1020,7 +1132,11 @@ def test_check_valid_related_mntners_inetnum_less_specific(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_route = parse_change_requests( - SAMPLE_ROUTE + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_ROUTE + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_route._check_auth() assert not result_route.error_messages @@ -1071,7 +1187,11 @@ def test_check_valid_related_mntners_route(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_route = parse_change_requests( - SAMPLE_ROUTE + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_ROUTE + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_route._check_auth() assert not result_route.error_messages @@ -1118,7 +1238,11 @@ def test_check_valid_no_related_mntners(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_route = parse_change_requests( - SAMPLE_ROUTE + "password: md5-password", mock_dh, auth_validator, reference_validator + SAMPLE_ROUTE + "password: md5-password", + mock_dh, + auth_validator, + reference_validator, + {}, )[0] assert result_route._check_auth() assert not result_route.error_messages @@ -1157,7 +1281,13 @@ def test_rpki_validation(self, prepare_mocks, monkeypatch, config_override): # New object, RPKI invalid, RPKI-aware mode disabled mock_dh.execute_query = lambda query: [] mock_roa_validator.validate_route = lambda prefix, asn, source: RPKIStatus.invalid - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_route._check_conflicting_roa() assert not result_route.error_messages @@ -1166,23 +1296,39 @@ def test_rpki_validation(self, prepare_mocks, monkeypatch, config_override): # New object, RPKI-aware mode enabled but object not RPKI relevant mock_dh.execute_query = lambda query: [] mock_roa_validator.validate_route = lambda prefix, asn, source: RPKIStatus.invalid - result_inetnum = parse_change_requests(SAMPLE_INETNUM, mock_dh, auth_validator, reference_validator)[ - 0 - ] + result_inetnum = parse_change_requests( + SAMPLE_INETNUM, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_inetnum._check_conflicting_roa() assert not result_inetnum.error_messages # New object, RPKI not_found mock_dh.execute_query = lambda query: [] mock_roa_validator.validate_route = lambda prefix, asn, source: RPKIStatus.not_found - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_route._check_conflicting_roa() assert not result_route.error_messages # New object, RPKI invalid mock_dh.execute_query = lambda query: [] mock_roa_validator.validate_route = lambda prefix, asn, source: RPKIStatus.invalid - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert not result_route._check_conflicting_roa() assert result_route.error_messages[0].startswith( "RPKI ROAs were found that conflict with this object." @@ -1191,7 +1337,13 @@ def test_rpki_validation(self, prepare_mocks, monkeypatch, config_override): # Update object, RPKI invalid mock_dh.execute_query = lambda query: [{"object_text": SAMPLE_ROUTE}] mock_roa_validator.validate_route = lambda prefix, asn, source: RPKIStatus.invalid - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert not result_route._check_conflicting_roa() assert not result_route._check_conflicting_roa() # Should use cache assert result_route.error_messages[0].startswith( @@ -1202,7 +1354,13 @@ def test_rpki_validation(self, prepare_mocks, monkeypatch, config_override): mock_dh.execute_query = lambda query: [{"object_text": SAMPLE_ROUTE}] mock_roa_validator.validate_route = lambda prefix, asn, source: RPKIStatus.invalid obj_text = SAMPLE_ROUTE + "delete: delete" - result_route = parse_change_requests(obj_text, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + obj_text, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_route._check_conflicting_roa() def test_scopefilter_validation(self, prepare_mocks, monkeypatch, config_override): @@ -1216,7 +1374,13 @@ def test_scopefilter_validation(self, prepare_mocks, monkeypatch, config_overrid # New object, in scope mock_dh.execute_query = lambda query: [] mock_scopefilter_validator.validate_rpsl_object = lambda obj: (ScopeFilterStatus.in_scope, "") - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_route._check_scopefilter() assert not result_route.error_messages @@ -1226,7 +1390,13 @@ def test_scopefilter_validation(self, prepare_mocks, monkeypatch, config_overrid ScopeFilterStatus.out_scope_as, "out of scope AS", ) - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert not result_route._check_scopefilter() assert result_route.error_messages[0] == "Contains out of scope information: out of scope AS" @@ -1236,7 +1406,13 @@ def test_scopefilter_validation(self, prepare_mocks, monkeypatch, config_overrid ScopeFilterStatus.out_scope_prefix, "out of scope prefix", ) - result_route = parse_change_requests(SAMPLE_ROUTE, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + SAMPLE_ROUTE, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_route._check_scopefilter() assert not result_route.error_messages assert result_route.info_messages[1] == "Contains out of scope information: out of scope prefix" @@ -1248,7 +1424,13 @@ def test_scopefilter_validation(self, prepare_mocks, monkeypatch, config_overrid "out of scope AS", ) obj_text = SAMPLE_ROUTE + "delete: delete" - result_route = parse_change_requests(obj_text, mock_dh, auth_validator, reference_validator)[0] + result_route = parse_change_requests( + obj_text, + mock_dh, + auth_validator, + reference_validator, + {}, + )[0] assert result_route._check_scopefilter() def test_user_report(self, prepare_mocks): @@ -1263,7 +1445,7 @@ def test_user_report(self, prepare_mocks): mock_dh.execute_query = lambda query: next(query_results) result_inetnum, result_as_set, result_unknown, result_invalid = parse_change_requests( - self._request_text(), mock_dh, AuthValidator(mock_dh), None + self._request_text(), mock_dh, AuthValidator(mock_dh), None, {} ) report_inetnum = result_inetnum.submitter_report_human() report_as_set = result_as_set.submitter_report_human() @@ -1330,9 +1512,9 @@ def test_user_report(self, prepare_mocks): """).strip() + "\n" inetnum_modify = SAMPLE_INETNUM.replace("PERSON-TEST", "NEW-TEST") - result_inetnum_modify = parse_change_requests(inetnum_modify, mock_dh, AuthValidator(mock_dh), None)[ - 0 - ] + result_inetnum_modify = parse_change_requests( + inetnum_modify, mock_dh, AuthValidator(mock_dh), None, {} + )[0] assert result_inetnum_modify.notification_target_report() == textwrap.dedent(""" Modify succeeded for object below: [inetnum] 192.0.2.0 - 192.0.2.255: @@ -1430,7 +1612,7 @@ def prepare_suspension_request_test(self, prepare_mocks, monkeypatch, config_ove monkeypatch.setattr("irrd.updates.parser.suspend_for_mntner", mock_suspend_for_mntner) mock_reactivate_for_mntner = Mock(suspend_for_mntner) monkeypatch.setattr("irrd.updates.parser.reactivate_for_mntner", mock_reactivate_for_mntner) - mock_auth_validator.check_override.return_value = True + mock_auth_validator.check_override.return_value = AuthMethod.OVERRIDE_PASSWORD default_request = textwrap.dedent(""" override: override-pw @@ -1453,7 +1635,7 @@ def test_valid_suspension(self, prepare_suspension_request_test): prepare_suspension_request_test ) - (r, *_) = parse_change_requests(default_request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(default_request, mock_dh, mock_auth_validator, None, {}) assert r.request_type == SuspensionRequestType.SUSPEND assert r.status == UpdateRequestStatus.PROCESSING, r.error_messages @@ -1496,7 +1678,7 @@ def test_valid_reactivation(self, prepare_suspension_request_test): ) request = default_request.replace("suspend", "reactivate") - (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None, {}) assert r.request_type == SuspensionRequestType.REACTIVATE assert r.status == UpdateRequestStatus.PROCESSING, r.error_messages @@ -1522,7 +1704,7 @@ def test_failed_reactivation(self, prepare_suspension_request_test): ) request = default_request.replace("suspend", "reactivate") - (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None, {}) mock_reactivate_for_mntner.side_effect = ValueError("failure") r.save() @@ -1537,7 +1719,7 @@ def test_not_authoritative(self, prepare_suspension_request_test, config_overrid ) config_override({"sources": {"TEST": {"suspension_enabled": False}}}) - (r, *_) = parse_change_requests(default_request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(default_request, mock_dh, mock_auth_validator, None, {}) assert r.request_type == SuspensionRequestType.SUSPEND assert r.status == UpdateRequestStatus.ERROR_NON_AUTHORITIVE @@ -1555,7 +1737,7 @@ def test_unknown_suspension(self, prepare_suspension_request_test): ) request = default_request.replace("suspend", "invalid") - (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None, {}) assert not r.request_type assert r.status == UpdateRequestStatus.ERROR_PARSING @@ -1570,7 +1752,7 @@ def test_invalid_rpsl_object(self, prepare_suspension_request_test): ) request = "suspension: suspend\nmntner: TEST" - (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None, {}) assert r.status == UpdateRequestStatus.ERROR_PARSING assert r.error_messages == [ @@ -1584,7 +1766,7 @@ def test_invalid_rpsl_object_class(self, prepare_suspension_request_test): ) request = "suspension: suspend\nsource: TEST" - (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None, {}) assert r.status == UpdateRequestStatus.ERROR_UNKNOWN_CLASS assert r.error_messages == [ @@ -1598,7 +1780,7 @@ def test_incorrect_object_class(self, prepare_suspension_request_test): ) request = "override: override-pw\n\nsuspension: suspend\n" + SAMPLE_INETNUM - (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(request, mock_dh, mock_auth_validator, None, {}) assert r.status == UpdateRequestStatus.ERROR_PARSING assert r.error_messages == [ @@ -1610,9 +1792,9 @@ def test_invalid_override_password(self, prepare_suspension_request_test): mock_dh, mock_auth_validator, mock_suspend_for_mntner, mock_reactivate_for_mntner, default_request = ( prepare_suspension_request_test ) - mock_auth_validator.check_override.return_value = False + mock_auth_validator.check_override.return_value = None - (r, *_) = parse_change_requests(default_request, mock_dh, mock_auth_validator, None) + (r, *_) = parse_change_requests(default_request, mock_dh, mock_auth_validator, None, {}) assert not r.is_valid() assert r.status == UpdateRequestStatus.ERROR_AUTH diff --git a/irrd/updates/tests/test_validators.py b/irrd/updates/tests/test_validators.py index bc5535501..b298c858c 100644 --- a/irrd/updates/tests/test_validators.py +++ b/irrd/updates/tests/test_validators.py @@ -1,4 +1,5 @@ import itertools +import uuid from unittest import mock from unittest.mock import Mock @@ -9,7 +10,7 @@ from irrd.rpsl.rpsl_objects import rpsl_object_from_text from irrd.storage.database_handler import DatabaseHandler from irrd.storage.queries import RPSLDatabaseSuspendedQuery -from irrd.updates.parser_state import UpdateRequestType +from irrd.updates.parser_state import AuthMethod, UpdateRequestType from irrd.utils.rpsl_samples import ( SAMPLE_AS_SET, SAMPLE_FILTER_SET, @@ -30,8 +31,8 @@ AuthoritativeChangeOrigin, AuthUser, ) -from ...utils.factories import AuthApiTokenFactory -from ..validators import AuthValidator, RulesValidator +from ...utils.factories import AuthApiTokenFactory, AuthMntnerFactory, AuthUserFactory +from ..validators import AuthValidator, RulesValidator, ValidatorResult VALID_PW = "override-password" INVALID_PW = "not-override-password" @@ -40,6 +41,30 @@ MNTNER_OBJ_MD5_PW = SAMPLE_MNTNER.replace("CRYPT", "") +def test_validator_result(): + user = AuthUserFactory.build(pk=uuid.uuid4()) + api_key = AuthApiTokenFactory.build(pk=uuid.uuid4()) + mntner = AuthMntnerFactory.build(pk=uuid.uuid4(), rpsl_mntner_obj_id=None) + + result = ValidatorResult( + auth_through_internal_user=user, + auth_through_api_key=api_key, + auth_through_internal_mntner=mntner, + auth_through_mntner="TEST-MNT", + ).to_change_log() + assert result.auth_by_user_id == str(user.pk) + assert result.auth_by_user_email == user.email + assert result.auth_by_api_key_id == str(api_key.pk) + assert result.auth_by_api_key_id_fixed == str(api_key.pk) + assert result.auth_through_rpsl_mntner_pk == "TEST-MNT" + + assert ( + ValidatorResult(auth_method=AuthMethod.MNTNER_PASSWORD).to_change_log().auth_by_rpsl_mntner_password + ) + assert ValidatorResult(auth_method=AuthMethod.MNTNER_PGP_KEY).to_change_log().auth_by_rpsl_mntner_pgp_key + assert ValidatorResult(auth_method=AuthMethod.OVERRIDE_PASSWORD).to_change_log().auth_by_override + + class TestAuthValidator: @pytest.fixture() def prepare_mocks(self, monkeypatch, config_override): @@ -68,12 +93,14 @@ def test_override_valid(self, prepare_mocks, config_override): validator.overrides = [VALID_PW] result = validator.process_auth(person, None) assert result.is_valid(), result.error_messages - assert result.used_override + assert result.auth_method == AuthMethod.OVERRIDE_PASSWORD + assert result.auth_method + assert result.auth_method.used_override() person = rpsl_object_from_text(SAMPLE_PERSON) result = validator.process_auth(person, rpsl_obj_current=person) assert result.is_valid(), result.error_messages - assert result.used_override + assert result.auth_method == AuthMethod.OVERRIDE_PASSWORD def test_override_internal_auth(self, prepare_mocks, config_override): validator, mock_dq, mock_dh = prepare_mocks @@ -85,12 +112,13 @@ def test_override_internal_auth(self, prepare_mocks, config_override): result = validator.process_auth(person, None) assert result.is_valid(), result.error_messages - assert result.used_override + assert result.auth_method == AuthMethod.OVERRIDE_INTERNAL_AUTH user.override = False result = validator.process_auth(person, None) assert not result.is_valid() - assert not result.used_override + assert result.auth_method == AuthMethod.NONE + assert not result.auth_method.used_override() def test_override_invalid_or_missing(self, prepare_mocks, config_override): # This test mostly ignores the regular process that happens @@ -102,7 +130,7 @@ def test_override_invalid_or_missing(self, prepare_mocks, config_override): validator.overrides = [VALID_PW] result = validator.process_auth(person, None) assert not result.is_valid() - assert not result.used_override + assert result.auth_method == AuthMethod.NONE config_override( { @@ -112,12 +140,12 @@ def test_override_invalid_or_missing(self, prepare_mocks, config_override): validator.overrides = [] result = validator.process_auth(person, None) assert not result.is_valid() - assert not result.used_override + assert result.auth_method == AuthMethod.NONE validator.overrides = [INVALID_PW] result = validator.process_auth(person, None) assert not result.is_valid() - assert not result.used_override + assert result.auth_method == AuthMethod.NONE config_override( { @@ -127,7 +155,7 @@ def test_override_invalid_or_missing(self, prepare_mocks, config_override): person = rpsl_object_from_text(SAMPLE_PERSON) result = validator.process_auth(person, None) assert not result.is_valid() - assert not result.used_override + assert result.auth_method == AuthMethod.NONE def test_valid_new_person(self, prepare_mocks): validator, mock_dq, mock_dh = prepare_mocks @@ -139,7 +167,7 @@ def test_valid_new_person(self, prepare_mocks): validator.passwords = [SAMPLE_MNTNER_MD5] result = validator.process_auth(person, None) assert result.is_valid(), result.error_messages - assert not result.used_override + assert result.auth_method == AuthMethod.MNTNER_PASSWORD assert len(result.mntners_notify) == 1 assert result.mntners_notify[0].pk() == "TEST-MNT" @@ -152,6 +180,7 @@ def test_valid_new_person(self, prepare_mocks): def test_valid_new_person_api_key(self, prepare_mocks, monkeypatch): validator, mock_dq, mock_dh = prepare_mocks person = rpsl_object_from_text(SAMPLE_PERSON) + mock_mntner = AuthMntner(rpsl_mntner_pk="TEST-MNT") mock_sa_session = UnifiedAlchemyMagicMock( data=[ ( @@ -161,11 +190,11 @@ def test_valid_new_person_api_key(self, prepare_mocks, monkeypatch): AuthMntner.rpsl_mntner_pk == "TEST-MNT", AuthMntner.rpsl_mntner_source == "TEST" ), ], - [AuthMntner(rpsl_mntner_pk="TEST-MNT")], + [mock_mntner], ) ] ) - mock_api_key = AuthApiTokenFactory.build() + mock_api_key = AuthApiTokenFactory.build(mntner=mock_mntner) monkeypatch.setattr("irrd.updates.validators.saorm.Session", lambda bind: mock_sa_session) mock_dh._connection = None @@ -177,8 +206,10 @@ def test_valid_new_person_api_key(self, prepare_mocks, monkeypatch): validator.api_keys = ["key"] result = validator.process_auth(person, None) assert result.is_valid(), result.error_messages - assert not result.used_override assert result.mntners_notify[0].pk() == "TEST-MNT" + assert result.auth_method == AuthMethod.MNTNER_API_KEY + assert result.auth_through_mntner == "TEST-MNT" + assert result.auth_through_internal_mntner.rpsl_mntner_pk == "TEST-MNT" mock_sa_session.filter.assert_has_calls( [ @@ -242,7 +273,7 @@ def test_existing_person_mntner_change(self, prepare_mocks): result = validator.process_auth(person_new, rpsl_obj_current=person_old) assert result.is_valid(), result.error_messages - assert not result.used_override + assert result.auth_method == AuthMethod.MNTNER_PASSWORD assert {m.pk() for m in result.mntners_notify} == {"TEST-MNT", "TEST-OLD-MNT"} assert flatten_mock_calls(mock_dq, flatten_objects=True) == [ @@ -280,7 +311,7 @@ def test_valid_new_person_preapproved_mntner(self, prepare_mocks): result = validator.process_auth(person, None) assert result.is_valid(), result.error_messages - assert not result.used_override + assert result.auth_method == AuthMethod.MNTNER_IN_SAME_REQUEST assert len(result.mntners_notify) == 1 assert result.mntners_notify[0].pk() == "TEST-MNT" @@ -294,7 +325,8 @@ def test_create_mntner_requires_override(self, prepare_mocks, config_override): validator.passwords = [SAMPLE_MNTNER_MD5] result = validator.process_auth(mntner, None) assert not result.is_valid() - assert not result.used_override + assert result.auth_method == AuthMethod.NONE + assert not result.auth_method assert result.error_messages == {"New mntner objects must be added by an administrator."} assert flatten_mock_calls(mock_dq, flatten_objects=True) == [ @@ -312,7 +344,7 @@ def test_create_mntner_requires_override(self, prepare_mocks, config_override): result = validator.process_auth(mntner, None) assert result.is_valid(), result.error_messages - assert result.used_override + assert result.auth_method == AuthMethod.OVERRIDE_PASSWORD def test_modify_mntner(self, prepare_mocks, config_override): validator, mock_dq, mock_dh = prepare_mocks @@ -370,6 +402,9 @@ def test_modify_mntner_internal_auth(self, prepare_mocks, config_override): validator = AuthValidator(mock_dh, keycert_obj_pk=None, internal_authenticated_user=user) result = validator.process_auth(mntner, mntner) assert result.is_valid() + assert result.auth_method == AuthMethod.MNTNER_INTERNAL_AUTH + assert result.auth_through_mntner == "TEST-MNT" + assert result.auth_through_internal_mntner == mock_mntners[0] # Modifying mntner should fail without user_management user = AuthUser(mntners=mock_mntners) diff --git a/irrd/updates/validators.py b/irrd/updates/validators.py index 93527322a..3b7a2856a 100644 --- a/irrd/updates/validators.py +++ b/irrd/updates/validators.py @@ -1,7 +1,7 @@ import functools import logging from dataclasses import dataclass, field -from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union import sqlalchemy.orm as saorm from IPy import IP @@ -17,10 +17,11 @@ AuthMntner, AuthoritativeChangeOrigin, AuthUser, + ChangeLog, ) from irrd.storage.queries import RPSLDatabaseQuery, RPSLDatabaseSuspendedQuery -from .parser_state import RPSLSetAutnumAuthenticationMode, UpdateRequestType +from .parser_state import AuthMethod, RPSLSetAutnumAuthenticationMode, UpdateRequestType if TYPE_CHECKING: # pragma: no cover # http://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles @@ -36,12 +37,46 @@ class ValidatorResult: info_messages: Set[str] = field(default_factory=OrderedSet) # type: ignore # mntners that may need to be notified mntners_notify: List[RPSLMntner] = field(default_factory=list) - # whether the authentication succeeded due to use of an override password - used_override: bool = field(default=False) + # Details of how authentication was provided + auth_method: AuthMethod = AuthMethod.NONE + auth_through_mntner: Optional[str] = None + auth_through_internal_mntner: Optional[AuthMntner] = None + auth_through_api_key: Optional[AuthApiToken] = None + auth_through_internal_user: Optional[AuthUser] = None def is_valid(self): return len(self.error_messages) == 0 + def to_change_log(self) -> ChangeLog: + kwargs: Dict[str, Union[str, bool, None]] = { + "auth_through_rpsl_mntner_pk": self.auth_through_mntner, + "auth_by_rpsl_mntner_password": self.auth_method == AuthMethod.MNTNER_PASSWORD, + "auth_by_rpsl_mntner_pgp_key": self.auth_method == AuthMethod.MNTNER_PGP_KEY, + "auth_by_override": self.auth_method in [ + AuthMethod.OVERRIDE_PASSWORD, + AuthMethod.OVERRIDE_INTERNAL_AUTH, + ], + } + if self.auth_through_internal_user: + kwargs["auth_by_user_id"] = str(self.auth_through_internal_user.pk) + kwargs["auth_by_user_email"] = self.auth_through_internal_user.email + if self.auth_through_api_key: + kwargs["auth_by_api_key_id"] = str(self.auth_through_api_key.pk) + kwargs["auth_by_api_key_id_fixed"] = str(self.auth_through_api_key.pk) + if self.auth_through_internal_mntner: + kwargs["auth_through_mntner_id"] = str(self.auth_through_internal_mntner.pk) + return ChangeLog(**kwargs) + + +@dataclass +class MntnerCheckResult: + valid: bool + associated_mntners: List[RPSLMntner] = field(default_factory=list) + auth_method: AuthMethod = AuthMethod.NONE + mntner_pk: Optional[str] = None + auth_mntner: Optional[AuthMntner] = None + api_key: Optional[AuthApiToken] = None + class ReferenceValidator: """ @@ -215,36 +250,41 @@ def process_auth( to be notified. If a valid override password is provided, changes are immediately approved. - On the result object, used_override is set to True, but mntners_notify is + On the result object, method is set to override, but associated_mntners is not filled, as mntner resolving does not take place. """ source = rpsl_obj_new.source() result = ValidatorResult() - if self.check_override(): - result.used_override = True + override_method = self.check_override() + if override_method: + result.auth_method = override_method + if override_method == AuthMethod.OVERRIDE_INTERNAL_AUTH: + result.auth_through_internal_user = self._internal_authenticated_user logger.info("Found valid override password.") return result mntners_new = rpsl_obj_new.parsed_data["mnt-by"] logger.debug(f"Checking auth for new object {rpsl_obj_new}, mntners in new object: {mntners_new}") - valid, mntner_objs_new = self._check_mntners(rpsl_obj_new, mntners_new, source) - if not valid: + new_mntners_result = self._check_mntners(rpsl_obj_new, mntners_new, source) + if not new_mntners_result.valid: self._generate_failure_message(result, mntners_new, rpsl_obj_new) + current_mntners_result = None + related_mntners_result = None if rpsl_obj_current: mntners_current = rpsl_obj_current.parsed_data["mnt-by"] logger.debug( f"Checking auth for current object {rpsl_obj_current}, " f"mntners in current object: {mntners_current}" ) - valid, mntner_objs_current = self._check_mntners(rpsl_obj_new, mntners_current, source) - if not valid: + current_mntners_result = self._check_mntners(rpsl_obj_new, mntners_current, source) + if not current_mntners_result.valid: self._generate_failure_message(result, mntners_current, rpsl_obj_new) - result.mntners_notify = mntner_objs_current + result.mntners_notify = current_mntners_result.associated_mntners else: - result.mntners_notify = mntner_objs_new + result.mntners_notify = new_mntners_result.associated_mntners mntners_related = self._find_related_mntners(rpsl_obj_new, result) if mntners_related: related_object_class, related_pk, related_mntner_list = mntners_related @@ -252,12 +292,12 @@ def process_auth( f"Checking auth for related object {related_object_class} / " f"{related_pk} with mntners {related_mntner_list}" ) - valid, mntner_objs_related = self._check_mntners(rpsl_obj_new, related_mntner_list, source) - if not valid: + related_mntners_result = self._check_mntners(rpsl_obj_new, related_mntner_list, source) + if not related_mntners_result.valid: self._generate_failure_message( result, related_mntner_list, rpsl_obj_new, related_object_class, related_pk ) - result.mntners_notify = mntner_objs_related + result.mntners_notify = related_mntners_result.associated_mntners if isinstance(rpsl_obj_new, RPSLMntner): if not rpsl_obj_current: @@ -290,22 +330,31 @@ def process_auth( ): result.error_messages.add("Authorisation failed for the auth methods on this mntner object.") + mntner_result_for_change_log = current_mntners_result or related_mntners_result or new_mntners_result + if mntner_result_for_change_log: + result.auth_method = mntner_result_for_change_log.auth_method + result.auth_through_mntner = mntner_result_for_change_log.mntner_pk + result.auth_through_api_key = mntner_result_for_change_log.api_key + result.auth_through_internal_mntner = mntner_result_for_change_log.auth_mntner + if result.auth_method == AuthMethod.MNTNER_INTERNAL_AUTH: + result.auth_through_internal_user = self._internal_authenticated_user + return result - def check_override(self) -> bool: + def check_override(self) -> Optional[AuthMethod]: if self._internal_authenticated_user and self._internal_authenticated_user.override: logger.info( "Authenticated by valid override from internally authenticated " f"user {self._internal_authenticated_user}" ) - return True + return AuthMethod.OVERRIDE_INTERNAL_AUTH override_hash = get_setting("auth.override_password") if override_hash: for override in self.overrides: try: if md5_crypt.verify(override, override_hash): - return True + return AuthMethod.OVERRIDE_PASSWORD else: logger.info("Found invalid override password, ignoring.") except ValueError as ve: @@ -315,15 +364,15 @@ def check_override(self) -> bool: ) elif self.overrides: logger.info("Ignoring override password, auth.override_password not set.") - return False + return AuthMethod.NONE def _check_mntners( self, rpsl_obj_new: RPSLObject, mntner_pk_list: List[str], source: str - ) -> Tuple[bool, List[RPSLMntner]]: + ) -> MntnerCheckResult: """ Check whether authentication passes for a list of maintainers. - Returns True if at least one of the mntners in mntner_list + Checks if at least one of the mntners in mntner_list passes authentication, given self.passwords and self.keycert_obj_pk. Updates and checks self._mntner_db_cache to prevent double retrieval of maintainers. @@ -344,39 +393,70 @@ def _check_mntners( mntner_objs += retrieved_mntner_objs for mntner_name in mntner_pk_list: - matches_internal_auth = self._mntner_matches_internal_auth(rpsl_obj_new, mntner_name, source) - matches_api_key = self._mntner_matches_api_key(rpsl_obj_new, mntner_name, source) - if mntner_name in self._pre_approved or matches_internal_auth or matches_api_key: - return True, mntner_objs + if mntner_name in self._pre_approved: + return MntnerCheckResult( + valid=True, + associated_mntners=mntner_objs, + auth_method=AuthMethod.MNTNER_IN_SAME_REQUEST, + mntner_pk=mntner_name, + ) + internal_auth_match = self._mntner_matches_internal_auth(rpsl_obj_new, mntner_name, source) + if internal_auth_match: + return MntnerCheckResult( + valid=True, + associated_mntners=mntner_objs, + auth_method=AuthMethod.MNTNER_INTERNAL_AUTH, + auth_mntner=internal_auth_match, + mntner_pk=mntner_name, + ) + api_key = self._api_key_match_for_mntner(rpsl_obj_new, mntner_name, source) + if api_key: + return MntnerCheckResult( + valid=True, + associated_mntners=mntner_objs, + auth_method=AuthMethod.MNTNER_API_KEY, + auth_mntner=api_key.mntner, + mntner_pk=mntner_name, + api_key=api_key.pk, + ) for mntner_obj in mntner_objs: - if mntner_obj.verify_auth(self.passwords, self.keycert_obj_pk): - return True, mntner_objs + valid_scheme = mntner_obj.verify_auth(self.passwords, self.keycert_obj_pk) + if valid_scheme: + auth_method = ( + AuthMethod.MNTNER_PGP_KEY if "PGPKEY" in valid_scheme else AuthMethod.MNTNER_PASSWORD + ) + return MntnerCheckResult( + valid=True, + associated_mntners=mntner_objs, + auth_method=auth_method, + mntner_pk=mntner_obj.pk(), + ) - return False, mntner_objs + return MntnerCheckResult(valid=False, associated_mntners=mntner_objs) - def _mntner_matches_internal_auth(self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source: str) -> bool: + def _mntner_matches_internal_auth( + self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source: str + ) -> Optional[AuthMntner]: if not self._internal_authenticated_user: - return False + return None if rpsl_obj_new.pk() == rpsl_pk and rpsl_obj_new.source() == source: user_mntner_set = self._internal_authenticated_user.mntners_user_management else: user_mntner_set = self._internal_authenticated_user.mntners - match = any( - [ - rpsl_pk == mntner.rpsl_mntner_pk and source == mntner.rpsl_mntner_source - for mntner in user_mntner_set - ] - ) - if match: - logger.info( - f"Authenticated through internally authenticated user {self._internal_authenticated_user}" - ) - return match + for mntner in user_mntner_set: + if rpsl_pk == mntner.rpsl_mntner_pk and source == mntner.rpsl_mntner_source: + logger.info( + f"Authenticated through internally authenticated user {self._internal_authenticated_user}" + ) + return mntner + return None - def _mntner_matches_api_key(self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source: str) -> bool: + def _api_key_match_for_mntner( + self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source: str + ) -> Optional[AuthApiToken]: if not self.api_keys or isinstance(rpsl_obj_new, RPSLMntner): - return False + return None session = saorm.Session(bind=self.database_handler._connection) query = ( @@ -391,9 +471,9 @@ def _mntner_matches_api_key(self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source for api_token in query.all(): if api_token.valid_for(self.origin, self.remote_ip): logger.info(f"Authenticated through API token {api_token.pk} on mntner {rpsl_pk}") - return True + return api_token - return False + return None def _generate_failure_message( self, diff --git a/irrd/utils/factories.py b/irrd/utils/factories.py index fe6677ff1..887e8ef81 100644 --- a/irrd/utils/factories.py +++ b/irrd/utils/factories.py @@ -7,6 +7,7 @@ AuthPermission, AuthUser, AuthWebAuthn, + ChangeLog, RPSLDatabaseObject, ) from irrd.webui.auth.users import password_handler @@ -65,7 +66,8 @@ def rpsl_mntner_obj_id(self): rpsl_mntner = ( AuthMntnerFactory._meta.sqlalchemy_session.query(RPSLDatabaseObject) .filter(RPSLDatabaseObject.object_class == "mntner") - .one() + .order_by(RPSLDatabaseObject.created.desc()) + .first() ) return str(rpsl_mntner.pk) @@ -84,3 +86,9 @@ class Meta: name = factory.Sequence(lambda n: "API token %s" % n) enabled_webapi = True enabled_email = True + + +class ChangeLogFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = ChangeLog + sqlalchemy_session_persistence = "commit" diff --git a/irrd/webui/auth/endpoints.py b/irrd/webui/auth/endpoints.py index 107fb7822..3798ee55a 100644 --- a/irrd/webui/auth/endpoints.py +++ b/irrd/webui/auth/endpoints.py @@ -19,7 +19,7 @@ validate_password_strength, ) from irrd.webui.helpers import ( - client_ip, + client_ip_str, message, rate_limit_post, send_authentication_change_mail, @@ -62,13 +62,13 @@ async def login(request: Request): user_token = await get_login_manager().login(request, email, password) if user_token: - logger.info(f"{client_ip(request)}{email}: successfully logged in") + logger.info(f"{client_ip_str(request)}{email}: successfully logged in") if not user_token.user.has_mfa: default_next = "ui:index" request.session[MFA_COMPLETE_SESSION_KEY] = True return RedirectResponse(clean_next_url(request, default_next), status_code=302) else: - logger.info(f"{client_ip(request)}user failed login due to invalid account or password") + logger.info(f"{client_ip_str(request)}user failed login due to invalid account or password") return template_context_render( "login.html", request, @@ -130,7 +130,7 @@ async def create_account(request: Request, session_provider: ORMSessionProvider) token = PasswordResetToken(new_user).generate_token() send_template_email(form.email.data, "create_account", request, {"user_pk": new_user.pk, "token": token}) message(request, f"You have been sent an email to confirm your account on {form.email.data}.") - logger.info(f"{client_ip(request)}{form.email.data}: created new account, confirmation pending") + logger.info(f"{client_ip_str(request)}{form.email.data}: created new account, confirmation pending") return RedirectResponse(request.url_for("ui:index"), status_code=302) @@ -164,7 +164,7 @@ async def reset_password_request(request: Request, session_provider: ORMSessionP request, f"You have been sent an email to reset your password on {form.email.data}, if this account exists.", ) - logger.info(f"{client_ip(request)}{form.email.data}: password reset email requested") + logger.info(f"{client_ip_str(request)}{form.email.data}: password reset email requested") return RedirectResponse(request.url_for("ui:index"), status_code=302) @@ -209,7 +209,7 @@ async def change_password(request: Request, session_provider: ORMSessionProvider request.auth.user.password = password_handler.hash(form.new_password.data) session_provider.session.add(request.auth.user) message(request, "Your password has been changed.") - logger.info(f"{client_ip(request)}{request.auth.user.email}: password changed successfully") + logger.info(f"{client_ip_str(request)}{request.auth.user.email}: password changed successfully") send_authentication_change_mail(request.auth.user, request, "Your password was changed.") return RedirectResponse(request.url_for("ui:index"), status_code=302) @@ -245,7 +245,7 @@ async def change_profile(request: Request, session_provider: ORMSessionProvider) session_provider.session.add(request.auth.user) message(request, "Your name/e-mail address have been changed.") logger.info( - f"{client_ip(request)}{request.auth.user.email}: name/email changed successfully (old email" + f"{client_ip_str(request)}{request.auth.user.email}: name/email changed successfully (old email" f" {old_email}" ) send_authentication_change_mail( @@ -308,7 +308,7 @@ async def set_password(request: Request, session_provider: ORMSessionProvider) - user.password = password_handler.hash(form.new_password.data) session_provider.session.add(user) message(request, "Your password has been changed.") - logger.info(f"{client_ip(request)}{user.email}: password (re)set successfully") + logger.info(f"{client_ip_str(request)}{user.email}: password (re)set successfully") if not initial: send_authentication_change_mail(user, request, "Your password was reset.") return RedirectResponse(request.url_for("ui:auth:login"), status_code=302) diff --git a/irrd/webui/auth/endpoints_mfa.py b/irrd/webui/auth/endpoints_mfa.py index 0f64d3ca4..0924ee75f 100644 --- a/irrd/webui/auth/endpoints_mfa.py +++ b/irrd/webui/auth/endpoints_mfa.py @@ -30,7 +30,7 @@ from irrd.webui.auth.endpoints import clean_next_url from irrd.webui.auth.users import CurrentPasswordForm from irrd.webui.helpers import ( - client_ip, + client_ip_str, message, rate_limit_post, send_authentication_change_mail, @@ -139,7 +139,7 @@ async def mfa_authenticate(request: Request, session_provider: ORMSessionProvide totp = pyotp.totp.TOTP(request.auth.user.totp_secret) form = await TOTPAuthenticateForm.from_formdata(request=request) if form.is_submitted(): - logger.info(f"{client_ip(request)}{request.auth.user.email}: attempting to log in with TOTP") + logger.info(f"{client_ip_str(request)}{request.auth.user.email}: attempting to log in with TOTP") if await form.validate(totp=totp, last_used=request.auth.user.totp_last_used): try: del request.session[WN_CHALLENGE_SESSION_KEY] @@ -149,7 +149,7 @@ async def mfa_authenticate(request: Request, session_provider: ORMSessionProvide request.auth.user.totp_last_used = form.token.data session_provider.session.add(request.auth.user) logger.info( - f"{client_ip(request)}{request.auth.user.email}: completed" + f"{client_ip_str(request)}{request.auth.user.email}: completed" " TOTP authentication successfully" ) return RedirectResponse(next_url, status_code=302) @@ -196,7 +196,7 @@ async def webauthn_verify_authentication_response( except Exception as err: logger.info( ( - f"{client_ip(request)}{request.auth.user.email}: unable to verify security token" + f"{client_ip_str(request)}{request.auth.user.email}: unable to verify security token" f" authentication response: {err}" ), exc_info=err, @@ -210,7 +210,7 @@ async def webauthn_verify_authentication_response( del request.session[WN_CHALLENGE_SESSION_KEY] request.session[MFA_COMPLETE_SESSION_KEY] = True logger.info( - f"{client_ip(request)}{request.auth.user.email}: authenticated successfully with security token" + f"{client_ip_str(request)}{request.auth.user.email}: authenticated successfully with security token" f" {authn.pk}" ) return JSONResponse({"verified": True}) @@ -269,7 +269,7 @@ async def webauthn_verify_registration_response( except Exception as err: logger.info( ( - f"{client_ip(request)}{request.auth.user.email}: unable to verify security" + f"{client_ip_str(request)}{request.auth.user.email}: unable to verify security" f"token registration response: {err}" ), exc_info=err, @@ -286,7 +286,7 @@ async def webauthn_verify_registration_response( session_provider.session.add(new_auth) del request.session[WN_CHALLENGE_SESSION_KEY] message(request, "Your security token has been added to your account. You may need to re-authenticate.") - logger.info(f"{client_ip(request)}{request.auth.user.email}: added security token {new_auth.pk}") + logger.info(f"{client_ip_str(request)}{request.auth.user.email}: added security token {new_auth.pk}") send_authentication_change_mail(request.auth.user, request, "A security token was added to your account.") return JSONResponse({"success": True}) @@ -320,7 +320,7 @@ async def webauthn_remove(request: Request, session_provider: ORMSessionProvider session_provider.session.delete(target) message(request, "The security token has been removed.") - logger.info(f"{client_ip(request)}{request.auth.user.email}: removed security token {target.pk}") + logger.info(f"{client_ip_str(request)}{request.auth.user.email}: removed security token {target.pk}") send_authentication_change_mail( request.auth.user, request, "A security token was removed from your account." ) @@ -369,7 +369,7 @@ async def totp_register(request: Request, session_provider: ORMSessionProvider) request.auth.user.totp_secret = totp_secret session_provider.session.add(request.auth.user) message(request, "One time passwords have been enabled. You may need to re-authenticate.") - logger.info(f"{client_ip(request)}{request.auth.user.email}: configured new TOTP on account") + logger.info(f"{client_ip_str(request)}{request.auth.user.email}: configured new TOTP on account") send_authentication_change_mail( request.auth.user, request, "One time password was added to your account." ) @@ -396,7 +396,7 @@ async def totp_remove(request: Request, session_provider: ORMSessionProvider) -> request.auth.user.totp_secret = None session_provider.session.add(request.auth.user) message(request, "The one time password been removed.") - logger.info(f"{client_ip(request)}{request.auth.user.email}: removed TOTP from account") + logger.info(f"{client_ip_str(request)}{request.auth.user.email}: removed TOTP from account") send_authentication_change_mail( request.auth.user, request, "One time password was removed from your account." ) diff --git a/irrd/webui/endpoints.py b/irrd/webui/endpoints.py index 56b09fe19..fe8d32bed 100644 --- a/irrd/webui/endpoints.py +++ b/irrd/webui/endpoints.py @@ -5,8 +5,16 @@ from starlette.responses import Response from starlette_wtf import csrf_protect, csrf_token +from irrd import META_KEY_HTTP_CLIENT_IP from irrd.conf import get_setting -from irrd.storage.models import AuthoritativeChangeOrigin, AuthUser, RPSLDatabaseObject +from irrd.storage.models import ( + AuthMntner, + AuthoritativeChangeOrigin, + AuthPermission, + AuthUser, + ChangeLog, + RPSLDatabaseObject, +) from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager from irrd.storage.queries import RPSLDatabaseQuery from irrd.updates.handler import ChangeSubmissionHandler @@ -140,7 +148,7 @@ async def rpsl_update( elif request.method == "POST": form_data = await request.form() request_meta = { - "HTTP-client-IP": request.client.host if request.client else "", + META_KEY_HTTP_CLIENT_IP: request.client.host if request.client else "", "HTTP-User-Agent": request.headers.get("User-Agent"), } @@ -172,3 +180,59 @@ def save(): }, ) return Response(status_code=405) # pragma: no cover + + +@session_provider_manager +@authentication_required +async def change_log_mntner(request: Request, session_provider: ORMSessionProvider) -> Response: + query = session_provider.session.query(AuthMntner).join(AuthPermission) + query = query.filter( + AuthMntner.pk == request.path_params["mntner"], + AuthPermission.user_id == str(request.auth.user.pk), + AuthPermission.user_management == True, # noqa + ) + mntner = await session_provider.run(query.one) + if not mntner or not mntner.migration_complete: + return Response(status_code=404) + + query = ( + session_provider.session.query(ChangeLog) + .filter( + (ChangeLog.auth_through_mntner_id == str(mntner.pk)) + | ( + (ChangeLog.auth_through_rpsl_mntner_pk == mntner.rpsl_mntner_pk) + & (ChangeLog.rpsl_target_source == mntner.rpsl_mntner_source) + ) + ) + .order_by(ChangeLog.timestamp.desc()) + ) + change_logs = await session_provider.run(query.all) + + return template_context_render( + "change_log_mntner.html", request, {"mntner": mntner, "change_logs": change_logs} + ) + + +@session_provider_manager +@authentication_required +async def change_log_entry(request: Request, session_provider: ORMSessionProvider) -> Response: + mntners = list(request.auth.user.mntners_user_management) + if not mntners: + return Response(status_code=404) + + query = session_provider.session.query(ChangeLog) + query = query.filter( + (ChangeLog.pk == request.path_params["entry"]) + & ( + (ChangeLog.auth_through_mntner_id.in_([str(mntner.pk) for mntner in mntners])) + | ( + ChangeLog.auth_through_rpsl_mntner_pk.in_([mntner.rpsl_mntner_pk for mntner in mntners]) + & ChangeLog.rpsl_target_source.in_([mntner.rpsl_mntner_source for mntner in mntners]) + ) + ) + ) + entry = await session_provider.run(query.one) + if not entry: + return Response(status_code=404) + + return template_context_render("change_log_entry.html", request, {"entry": entry}) diff --git a/irrd/webui/endpoints_mntners.py b/irrd/webui/endpoints_mntners.py index 3b2a88fff..192ca5a07 100644 --- a/irrd/webui/endpoints_mntners.py +++ b/irrd/webui/endpoints_mntners.py @@ -16,6 +16,7 @@ AuthMntner, AuthPermission, AuthUser, + ChangeLog, JournalEntryOrigin, RPSLDatabaseObject, ) @@ -24,7 +25,13 @@ from irrd.utils.text import clean_ip_value_error from irrd.webui.auth.decorators import authentication_required from irrd.webui.auth.users import CurrentPasswordForm -from irrd.webui.helpers import client_ip, message, rate_limit_post, send_template_email +from irrd.webui.helpers import ( + client_ip, + client_ip_str, + message, + rate_limit_post, + send_template_email, +) from irrd.webui.rendering import render_form, template_context_render logger = logging.getLogger(__name__) @@ -102,8 +109,19 @@ async def api_token_add(request: Request, session_provider: ORMSessionProvider) message(request, message_text) await notify_mntner(session_provider, request.auth.user, mntner, explanation=message_text) logger.info( - f"{client_ip(request)}{request.auth.user.email}: added API token {new_token.pk} on mntner" - f" {mntner.rpsl_mntner_pk}" + f'{client_ip_str(request)}{request.auth.user.email}: added API token "{new_token.name}"' + f" ({new_token.pk}) on mntner {mntner.rpsl_mntner_pk}" + ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(mntner.pk), + auth_through_rpsl_mntner_pk=str(mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr=f'added API token "{new_token.name}" ({new_token.pk})', + auth_affected_mntner_id=str(mntner.pk), + ) ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) @@ -143,8 +161,19 @@ async def api_token_edit(request: Request, session_provider: ORMSessionProvider) message_text = f"The API key for '{api_token.name}' on {api_token.mntner.rpsl_mntner_pk} was modified." message(request, message_text) logger.info( - f"{client_ip(request)}{request.auth.user.email}: updated API token {api_token.pk} on mntner" - f" {api_token.mntner.rpsl_mntner_pk}" + f'{client_ip_str(request)}{request.auth.user.email}: updated API token "{api_token.name}"' + f" ({api_token.pk}) on mntner {api_token.mntner.rpsl_mntner_pk}" + ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(api_token.mntner.pk), + auth_through_rpsl_mntner_pk=str(api_token.mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr=f'modified API token "{api_token.name}" ({api_token.pk})', + auth_affected_mntner_id=str(api_token.mntner.pk), + ) ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) @@ -186,8 +215,19 @@ async def api_token_delete(request: Request, session_provider: ORMSessionProvide message(request, message_text) await notify_mntner(session_provider, request.auth.user, api_token.mntner, explanation=message_text) logger.info( - f"{client_ip(request)}{request.auth.user.email}: removed API token {api_token.pk} on mntner" - f" {api_token.mntner.rpsl_mntner_pk}" + f'{client_ip_str(request)}{request.auth.user.email}: removed API token "{api_token.name}"' + f" ({api_token.pk}) on mntner {api_token.mntner.rpsl_mntner_pk}" + ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(api_token.mntner.pk), + auth_through_rpsl_mntner_pk=str(api_token.mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr=f'removed API token "{api_token.name}" ({api_token.pk})', + auth_affected_mntner_id=str(api_token.mntner.pk), + ) ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) @@ -277,9 +317,24 @@ async def permission_add(request: Request, session_provider: ORMSessionProvider) message(request, message_text) await notify_mntner(session_provider, request.auth.user, mntner, explanation=message_text) logger.info( - f"{client_ip(request)}{request.auth.user.email}: added permission {new_permission.pk} on mntner" + f"{client_ip_str(request)}{request.auth.user.email}: added permission {new_permission.pk} on mntner" f" {mntner.rpsl_mntner_pk} for user {form.new_user.email}" ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(mntner.pk), + auth_through_rpsl_mntner_pk=str(mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr=( + f"added permission for {new_permission.user.email}," + f" {'with' if new_permission.user_management else 'without'} user management" + ), + auth_affected_user_id=str(new_permission.user.pk), + auth_affected_mntner_id=str(mntner.pk), + ) + ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) @@ -341,9 +396,24 @@ async def permission_delete(request: Request, session_provider: ORMSessionProvid ).delete() message(request, message_text) logger.info( - f"{client_ip(request)}{request.auth.user.email}: removed permission {permission.pk} on mntner" + f"{client_ip_str(request)}{request.auth.user.email}: removed permission {permission.pk} on mntner" f" {permission.mntner.rpsl_mntner_pk} for user {permission.user.email}" ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(permission.mntner.pk), + auth_through_rpsl_mntner_pk=str(permission.mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr=( + f"deleted permission for {permission.user.email}" + f" {'with' if permission.user_management else 'without'} user management" + ), + auth_affected_user_id=str(permission.user.pk), + auth_affected_mntner_id=str(permission.mntner.pk), + ) + ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) @@ -402,7 +472,8 @@ async def validate(self): self.rpsl_mntner_db_pk = mntner_obj.pk self.rpsl_mntner = RPSLMntner(mntner_obj.object_text, strict_validation=False) - if not self.rpsl_mntner.verify_auth(passwords=[self.mntner_password.data]): + valid_scheme = self.rpsl_mntner.verify_auth(passwords=[self.mntner_password.data]) + if not valid_scheme: logger.info( f"invalid password provided for mntner {self.rpsl_mntner.pk()} " " while attempting to start migration" @@ -453,9 +524,20 @@ async def mntner_migrate_initiate(request: Request, session_provider: ORMSession await send_mntner_migrate_initiate_mail(session_provider, request, new_auth_mntner, form.rpsl_mntner) message(request, "The mntner's admin-c's have been sent a confirmation email to complete the migration.") logger.info( - f"{client_ip(request)}{request.auth.user.email}: initiated migration of {form.rpsl_mntner.pk()}," + f"{client_ip_str(request)}{request.auth.user.email}: initiated migration of {form.rpsl_mntner.pk()}," " pending confirmation" ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(new_auth_mntner.pk), + auth_through_rpsl_mntner_pk=str(new_auth_mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr="maintainer migration initiated", + auth_affected_mntner_id=str(new_auth_mntner.pk), + ) + ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) @@ -482,7 +564,8 @@ async def validate(self): self.rpsl_mntner_obj = RPSLMntner( self.auth_mntner.rpsl_mntner_obj.object_text, strict_validation=False ) - if not self.rpsl_mntner_obj.verify_auth(passwords=[self.mntner_password.data]): + valid_scheme = self.rpsl_mntner_obj.verify_auth(passwords=[self.mntner_password.data]) + if not valid_scheme: logger.info( f"invalid password provided for mntner {self.auth_mntner.rpsl_mntner_pk} while attempting to" " confirm migration" @@ -539,7 +622,19 @@ async def mntner_migrate_complete(request: Request, session_provider: ORMSession message(request, f"The mntner {auth_mntner.rpsl_mntner_pk} has been migrated.") logger.info( - f"{client_ip(request)}{request.auth.user.email}: completed migration of {auth_mntner.rpsl_mntner_pk}" + f"{client_ip_str(request)}{request.auth.user.email}: completed migration of" + f" {auth_mntner.rpsl_mntner_pk}" + ) + session_provider.session.add( + ChangeLog( + auth_by_user_id=str(request.auth.user.pk), + auth_by_user_email=request.auth.user.email, + auth_through_mntner_id=str(auth_mntner.pk), + auth_through_rpsl_mntner_pk=str(auth_mntner.rpsl_mntner_pk), + from_ip=client_ip(request), + auth_change_descr="maintainer migration completed", + auth_affected_mntner_id=str(auth_mntner.pk), + ) ) return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) diff --git a/irrd/webui/helpers.py b/irrd/webui/helpers.py index d10097fe9..3eb47fd59 100644 --- a/irrd/webui/helpers.py +++ b/irrd/webui/helpers.py @@ -41,7 +41,7 @@ async def endpoint_wrapper(*args, **kwargs): limiter = request.app.state.rate_limiter permitted = await limiter.test(rate_limit, RATE_LIMIT_POST_200_NAMESPACE, request.client.host) if not permitted: - logger.info(f"{client_ip(request)}rejecting request due to rate limiting") + logger.info(f"{client_ip_str(request)}rejecting request due to rate limiting") return Response("Request denied due to rate limiting", status_code=403) response = await func(*args, **kwargs) @@ -90,7 +90,7 @@ def send_template_email( ) body = templates.get_template(f"{template_key}_mail.txt").render(request=request, **template_kwargs) send_email(recipient, subject, body) - logger.info(f"{client_ip(request)}email sent to {recipient}: {subject}") + logger.info(f"{client_ip_str(request)}email sent to {recipient}: {subject}") def send_authentication_change_mail( @@ -127,8 +127,16 @@ def filter_auth_hash_non_mntner(user: Optional[AuthUser], rpsl_object: RPSLDatab return remove_auth_hashes(rpsl_object.object_text) -def client_ip(request: Optional[Request]) -> str: +def client_ip_str(request: Optional[Request]) -> str: + """Small wrapper to wrap client IP in a loggable str.""" + ip = client_ip(request) + if ip: + return f"{ip}: " + return "" # pragma: no cover + + +def client_ip(request: Optional[Request]) -> Optional[str]: """Small wrapper to get the client IP from a request.""" if request and request.client: - return f"{request.client.host}: " - return "" # pragma: no cover + return request.client.host if request.client.host != "testclient" else "127.0.0.1" + return None diff --git a/irrd/webui/routes.py b/irrd/webui/routes.py index 6af7b5712..1f01f1e1c 100644 --- a/irrd/webui/routes.py +++ b/irrd/webui/routes.py @@ -2,6 +2,8 @@ from irrd.webui.auth.routes import AUTH_ROUTES from irrd.webui.endpoints import ( + change_log_entry, + change_log_mntner, index, maintained_objects, rpsl_detail, @@ -54,5 +56,7 @@ name="api_token_delete", methods=["GET", "POST"], ), + Route("/change-log/{mntner:uuid}/", change_log_mntner, name="change_log_mntner"), + Route("/change-log/entry/{entry:uuid}/", change_log_entry, name="change_log_entry"), Mount("/auth", name="auth", routes=AUTH_ROUTES), ] diff --git a/irrd/webui/templates/base.html b/irrd/webui/templates/base.html index 8657e82fd..c8d9785fd 100644 --- a/irrd/webui/templates/base.html +++ b/irrd/webui/templates/base.html @@ -32,7 +32,7 @@ {{ nav_link('ui:rpsl_update', 'Submit update') }} {% if request.auth.is_authenticated %} - {{ nav_link('ui:user_permissions', 'Maintainer permissions') }} + {{ nav_link('ui:user_permissions', 'Maintainers & permissions') }} {{ nav_link('ui:maintained_objects', 'My objects') }} {% if irrd_internal_migration_enabled %} {{ nav_link('ui:mntner_migrate_initiate', 'Migrate a mntner') }} diff --git a/irrd/webui/templates/change_log_entry.html b/irrd/webui/templates/change_log_entry.html new file mode 100644 index 000000000..ab392b965 --- /dev/null +++ b/irrd/webui/templates/change_log_entry.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} +{% block content %} +

Change: {{ entry.description() }}

+
+
+ Time: +
+
+ {{ entry.timestamp|datetime_format }} +
+
+ Authenticated through: +
+
+ {{ entry.auth_through_rpsl_mntner_pk }} +
+
+ Authentication method: +
+
+ {% if entry.auth_by_user_email %} + web by user {{ entry.auth_by_user_email }} + {% endif %} + {% if entry.auth_by_api_key %} + API key {{ entry.auth_by_api_key.name }} ({{ entry.auth_by_api_key.pk }}) + {% elif entry.auth_by_api_key_id_fixed %} + API key {{ entry.auth_by_api_key_id_fixed }} + {% endif %} + {% if entry.auth_by_rpsl_mntner_password %} + password on the RPSL mntner + {% endif %} + {% if entry.auth_by_rpsl_mntner_pgp_key %} + PGP key on the RPSL mntner + {% endif %} + {% if entry.auth_by_override %} + override access + {% endif %} +
+ + {% if entry.from_email %} +
+ From email: +
+
+ {{ entry.from_email }} +
+ {% endif %} + {% if entry.from_ip %} +
+ From IP: +
+
+ {{ entry.from_ip }} +
+ {% endif %} + + {% if entry.rpsl_target_pk %} +

Modified object

+
+ Primary key: +
+
+ {{ entry.rpsl_target_pk }} +
+
+ Source: +
+
+ {{ entry.rpsl_target_source }} +
+
+ Object class: +
+
+ {{ entry.rpsl_target_object_class }} +
+ {% if entry.rpsl_target_object_text_old %} +
+ Old object text: +
+
+
{{ entry.rpsl_target_object_text_old }}
+
+ {% endif %} + {% if entry.rpsl_target_object_text_new %} +
+ New object text: +
+
+
{{ entry.rpsl_target_object_text_new }}
+
+ {% endif %} + {% elif entry.auth_change_descr %} +

Authentication change

+
+ Change: +
+
+ {{ entry.auth_change_descr }} +
+ {% if entry.auth_affected_user %} +
+ Affected user: +
+
+ {{ entry.auth_affected_user.email }} +
+ {% endif %} + {% if entry.auth_affected_mntner %} +
+ Affected internal mntner: +
+
+ {{ entry.auth_affected_mntner.rpsl_mntner_pk }} +
+ {% endif %} + {% endif %} +
+ +{% endblock %} diff --git a/irrd/webui/templates/change_log_mntner.html b/irrd/webui/templates/change_log_mntner.html new file mode 100644 index 000000000..21caff049 --- /dev/null +++ b/irrd/webui/templates/change_log_mntner.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block content %} +

Changes for {{ mntner.rpsl_mntner_pk }}

+ {% if change_logs %} + + + + + + + + + + {% for entry in change_logs %} + + + + + + {% endfor %} + +
TimeChangeAuthentication
+ + {{ entry.timestamp|datetime_format }} + + + {{ entry.description() }} + + {% if entry.auth_by_user_email %} + web by user {{ entry.auth_by_user_email }} + {% endif %} + {% if entry.auth_by_api_key %} + API key {{ entry.auth_by_api_key.name }} ({{ entry.auth_by_api_key.pk }}) + {% elif entry.auth_by_api_key_id_fixed %} + API key {{ entry.auth_by_api_key_id_fixed }} + {% endif %} + {% if entry.auth_by_rpsl_mntner_password %} + password on the RPSL mntner + {% endif %} + {% if entry.auth_by_rpsl_mntner_pgp_key %} + PGP key on the RPSL mntner + {% endif %} + {% if entry.auth_by_override %} + override access + {% endif %} + {% if entry.from_ip %} + (from IP {{ entry.from_ip }}) + {% endif %} + {% if entry.from_email %} + (from e-mail {{ entry.from_email }}) + {% endif %} +
+ {% else %} + There have been no changes since the migration of this mntner. + {% endif %} +{% endblock %} diff --git a/irrd/webui/templates/user_permissions.html b/irrd/webui/templates/user_permissions.html index 034337c7b..9c368fe00 100644 --- a/irrd/webui/templates/user_permissions.html +++ b/irrd/webui/templates/user_permissions.html @@ -108,8 +108,15 @@
API tokens for {{ permission.mntner.rpsl_mntner_pk }}
Add a new API token + + Change log for this mntner + {% else %} The migration for this mntner is not yet complete. {% endif %} + {% else %} + You do not have access to any maintainers. + You can migrate a maintainer, + or ask an authorised user to add you to a maintainer. {% endfor %} {% endblock %} diff --git a/irrd/webui/tests/test_endpoints.py b/irrd/webui/tests/test_endpoints.py index ebb900211..7b8a8eceb 100644 --- a/irrd/webui/tests/test_endpoints.py +++ b/irrd/webui/tests/test_endpoints.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime, timezone from unittest.mock import create_autospec @@ -7,6 +8,11 @@ from irrd.utils.rpsl_samples import SAMPLE_MNTNER from irrd.webui import datetime_format +from ...rpsl.rpsl_objects import rpsl_object_from_text +from ...storage.database_handler import DatabaseHandler +from ...storage.models import JournalEntryOrigin +from ...updates.parser_state import UpdateRequestType +from ...utils.factories import AuthApiTokenFactory, AuthMntnerFactory, ChangeLogFactory from .conftest import WebRequestTest, create_permission @@ -252,3 +258,140 @@ def test_valid_mntner_not_logged_in(self, test_client, irrd_db_session_with_user assert response.status_code == 200 assert "TEST-MNT" in response.text assert "DUMMYVALUE" in response.text.upper() + + +class TestChangeLogMntner(WebRequestTest): + url_template = "/ui/change-log/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.url = self.url_template.format(uuid=self.permission.mntner.pk) + + def test_render(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + ChangeLogFactory( + auth_through_mntner_id=str(self.permission.mntner.pk), + auth_change_descr="auth change descr", + ) + api_token = AuthApiTokenFactory() + ChangeLogFactory( + auth_through_rpsl_mntner_pk=str(self.permission.mntner.rpsl_mntner_pk), + rpsl_target_pk="TARGET-PK", + rpsl_target_object_class="person", + rpsl_target_source=self.permission.mntner.rpsl_mntner_source, + auth_by_api_key_id_fixed=str(api_token.pk), + from_ip="127.0.0.1", + rpsl_target_request_type=UpdateRequestType.MODIFY, + ) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert self.permission.mntner.rpsl_mntner_pk in response.text + assert "auth change descr" in response.text + assert str(api_token.pk) in response.text + assert "127.0.0.1" in response.text + assert "modify of person TARGET-PK" in response.text + + def test_no_entries(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert self.permission.mntner.rpsl_mntner_pk in response.text + + def test_no_permissions(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + session_provider.session.delete(self.permission) + session_provider.session.commit() + + response = test_client.get(self.url) + assert response.status_code == 404 + + def test_wrong_permissions(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + session_provider.session.delete(self.permission) + session_provider.session.commit() + + # Creating a second mntner with separate permissions is kind of tricky + dh = DatabaseHandler() + dh.upsert_rpsl_object( + rpsl_object_from_text(SAMPLE_MNTNER.replace("TEST", "TEST2")), origin=JournalEntryOrigin.unknown + ) + dh.commit() + dh.close() + create_permission( + session_provider, user, user_management=True, mntner=AuthMntnerFactory(rpsl_mntner_source="TEST2") + ) + + response = test_client.get(self.url) + assert response.status_code == 404 + + +class TestChangeLogEntry(WebRequestTest): + url_template = "/ui/change-log/entry/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.change_log = ChangeLogFactory( + auth_through_mntner_id=str(self.permission.mntner.pk), + auth_change_descr="auth change descr", + ) + self.url = self.url_template.format(uuid=self.change_log.pk) + + def test_render(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "auth change descr" in response.text + + def test_render_rpsl_change(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + + api_token = AuthApiTokenFactory() + change_log = ChangeLogFactory( + auth_through_rpsl_mntner_pk=str(self.permission.mntner.rpsl_mntner_pk), + rpsl_target_pk="TARGET-PK", + rpsl_target_object_class="person", + rpsl_target_source=self.permission.mntner.rpsl_mntner_source, + auth_by_api_key_id_fixed=str(api_token.pk), + from_ip="127.0.0.1", + rpsl_target_request_type=UpdateRequestType.MODIFY, + ) + self.url = self.url_template.format(uuid=change_log.pk) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert str(api_token.pk) in response.text + assert "127.0.0.1" in response.text + assert "modify of person TARGET-PK" in response.text + + def test_no_permissions(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + session_provider.session.delete(self.permission) + session_provider.session.commit() + + response = test_client.get(self.url) + assert response.status_code == 404 + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + response = test_client.get(self.url_template.format(uuid=uuid.uuid4())) + assert response.status_code == 404