diff --git a/docs/admins/webui.rst b/docs/admins/webui.rst index 6c82d5ab5..b31e91ab8 100644 --- a/docs/admins/webui.rst +++ b/docs/admins/webui.rst @@ -87,3 +87,11 @@ the following steps: A migrated maintainer must have ``IRRD-INTERNAL-AUTH`` as one of the ``auth`` methods. This is added as part of the migration process. + +API tokens +---------- +All linked users can add API tokens for a maintainer. Tokens can have +a restriction on submission methods or IP ranges. Each token has a +secret, that can be passed in ``api_keys`` in an HTTP call, +or the pseudo-attribute ``api-key`` in email submissions. +API keys can not be used to update their own maintainer. diff --git a/docs/users/database-changes.rst b/docs/users/database-changes.rst index 4ce440573..adc0124a8 100644 --- a/docs/users/database-changes.rst +++ b/docs/users/database-changes.rst @@ -52,6 +52,10 @@ The expected request body is a JSON object, with a number of keys: a list, which will be translated into RPSL by IRRd. * ``passwords``: an optional list of passwords to use for authentication. Each password will be considered for each object to be changed. +* ``api_keys``: an optional list of API keys to use for authentication. + Each key will be considered for each object to be changed. + Keys can be created on migrated maintainers in the web interface, if + enabled in your instance. * ``delete_reason``: an optional string with the reason for object deletion. * ``override``: an optional string containing the override password. @@ -160,8 +164,8 @@ To delete an object, submit the current version of the object with a [other object data] delete: -For authentication, you can include ``password`` attributes anywhere -in the submission, on their own or as part of objects, e.g.:: +For authentication, you can include ``password`` or ``api-key`` attributes +anywhere in the submission, on their own or as part of objects, e.g.:: route: 192.0.2.0/24 origin: AS65536 @@ -170,9 +174,12 @@ in the submission, on their own or as part of objects, e.g.:: password: -You may submit multiple passwords, and each password will be considered +You may submit multiple passwords or keys, and each will be considered for each authentication check. +API keys can be created on migrated maintainers in the web interface, if +enabled in your instance. + For PGP authentication, sign your message with a PGP/MIME signature or inline PGP. You can combine PGP signatures and passwords, and each method will be considered for each authentication check. diff --git a/irrd/scripts/submit_changes.py b/irrd/scripts/submit_changes.py index 2a0d47e93..5153af561 100755 --- a/irrd/scripts/submit_changes.py +++ b/irrd/scripts/submit_changes.py @@ -15,6 +15,8 @@ import sys from pathlib import Path +from irrd.storage.models import AuthoritativeChangeOrigin + sys.path.append(str(Path(__file__).resolve().parents[2])) from irrd.conf import CONFIG_PATH_DEFAULT, config_init @@ -22,7 +24,7 @@ def main(data): - handler = ChangeSubmissionHandler().load_text_blob(data) + handler = ChangeSubmissionHandler().load_text_blob(data, AuthoritativeChangeOrigin.other) print(handler.submitter_report_human()) diff --git a/irrd/scripts/tests/test_submit_update.py b/irrd/scripts/tests/test_submit_update.py index c7843d2b8..fbbd099a8 100644 --- a/irrd/scripts/tests/test_submit_update.py +++ b/irrd/scripts/tests/test_submit_update.py @@ -7,7 +7,7 @@ def test_submit_changes(capsys, monkeypatch): mock_update_handler = Mock(spec=ChangeSubmissionHandler) monkeypatch.setattr("irrd.scripts.submit_changes.ChangeSubmissionHandler", lambda: mock_update_handler) - mock_update_handler.load_text_blob = lambda data: mock_update_handler + mock_update_handler.load_text_blob = lambda data, origin: mock_update_handler mock_update_handler.submitter_report_human = lambda: "output" main("test input") diff --git a/irrd/server/http/endpoints_api.py b/irrd/server/http/endpoints_api.py index 658de79f6..85adf50c7 100644 --- a/irrd/server/http/endpoints_api.py +++ b/irrd/server/http/endpoints_api.py @@ -5,6 +5,7 @@ import pydantic from asgiref.sync import sync_to_async +from IPy import IP from starlette.endpoints import HTTPEndpoint from starlette.requests import Request from starlette.responses import JSONResponse, PlainTextResponse, Response @@ -13,6 +14,7 @@ from irrd.updates.handler import ChangeSubmissionHandler from irrd.utils.validators import RPSLChangeSubmission, RPSLSuspensionSubmission +from ...storage.models import AuthoritativeChangeOrigin from ..whois.query_parser import WhoisQueryParser from ..whois.query_response import WhoisQueryResponseType from .status_generator import StatusGenerator @@ -84,10 +86,18 @@ async def _handle_submission(self, request: Request, delete=False): request_meta["HTTP-client-IP"] = request.client.host request_meta["HTTP-User-Agent"] = request.headers.get("User-Agent") + try: + remote_ip = IP(request.client.host) + except ValueError: + remote_ip = None handler = ChangeSubmissionHandler() await sync_to_async(handler.load_change_submission)( - data=data, delete=delete, request_meta=request_meta + data=data, + origin=AuthoritativeChangeOrigin.webapi, + delete=delete, + request_meta=request_meta, + remote_ip=remote_ip, ) await sync_to_async(handler.send_notification_target_reports)() return JSONResponse(handler.submitter_report_json()) diff --git a/irrd/server/http/tests/test_endpoints.py b/irrd/server/http/tests/test_endpoints.py index 08209e393..af5ac2434 100644 --- a/irrd/server/http/tests/test_endpoints.py +++ b/irrd/server/http/tests/test_endpoints.py @@ -5,6 +5,7 @@ from starlette.testclient import TestClient from irrd.storage.database_handler import DatabaseHandler +from irrd.storage.models import AuthoritativeChangeOrigin from irrd.storage.preload import Preloader from irrd.updates.handler import ChangeSubmissionHandler from irrd.utils.validators import RPSLChangeSubmission, RPSLSuspensionSubmission @@ -183,8 +184,10 @@ def test_endpoint(self, monkeypatch): assert response_post.text == '{"response":true}' mock_handler.load_change_submission.assert_called_once_with( data=expected_data, + origin=AuthoritativeChangeOrigin.webapi, delete=False, request_meta={"HTTP-client-IP": "testclient", "HTTP-User-Agent": "testclient", "meta": 2}, + remote_ip=None, ) mock_handler.send_notification_target_reports.assert_called_once() mock_handler.reset_mock() @@ -194,8 +197,10 @@ def test_endpoint(self, monkeypatch): assert response_delete.text == '{"response":true}' mock_handler.load_change_submission.assert_called_once_with( data=expected_data, + origin=AuthoritativeChangeOrigin.webapi, delete=True, request_meta={"HTTP-client-IP": "testclient", "HTTP-User-Agent": "testclient"}, + remote_ip=None, ) mock_handler.send_notification_target_reports.assert_called_once() mock_handler.reset_mock() diff --git a/irrd/storage/alembic/versions/500027f85a55_add_api_tokens.py b/irrd/storage/alembic/versions/500027f85a55_add_api_tokens.py new file mode 100644 index 000000000..53ef8c7b1 --- /dev/null +++ b/irrd/storage/alembic/versions/500027f85a55_add_api_tokens.py @@ -0,0 +1,49 @@ +"""add_api_tokens + +Revision ID: 500027f85a55 +Revises: 5bbbc2989aa6 +Create Date: 2023-05-05 13:21:43.915949 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "500027f85a55" +down_revision = "5bbbc2989aa6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "auth_api_token", + sa.Column( + "pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column( + "token", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=True + ), + sa.Column("name", sa.String(), nullable=False), + sa.Column("creator_id", postgresql.UUID(), nullable=True), + sa.Column("mntner_id", postgresql.UUID(), nullable=True), + sa.Column("ip_restriction", sa.String(), nullable=True), + sa.Column("enabled_webapi", sa.Boolean(), nullable=False), + sa.Column("enabled_email", sa.Boolean(), nullable=False), + sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["creator_id"], ["auth_user.pk"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["mntner_id"], ["auth_mntner.pk"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("pk"), + ) + op.create_index(op.f("ix_auth_api_token_creator_id"), "auth_api_token", ["creator_id"], unique=False) + op.create_index(op.f("ix_auth_api_token_mntner_id"), "auth_api_token", ["mntner_id"], unique=False) + op.create_index(op.f("ix_auth_api_token_token"), "auth_api_token", ["token"], unique=True) + + +def downgrade(): + op.drop_index(op.f("ix_auth_api_token_token"), table_name="auth_api_token") + op.drop_index(op.f("ix_auth_api_token_mntner_id"), table_name="auth_api_token") + op.drop_index(op.f("ix_auth_api_token_creator_id"), table_name="auth_api_token") + op.drop_table("auth_api_token") diff --git a/irrd/storage/models.py b/irrd/storage/models.py index cd0537276..1651c72a6 100644 --- a/irrd/storage/models.py +++ b/irrd/storage/models.py @@ -1,6 +1,7 @@ import enum import sqlalchemy as sa +from IPy import IP from sqlalchemy.dialects import postgresql as pg from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import relationship @@ -37,6 +38,13 @@ class JournalEntryOrigin(enum.Enum): route_preference = "ROUTE_PREFERENCE" +class AuthoritativeChangeOrigin(enum.Enum): + webui = "WEBUI" + webapi = "WEBAPI" + email = "EMAIL" + other = "OTHER" + + Base = declarative_base() @@ -270,7 +278,6 @@ class AuthPermission(Base): # type: ignore user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"), index=True) mntner_id = sa.Column(pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="RESTRICT"), index=True) - # This may not scale well user_management = sa.Column(sa.Boolean, default=False, nullable=False) created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) @@ -300,7 +307,6 @@ class AuthUser(Base): # type: ignore active = sa.Column(sa.Boolean, default=False, nullable=False) override = sa.Column(sa.Boolean, default=False, nullable=False) - # api_tokens = relationship("AuthApiToken", backref="user") permissions = relationship( "AuthPermission", @@ -383,19 +389,50 @@ class AuthWebAuthn(Base): # type: ignore last_used = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) -# class AuthApiToken(Base): # type: ignore -# __tablename__ = "auth_api_token" -# -# token = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) -# user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT")) -# # IP range? -# # submission method -# -# created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) -# updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) -# -# def __repr__(self): -# return f"<{self.pk}/{self.email}" +class AuthApiToken(Base): # type: ignore + __tablename__ = "auth_api_token" + + pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) + token = sa.Column( + pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True, index=True + ) + name = sa.Column(sa.String, nullable=False) + creator_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"), index=True) + mntner_id = sa.Column(pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="RESTRICT"), index=True) + creator = relationship( + "AuthUser", + backref=sa.orm.backref("api_tokens_created"), + ) + mntner = relationship( + "AuthMntner", + backref=sa.orm.backref("api_tokens"), + ) + + # This is not an ARRAY(CIDR) because psycopg2cffi does not support those. + ip_restriction = sa.Column(sa.String, nullable=True) + enabled_webapi = sa.Column(sa.Boolean(), default=True, nullable=False) + enabled_email = sa.Column(sa.Boolean(), default=True, nullable=False) + + created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + + def __repr__(self): + return f"<{self.token}/{self.name}/{self.mntner.rpsl_mntner_pk if self.mntner else None}>" + + def valid_for(self, origin: AuthoritativeChangeOrigin, remote_ip: IP) -> bool: + if not any( + [ + self.enabled_webapi and origin == AuthoritativeChangeOrigin.webapi, + self.enabled_email and origin == AuthoritativeChangeOrigin.email, + ] + ): + return False + if self.ip_restriction: + for ip in self.ip_restriction.split(","): + if remote_ip and remote_ip in IP(ip): + return True + return False + return True class AuthMntner(Base): # type: ignore @@ -414,7 +451,6 @@ class AuthMntner(Base): # type: ignore migration_token = sa.Column(sa.String, nullable=True) - # permissions = relationship("AuthPermission", backref='mntner') permissions = relationship( "AuthPermission", backref=sa.orm.backref("mntner", uselist=False), diff --git a/irrd/updates/email.py b/irrd/updates/email.py index d625d626d..245058cca 100644 --- a/irrd/updates/email.py +++ b/irrd/updates/email.py @@ -4,6 +4,7 @@ from irrd.utils import email +from ..storage.models import AuthoritativeChangeOrigin from .handler import ChangeSubmissionHandler logger = logging.getLogger(__name__) @@ -50,7 +51,10 @@ def handle_email_submission(email_txt: str) -> Optional[ChangeSubmissionHandler] """) else: handler = ChangeSubmissionHandler().load_text_blob( - msg.body, pgp_fingerprint=msg.pgp_fingerprint, request_meta=request_meta + msg.body, + AuthoritativeChangeOrigin.email, + pgp_fingerprint=msg.pgp_fingerprint, + request_meta=request_meta, ) logger.info(f"Processed e-mail {msg.message_id} from {msg.message_from}: {handler.status()}") logger.debug( diff --git a/irrd/updates/handler.py b/irrd/updates/handler.py index bc1da6483..d91a77240 100644 --- a/irrd/updates/handler.py +++ b/irrd/updates/handler.py @@ -3,12 +3,13 @@ from collections import defaultdict from typing import Dict, List, Optional, Union +from IPy import IP from ordered_set import OrderedSet from irrd.conf import get_setting from irrd.rpsl.rpsl_objects import RPSLMntner from irrd.storage.database_handler import DatabaseHandler -from irrd.storage.models import AuthUser +from irrd.storage.models import AuthoritativeChangeOrigin, AuthUser from irrd.storage.queries import RPSLDatabaseQuery from irrd.utils import email @@ -31,6 +32,7 @@ class ChangeSubmissionHandler: def load_text_blob( self, object_texts_blob: str, + origin: AuthoritativeChangeOrigin, pgp_fingerprint: Optional[str] = None, internal_authenticated_user: Optional[AuthUser] = None, request_meta: Optional[Dict[str, Optional[str]]] = None, @@ -40,7 +42,9 @@ def load_text_blob( self._pgp_key_id = self._resolve_pgp_key_id(pgp_fingerprint) if pgp_fingerprint else None reference_validator = ReferenceValidator(self.database_handler) - auth_validator = AuthValidator(self.database_handler, self._pgp_key_id, internal_authenticated_user) + auth_validator = AuthValidator( + 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 ) @@ -53,14 +57,16 @@ def load_text_blob( def load_change_submission( self, data: RPSLChangeSubmission, + origin: AuthoritativeChangeOrigin, delete=False, request_meta: Optional[Dict[str, Optional[str]]] = None, + remote_ip: Optional[IP] = None, ): self.database_handler = DatabaseHandler() self.request_meta = request_meta if request_meta else {} reference_validator = ReferenceValidator(self.database_handler) - auth_validator = AuthValidator(self.database_handler) + auth_validator = AuthValidator(self.database_handler, origin, remote_ip=remote_ip) change_requests: List[Union[ChangeRequest, SuspensionRequest]] = [] delete_reason = None @@ -69,6 +75,7 @@ def load_change_submission( auth_validator.passwords = data.passwords auth_validator.overrides = [data.override] if data.override else [] + auth_validator.api_keys = data.api_keys for rpsl_obj in data.objects: object_text = rpsl_obj.object_text diff --git a/irrd/updates/parser.py b/irrd/updates/parser.py index 67b7af7d5..61843def0 100644 --- a/irrd/updates/parser.py +++ b/irrd/updates/parser.py @@ -564,6 +564,7 @@ def parse_change_requests( results: List[Union[ChangeRequest, SuspensionRequest]] = [] passwords = [] overrides = [] + api_keys = [] requests_text = requests_text.replace("\r", "") for object_text in requests_text.split("\n\n"): @@ -586,6 +587,9 @@ def parse_change_requests( elif line.startswith("override:"): override = line.split(":", maxsplit=1)[1].strip() overrides.append(override) + elif line.startswith("api-key:"): + api_key = line.split(":", maxsplit=1)[1].strip() + api_keys.append(api_key) elif line.startswith("delete:"): delete_reason = line.split(":", maxsplit=1)[1].strip() elif line.startswith("suspension:"): @@ -616,4 +620,5 @@ def parse_change_requests( if auth_validator: auth_validator.passwords = passwords auth_validator.overrides = overrides + auth_validator.api_keys = api_keys return results diff --git a/irrd/updates/tests/test_handler.py b/irrd/updates/tests/test_handler.py index 22a307d64..cab49822f 100644 --- a/irrd/updates/tests/test_handler.py +++ b/irrd/updates/tests/test_handler.py @@ -5,7 +5,7 @@ from irrd.scopefilter.status import ScopeFilterStatus from irrd.scopefilter.validators import ScopeFilterValidator -from irrd.storage.models import JournalEntryOrigin +from irrd.storage.models import AuthoritativeChangeOrigin, JournalEntryOrigin from irrd.utils.rpsl_samples import SAMPLE_MNTNER from irrd.utils.test_utils import flatten_mock_calls @@ -86,7 +86,7 @@ def test_parse_valid_new_objects_with_override(self, prepare_mocks): remarks: remark """) - handler = ChangeSubmissionHandler().load_text_blob(rpsl_text) + handler = ChangeSubmissionHandler().load_text_blob(rpsl_text, AuthoritativeChangeOrigin.email) assert handler.status() == "SUCCESS" assert flatten_mock_calls(mock_dq) == [ @@ -192,6 +192,7 @@ def test_parse_valid_new_person_existing_mntner_pgp_key(self, prepare_mocks): handler = ChangeSubmissionHandler().load_text_blob( rpsl_text, + AuthoritativeChangeOrigin.email, pgp_fingerprint="8626 1D8DBEBD A4F5 4692 D64D A838 3BA7 80F2 38C6", request_meta={"Message-ID": "test", "From": "example@example.com"}, ) @@ -342,7 +343,9 @@ def test_parse_invalid_new_objects_pgp_key_does_not_exist(self, prepare_mocks): mock_dh.execute_query = lambda query: next(query_responses) handler = ChangeSubmissionHandler().load_text_blob( - rpsl_text, pgp_fingerprint="8626 1D8DBEBD A4F5 4692 D64D A838 3BA7 80F2 38C6" + rpsl_text, + AuthoritativeChangeOrigin.email, + pgp_fingerprint="8626 1D8DBEBD A4F5 4692 D64D A838 3BA7 80F2 38C6", ) assert handler.status() == "FAILED", handler.submitter_report_human() @@ -388,7 +391,7 @@ def test_parse_valid_delete(self, prepare_mocks): mock_dh.execute_query = lambda query: next(query_responses) handler = ChangeSubmissionHandler().load_text_blob( - rpsl_person + "delete: delete\npassword: crypt-password\n" + rpsl_person + "delete: delete\npassword: crypt-password\n", AuthoritativeChangeOrigin.email ) assert handler.status() == "SUCCESS" @@ -566,7 +569,7 @@ def test_parse_invalid_cascading_failure(self, prepare_mocks): source: TEST """) - handler = ChangeSubmissionHandler().load_text_blob(rpsl_text) + handler = ChangeSubmissionHandler().load_text_blob(rpsl_text, AuthoritativeChangeOrigin.email) assert handler.status() == "FAILED" assert flatten_mock_calls(mock_dq) == [ @@ -803,7 +806,9 @@ def test_parse_invalid_single_failure_invalid_password(self, prepare_mocks): } ) - handler = ChangeSubmissionHandler().load_change_submission(submission_object) + handler = ChangeSubmissionHandler().load_change_submission( + submission_object, origin=AuthoritativeChangeOrigin.webapi + ) assert handler.status() == "FAILED" assert flatten_mock_calls(mock_dq) == [ @@ -941,7 +946,7 @@ def test_parse_invalid_cascading_failure_invalid_password(self, prepare_mocks): remarks: remark """) - handler = ChangeSubmissionHandler().load_text_blob(rpsl_text) + handler = ChangeSubmissionHandler().load_text_blob(rpsl_text, AuthoritativeChangeOrigin.email) assert handler.status() == "FAILED" assert flatten_mock_calls(mock_dq) == [ @@ -1054,7 +1059,9 @@ def test_parse_invalid_object_delete_syntax(self, prepare_mocks): } ) - handler = ChangeSubmissionHandler().load_change_submission(submission_object, delete=True) + handler = ChangeSubmissionHandler().load_change_submission( + submission_object, origin=AuthoritativeChangeOrigin.webapi, delete=True + ) assert handler.status() == "FAILED" assert flatten_mock_calls(mock_dq) == [] diff --git a/irrd/updates/tests/test_parser.py b/irrd/updates/tests/test_parser.py index a7f12b0b4..f68d86d68 100644 --- a/irrd/updates/tests/test_parser.py +++ b/irrd/updates/tests/test_parser.py @@ -490,7 +490,10 @@ def test_check_auth_valid_update_mntner(self, prepare_mocks): auth_validator = AuthValidator(mock_dh) result_inetnum = parse_change_requests( - SAMPLE_INETNUM + "password: crypt-password", mock_dh, auth_validator, reference_validator + SAMPLE_INETNUM + "password: crypt-password", + mock_dh, + auth_validator, + reference_validator, )[0] assert result_inetnum._check_auth() assert not result_inetnum.error_messages @@ -516,12 +519,12 @@ def test_check_auth_valid_update_mntner(self, prepare_mocks): "notify@example.com", } - auth_validator = AuthValidator(mock_dh, "PGPKEY-80F238C6") + auth_validator = AuthValidator(mock_dh, keycert_obj_pk="PGPKEY-80F238C6") result_inetnum = parse_change_requests(SAMPLE_INETNUM, mock_dh, auth_validator, reference_validator)[ 0 ] - assert result_inetnum._check_auth() assert not result_inetnum.error_messages + assert result_inetnum._check_auth() def test_check_auth_valid_create_mntner_referencing_self(self, prepare_mocks): mock_dq, mock_dh = prepare_mocks @@ -658,11 +661,15 @@ def test_check_auth_invalid_update_mntner_submits_new_object_with_dummy_hash_mul ) data = data.replace("$1$fgW84Y9r$kKEn9MUq8PChNKpQhO6BM.", PASSWORD_HASH_DUMMY_VALUE) result_mntner = parse_change_requests( - data + "password: md5-password\npassword: other-password", + data + "password: md5-password\npassword: other-password\napi-key: key", 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"] + assert auth_validator.api_keys == ["key"] + auth_validator.pre_approve([result_mntner.rpsl_obj_new]) result_mntner._check_auth() assert not result_mntner.is_valid() diff --git a/irrd/updates/tests/test_validators.py b/irrd/updates/tests/test_validators.py index 94155f630..bc5535501 100644 --- a/irrd/updates/tests/test_validators.py +++ b/irrd/updates/tests/test_validators.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest +from IPy import IP from irrd.conf import AUTH_SET_CREATION_COMMON_KEY, RPSL_MNTNER_AUTH_INTERNAL from irrd.rpsl.rpsl_objects import rpsl_object_from_text @@ -23,7 +24,13 @@ from irrd.utils.text import remove_auth_hashes from irrd.vendor.mock_alchemy.mocking import UnifiedAlchemyMagicMock -from ...storage.models import AuthMntner, AuthUser +from ...storage.models import ( + AuthApiToken, + AuthMntner, + AuthoritativeChangeOrigin, + AuthUser, +) +from ...utils.factories import AuthApiTokenFactory from ..validators import AuthValidator, RulesValidator VALID_PW = "override-password" @@ -46,7 +53,7 @@ def prepare_mocks(self, monkeypatch, config_override): } ) - validator = AuthValidator(mock_dh, None) + validator = AuthValidator(mock_dh, AuthoritativeChangeOrigin.webapi) yield validator, mock_dq, mock_dh def test_override_valid(self, prepare_mocks, config_override): @@ -142,6 +149,71 @@ def test_valid_new_person(self, prepare_mocks): ["rpsl_pks", ({"TEST-MNT"},), {}], ] + 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_sa_session = UnifiedAlchemyMagicMock( + data=[ + ( + [ + mock.call.query(AuthMntner), + mock.call.filter( + AuthMntner.rpsl_mntner_pk == "TEST-MNT", AuthMntner.rpsl_mntner_source == "TEST" + ), + ], + [AuthMntner(rpsl_mntner_pk="TEST-MNT")], + ) + ] + ) + mock_api_key = AuthApiTokenFactory.build() + monkeypatch.setattr("irrd.updates.validators.saorm.Session", lambda bind: mock_sa_session) + + mock_dh._connection = None + mock_dh.execute_query = lambda q: [ + {"object_class": "mntner", "object_text": SAMPLE_MNTNER}, + ] + mock_sa_session.all = lambda: [mock_api_key] + + 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" + + mock_sa_session.filter.assert_has_calls( + [ + mock.call( + AuthMntner.rpsl_mntner_pk == "TEST-MNT", + AuthMntner.rpsl_mntner_source == "TEST", + AuthApiToken.token.in_(["key"]), + ), + ] + ) + + validator.origin = AuthoritativeChangeOrigin.webui + result = validator.process_auth(person, None) + assert not result.is_valid(), result.error_messages + + validator.origin = AuthoritativeChangeOrigin.email + result = validator.process_auth(person, None) + assert result.is_valid(), result.error_messages + + mock_api_key.enabled_email = False + validator.origin = AuthoritativeChangeOrigin.email + result = validator.process_auth(person, None) + assert not result.is_valid(), result.error_messages + + mock_api_key.ip_restriction = "192.0.2.0/26,192.0.2.64/26" + validator.remote_ip = IP("192.0.2.1") + validator.origin = AuthoritativeChangeOrigin.webapi + result = validator.process_auth(person, None) + assert result.is_valid(), result.error_messages + + validator.remote_ip = IP("192.0.2.200") + validator.origin = AuthoritativeChangeOrigin.webapi + result = validator.process_auth(person, None) + assert not result.is_valid(), result.error_messages + def test_existing_person_mntner_change(self, prepare_mocks): validator, mock_dq, mock_dh = prepare_mocks # TEST-MNT is in both maintainers diff --git a/irrd/updates/validators.py b/irrd/updates/validators.py index b093c7346..93527322a 100644 --- a/irrd/updates/validators.py +++ b/irrd/updates/validators.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union import sqlalchemy.orm as saorm +from IPy import IP from ordered_set import OrderedSet from passlib.hash import md5_crypt @@ -11,7 +12,12 @@ from irrd.rpsl.parser import RPSLObject from irrd.rpsl.rpsl_objects import RPSLMntner, RPSLSet, rpsl_object_from_text from irrd.storage.database_handler import DatabaseHandler -from irrd.storage.models import AuthMntner, AuthUser +from irrd.storage.models import ( + AuthApiToken, + AuthMntner, + AuthoritativeChangeOrigin, + AuthUser, +) from irrd.storage.queries import RPSLDatabaseQuery, RPSLDatabaseSuspendedQuery from .parser_state import RPSLSetAutnumAuthenticationMode, UpdateRequestType @@ -164,17 +170,23 @@ class AuthValidator: passwords: List[str] overrides: List[str] + api_keys: List[str] keycert_obj_pk: Optional[str] = None def __init__( self, database_handler: DatabaseHandler, + origin: AuthoritativeChangeOrigin = AuthoritativeChangeOrigin.other, keycert_obj_pk=None, internal_authenticated_user: Optional[AuthUser] = None, + remote_ip: Optional[IP] = None, ) -> None: self.database_handler = database_handler self.passwords = [] self.overrides = [] + self.api_keys = [] + self.origin = origin + self.remote_ip = remote_ip self._mntner_db_cache: Set[RPSLMntner] = set() self._pre_approved: Set[str] = set() self.keycert_obj_pk = keycert_obj_pk @@ -273,6 +285,7 @@ def process_auth( [ rpsl_obj_new.verify_auth(self.passwords, self.keycert_obj_pk), self._mntner_matches_internal_auth(rpsl_obj_new, rpsl_obj_new.pk(), source), + # API keys are not checked here, as they can never be used on RPSLMntner ] ): result.error_messages.add("Authorisation failed for the auth methods on this mntner object.") @@ -332,7 +345,8 @@ def _check_mntners( for mntner_name in mntner_pk_list: matches_internal_auth = self._mntner_matches_internal_auth(rpsl_obj_new, mntner_name, source) - if mntner_name in self._pre_approved or matches_internal_auth: + 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 for mntner_obj in mntner_objs: @@ -348,12 +362,38 @@ def _mntner_matches_internal_auth(self, rpsl_obj_new: RPSLObject, rpsl_pk: str, user_mntner_set = self._internal_authenticated_user.mntners_user_management else: user_mntner_set = self._internal_authenticated_user.mntners - return any( + 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 + + def _mntner_matches_api_key(self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source: str) -> bool: + if not self.api_keys or isinstance(rpsl_obj_new, RPSLMntner): + return False + + session = saorm.Session(bind=self.database_handler._connection) + query = ( + session.query(AuthApiToken) + .join(AuthMntner) + .filter( + AuthMntner.rpsl_mntner_pk == rpsl_pk, + AuthMntner.rpsl_mntner_source == source, + AuthApiToken.token.in_(self.api_keys), + ) + ) + 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 False def _generate_failure_message( self, diff --git a/irrd/utils/factories.py b/irrd/utils/factories.py index f8ef9ae95..fe6677ff1 100644 --- a/irrd/utils/factories.py +++ b/irrd/utils/factories.py @@ -2,6 +2,7 @@ from webauthn import base64url_to_bytes from irrd.storage.models import ( + AuthApiToken, AuthMntner, AuthPermission, AuthUser, @@ -73,3 +74,13 @@ class AuthPermissionFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta: model = AuthPermission sqlalchemy_session_persistence = "commit" + + +class AuthApiTokenFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = AuthApiToken + sqlalchemy_session_persistence = "commit" + + name = factory.Sequence(lambda n: "API token %s" % n) + enabled_webapi = True + enabled_email = True diff --git a/irrd/utils/validators.py b/irrd/utils/validators.py index c6dda8182..931759fda 100644 --- a/irrd/utils/validators.py +++ b/irrd/utils/validators.py @@ -67,6 +67,7 @@ class RPSLChangeSubmission(pydantic.main.BaseModel): objects: List[RPSLChangeSubmissionObject] passwords: List[str] = [] override: Optional[str] + api_keys: List[str] = [] delete_reason: str = "(No reason provided)" diff --git a/irrd/webui/endpoints.py b/irrd/webui/endpoints.py index bc99f5a4f..56b09fe19 100644 --- a/irrd/webui/endpoints.py +++ b/irrd/webui/endpoints.py @@ -6,7 +6,7 @@ from starlette_wtf import csrf_protect, csrf_token from irrd.conf import get_setting -from irrd.storage.models import AuthUser, RPSLDatabaseObject +from irrd.storage.models import AuthoritativeChangeOrigin, AuthUser, RPSLDatabaseObject from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager from irrd.storage.queries import RPSLDatabaseQuery from irrd.updates.handler import ChangeSubmissionHandler @@ -154,6 +154,7 @@ async def rpsl_update( def save(): return ChangeSubmissionHandler().load_text_blob( object_texts_blob=form_data["data"], + origin=AuthoritativeChangeOrigin.webui, request_meta=request_meta, internal_authenticated_user=active_user, ) diff --git a/irrd/webui/endpoints_mntners.py b/irrd/webui/endpoints_mntners.py index 8c6ea74cd..3b2a88fff 100644 --- a/irrd/webui/endpoints_mntners.py +++ b/irrd/webui/endpoints_mntners.py @@ -4,13 +4,15 @@ from typing import Optional import wtforms +from IPy import IP from starlette.requests import Request from starlette.responses import RedirectResponse, Response -from starlette_wtf import StarletteForm, csrf_protect +from starlette_wtf import StarletteForm, csrf_protect, csrf_token from irrd.conf import get_setting from irrd.rpsl.rpsl_objects import RPSLMntner from irrd.storage.models import ( + AuthApiToken, AuthMntner, AuthPermission, AuthUser, @@ -19,6 +21,7 @@ ) from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager from irrd.utils.email import send_email +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 @@ -27,6 +30,169 @@ logger = logging.getLogger(__name__) +class ApiTokenForm(StarletteForm): + name = wtforms.StringField( + "Name of the new token", + validators=[wtforms.validators.DataRequired()], + description="For your own reference.", + ) + # TODO: cidr validator + ip_restriction = wtforms.StringField( + "Restrict to IP/CIDR (optional)", + description="You can include multiple IP/CIDRs, separated by commas.", + ) + enabled_webapi = wtforms.BooleanField("Enable this token for HTTPS API submissions") + enabled_email = wtforms.BooleanField("Enable this token for email submissions") + submit = wtforms.SubmitField("Save API token") + + def validate_ip_restriction(self, field): + if not field.data: + field.data = None + return + try: + field.data = ",".join([str(IP(data)) for data in field.data.split(",")]) + except ValueError as ve: + raise wtforms.ValidationError(f"Invalid IP or CIDR ranges: {clean_ip_value_error(ve)}") + + async def validate(self, **kwargs): + if not await super().validate(): # pragma: no cover + return False + + if self.enabled_email.data and self.ip_restriction.data: + self.enabled_email.errors.append("Email submissions can not be combined with an IP restriction.") + return False + + return True + + +@csrf_protect +@session_provider_manager +@authentication_required +async def api_token_add(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Create a new API token. + """ + 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), + ) + mntner = await session_provider.run(query.one) + + if not mntner or not mntner.migration_complete: + return Response(status_code=404) + + form = await ApiTokenForm.from_formdata(request=request) + if not form.is_submitted() or not await form.validate(): + form_html = render_form(form) + return template_context_render( + "api_token_form.html", request, {"form_html": form_html, "api_token": None, "mntner": mntner} + ) + + new_token = AuthApiToken( + mntner_id=str(mntner.pk), + creator_id=str(request.auth.user.pk), + name=form.data["name"], + ip_restriction=form.data["ip_restriction"], + enabled_webapi=form.data["enabled_webapi"], + enabled_email=form.data["enabled_email"], + ) + session_provider.session.add(new_token) + message_text = f"An API key for '{new_token.name}' on {mntner.rpsl_mntner_pk} has been added." + 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}" + ) + + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + +@csrf_protect +@session_provider_manager +@authentication_required +async def api_token_edit(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Edit an existing API token + """ + query = session_provider.session.query(AuthApiToken) + query = query.filter( + AuthApiToken.pk == request.path_params["token_pk"], + AuthApiToken.mntner_id.in_([perm.mntner_id for perm in request.auth.user.permissions]), + ) + api_token = await session_provider.run(query.one) + + if not api_token: + return Response(status_code=404) + + form = await ApiTokenForm.from_formdata(request=request, obj=api_token) + if not form.is_submitted() or not await form.validate(): + form_html = render_form(form) + return template_context_render( + "api_token_form.html", + request, + {"form_html": form_html, "api_token": api_token, "mntner": api_token.mntner}, + ) + + api_token.name = form.data["name"] + api_token.ip_restriction = form.data["ip_restriction"] + api_token.enabled_webapi = form.data["enabled_webapi"] + api_token.enabled_email = form.data["enabled_email"] + session_provider.session.add(api_token) + 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}" + ) + + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + +@csrf_protect +@session_provider_manager +@authentication_required +async def api_token_delete(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Delete an API token + """ + query = session_provider.session.query(AuthApiToken) + query = query.filter( + AuthApiToken.pk == request.path_params["token_pk"], + AuthApiToken.mntner_id.in_([perm.mntner_id for perm in request.auth.user.permissions]), + ) + api_token = await session_provider.run(query.one) + + if not api_token: + return Response(status_code=404) + + if request.method == "GET": + return template_context_render( + "api_token_delete.html", + request, + { + "api_token": api_token, + "csrf_token": csrf_token(request), + }, + ) + + session_provider.session.query(AuthApiToken).filter( + AuthApiToken.pk == request.path_params["token_pk"] + ).delete() + message_text = ( + f"The API token named '{api_token.name}' on {api_token.mntner.rpsl_mntner_pk} has been deleted." + ) + 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}" + ) + + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + class PermissionAddForm(CurrentPasswordForm): def __init__(self, *args, session_provider: ORMSessionProvider, **kwargs): super().__init__(*args, **kwargs) diff --git a/irrd/webui/routes.py b/irrd/webui/routes.py index 291e58e80..6af7b5712 100644 --- a/irrd/webui/routes.py +++ b/irrd/webui/routes.py @@ -9,6 +9,9 @@ user_permissions, ) from irrd.webui.endpoints_mntners import ( + api_token_add, + api_token_delete, + api_token_edit, mntner_migrate_complete, mntner_migrate_initiate, permission_add, @@ -27,10 +30,7 @@ Route("/rpsl/update/", rpsl_update, name="rpsl_update", methods=["GET", "POST"]), Route("/rpsl/{source}/{object_class}/{rpsl_pk:path}/", rpsl_detail, name="rpsl_detail"), Route( - "/migrate-mntner/", - mntner_migrate_initiate, - name="mntner_migrate_initiate", - methods=["GET", "POST"], + "/migrate-mntner/", mntner_migrate_initiate, name="mntner_migrate_initiate", methods=["GET", "POST"] ), Route( "/migrate-mntner/complete/{pk:uuid}/{token}/", @@ -39,17 +39,20 @@ methods=["GET", "POST"], ), Route("/user/", user_permissions, name="user_permissions"), - Route( - "/permission/add/{mntner:uuid}/", - permission_add, - name="permission_add", - methods=["GET", "POST"], - ), + Route("/permission/add/{mntner:uuid}/", permission_add, name="permission_add", methods=["GET", "POST"]), Route( "/permission/delete/{permission:uuid}/", permission_delete, name="permission_delete", methods=["GET", "POST"], ), + Route("/api_token/add/{mntner:uuid}/", api_token_add, name="api_token_add", methods=["GET", "POST"]), + Route("/api_token/edit/{token_pk:uuid}/", api_token_edit, name="api_token_edit", methods=["GET", "POST"]), + Route( + "/api_token/delete/{token_pk:uuid}/", + api_token_delete, + name="api_token_delete", + methods=["GET", "POST"], + ), Mount("/auth", name="auth", routes=AUTH_ROUTES), ] diff --git a/irrd/webui/templates/api_token_delete.html b/irrd/webui/templates/api_token_delete.html new file mode 100644 index 000000000..cf5836276 --- /dev/null +++ b/irrd/webui/templates/api_token_delete.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +

Remove API token '{{ api_token.name }}' on {{ api_token.mntner.rpsl_mntner_pk }}

+
+
+ + +
+
+ +{% endblock %} diff --git a/irrd/webui/templates/api_token_form.html b/irrd/webui/templates/api_token_form.html new file mode 100644 index 000000000..751ea81d0 --- /dev/null +++ b/irrd/webui/templates/api_token_form.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} + {% if api_token %} +

Update API '{{ api_token.name }}' token on {{ mntner.rpsl_mntner_pk }}

+ {% else %} +

Add an API token on {{ mntner.rpsl_mntner_pk }}

+ {% endif %} +
+
+ {{ form_html }} +
+
+ API keys are a method to provide access to a maintainer + for automated processes or scripts. You can restrict them + to certain submission methods. After creation, IRRD + will create a random secret to use for authentication. +
+
+{% endblock %} diff --git a/irrd/webui/templates/user_permissions.html b/irrd/webui/templates/user_permissions.html index f4595d9db..034337c7b 100644 --- a/irrd/webui/templates/user_permissions.html +++ b/irrd/webui/templates/user_permissions.html @@ -53,11 +53,61 @@
All users with access to {{ permission.mntner.rpsl_mntner_pk }}
{% endfor %} +
API tokens for {{ permission.mntner.rpsl_mntner_pk }}
+ + + + + + + + + + + + + + + {% for api_token in permission.mntner.api_tokens %} + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
NameCreatorIP restrictionHTTPS API accessEmail access
{{ api_token.name }}{{ api_token.creator.name }} <{{ api_token.creator.email }}>{{ api_token.ip_restriction.__str__().replace(",", ", ") }}{{ "Yes" if api_token.enabled_webapi else "No" }}{{ "Yes" if api_token.enabled_email else "No" }} + Edit + token + + Remove + token + + +
Secret: {{ api_token.token }}
No API tokens configured.
{% if permission.user_management %} - + Add access for another user {% endif %} + + Add a new API token + {% else %} The migration for this mntner is not yet complete. {% endif %} diff --git a/irrd/webui/tests/test_endpoints_mntners.py b/irrd/webui/tests/test_endpoints_mntners.py index b5cf42b01..db7f1b009 100644 --- a/irrd/webui/tests/test_endpoints_mntners.py +++ b/irrd/webui/tests/test_endpoints_mntners.py @@ -1,14 +1,199 @@ import uuid -from irrd.storage.models import AuthPermission -from irrd.utils.factories import SAMPLE_USER_PASSWORD, AuthUserFactory +from irrd.conf import RPSL_MNTNER_AUTH_INTERNAL +from irrd.rpsl.rpsl_objects import rpsl_object_from_text +from irrd.storage.models import AuthApiToken, AuthPermission +from irrd.utils.factories import ( + SAMPLE_USER_PASSWORD, + AuthApiTokenFactory, + AuthUserFactory, +) +from irrd.utils.rpsl_samples import SAMPLE_MNTNER, SAMPLE_MNTNER_BCRYPT -from ...conf import RPSL_MNTNER_AUTH_INTERNAL -from ...rpsl.rpsl_objects import rpsl_object_from_text -from ...utils.rpsl_samples import SAMPLE_MNTNER, SAMPLE_MNTNER_BCRYPT from .conftest import WebRequestTest, create_permission +class TestApiTokenAdd(WebRequestTest): + url_template = "/ui/api_token/add/{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_form(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 "TEST-MNT" in response.text + + def test_valid_new_token(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + api_token_name = "token name" + response = test_client.post( + self.url, + data={ + "name": api_token_name, + "enabled_webapi": "1", + "ip_restriction": " 192.0.2.1 ,192.0.02.0/24", + }, + follow_redirects=False, + ) + assert response.status_code == 302 + + new_api_token = session_provider.run_sync(session_provider.session.query(AuthApiToken).one) + assert new_api_token.token + assert new_api_token.creator == user + assert new_api_token.name == api_token_name + assert new_api_token.enabled_webapi + assert new_api_token.ip_restriction == "192.0.2.1,192.0.2.0/24" + assert not new_api_token.enabled_email + assert len(smtpd.messages) == 3 + assert new_api_token.name in smtpd.messages[1].as_string() + + def test_invalid_cidr_range(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) + + api_token_name = "token name" + response = test_client.post( + self.url, + data={"name": api_token_name, "ip_restriction": "192.0.2.1.1"}, + follow_redirects=False, + ) + assert response.status_code == 200 + + new_api_token = session_provider.run_sync(session_provider.session.query(AuthApiToken).one) + assert not new_api_token + assert "Invalid IP" in response.text + + def test_invalid_ip_restriction_with_email(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) + + api_token_name = "token name" + response = test_client.post( + self.url, + data={ + "name": api_token_name, + "enabled_email": "1", + "ip_restriction": " 192.0.2.1 ,192.0.02.0/24", + }, + follow_redirects=False, + ) + assert response.status_code == 200 + + new_api_token = session_provider.run_sync(session_provider.session.query(AuthApiToken).one) + assert not new_api_token + assert "can not be combined" in response.text + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url_template.format(uuid=uuid.uuid4())) + assert response.status_code == 404 + + +class TestApiTokenEdit(WebRequestTest): + url_template = "/ui/api_token/edit/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.api_token = AuthApiTokenFactory( + mntner_id=str(self.permission.mntner.pk), creator_id=str(user.pk) + ) + self.url = self.url_template.format(uuid=self.api_token.pk) + + def test_render_form(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 "TEST-MNT" in response.text + + def test_valid_edit(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) + + api_token_name = "new name" + old_api_token_token = self.api_token.token + response = test_client.post( + self.url, + data={"name": api_token_name, "enabled_email": "1"}, + follow_redirects=False, + ) + assert response.status_code == 302 + + session_provider.session.refresh(self.api_token) + assert self.api_token.token == old_api_token_token + assert self.api_token.creator == user + assert self.api_token.name == api_token_name + assert not self.api_token.enabled_webapi + assert self.api_token.enabled_email + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url_template.format(uuid=uuid.uuid4())) + assert response.status_code == 404 + + +class TestApiTokenDelete(WebRequestTest): + url_template = "/ui/api_token/delete/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.api_token = AuthApiTokenFactory( + mntner_id=str(self.permission.mntner.pk), creator_id=str(user.pk) + ) + self.url = self.url_template.format(uuid=self.api_token.pk) + + def test_render_form(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 "TEST-MNT" in response.text + + def test_valid_delete(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + api_token_name = self.api_token.name + + response = test_client.post( + self.url, + follow_redirects=False, + ) + assert response.status_code == 302 + + deleted_api_token = session_provider.run_sync(session_provider.session.query(AuthApiToken).one) + assert deleted_api_token is None + + assert len(smtpd.messages) == 3 + assert api_token_name in smtpd.messages[1].as_string() + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url_template.format(uuid=uuid.uuid4())) + assert response.status_code == 404 + + class TestPermissionAdd(WebRequestTest): url_template = "/ui/permission/add/{uuid}/"