Skip to content

Commit

Permalink
Add rough implementation of autnum_authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
mxsasha committed Nov 10, 2021
1 parent 1660b40 commit 2f1b467
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 20 deletions.
8 changes: 5 additions & 3 deletions irrd/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
PASSWORD_HASH_DUMMY_VALUE = 'DummyValue'
SOURCE_NAME_RE = re.compile('^[A-Z][A-Z0-9-]*[A-Z0-9]$')
RPKI_IRR_PSEUDO_SOURCE = 'RPKI'
VALID_SET_AUTNUM_AUTHENTICATION = ['disabled', 'opportunistic', 'required']


LOGGING = {
'version': 1,
Expand Down Expand Up @@ -265,11 +265,13 @@ def _validate_subconfig(key, value):
if not self._check_is_str(config, 'auth.gnupg_keyring'):
errors.append('Setting auth.gnupg_keyring is required.')

from irrd.updates.parser_state import RPSLSetAutnumAuthenticationMode
for set_name, params in config.get('auth.set_creation', {}).items():
if not isinstance(params.get('prefix_required', False), bool):
errors.append(f'Setting auth.set_creation.{set_name}.prefix_required must be a bool')
if params.get('autnum_authentication') and params['autnum_authentication'].lower() not in VALID_SET_AUTNUM_AUTHENTICATION:
errors.append(f'Setting auth.set_creation.{set_name}.autnum_authentication must be one of {VALID_SET_AUTNUM_AUTHENTICATION} if set')
valid_auth = [mode.value for mode in RPSLSetAutnumAuthenticationMode]
if params.get('autnum_authentication') and params['autnum_authentication'].lower() not in valid_auth:
errors.append(f'Setting auth.set_creation.{set_name}.autnum_authentication must be one of {valid_auth} if set')

for name, access_list in config.get('access_lists', {}).items():
for item in access_list:
Expand Down
1 change: 1 addition & 0 deletions irrd/rpsl/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class RPSLObject(metaclass=RPSLObjectMeta):
prefix_length: Optional[int] = None
rpki_status: RPKIStatus = RPKIStatus.not_found
scopefilter_status: ScopeFilterStatus = ScopeFilterStatus.in_scope
pk_first_segment: Optional[str] = None
default_source: Optional[str] = None # noqa: E704 (flake8 bug)
# Whether this object has a relation to RPKI ROA data, and therefore RPKI
# checks should be performed in certain scenarios. Enabled for route/route6.
Expand Down
20 changes: 11 additions & 9 deletions irrd/rpsl/rpsl_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,21 @@ def rpsl_object_from_text(text, strict_validation=True, default_source: Optional

class RPSLSet(RPSLObject):
def clean_for_create(self) -> bool:
if get_setting(f'auth.set_creation.{self.rpsl_object_class}.prefix_required') is False:
return True
if get_setting('auth.set_creation.DEFAULT.prefix_required') is False:
return True

first_segment = self.pk().split(':')[0]
# TODO: validate this attribute in the tests
self.pk_first_segment = self.pk().split(':')[0]
try:
parse_as_number(first_segment)
parse_as_number(self.pk_first_segment)
return True
except ValidationError as ve:
self.pk_first_segment = None
if get_setting(f'auth.set_creation.{self.rpsl_object_class}.prefix_required') is False:
return True
if get_setting('auth.set_creation.DEFAULT.prefix_required') is False:
return True
self.messages.error(f'{self.rpsl_object_class} names must be hierarchical and the first '
f'component must be an AS number, e.g. "AS65537:{first_segment}": {str(ve)}')
return False
f'component must be an AS number, e.g. "AS65537:{self.pk_first_segment}": {str(ve)}')

return False


class RPSLAsBlock(RPSLObject):
Expand Down
18 changes: 18 additions & 0 deletions irrd/updates/parser_state.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from enum import unique, Enum

from irrd.conf import get_setting


@unique
class UpdateRequestType(Enum):
Expand All @@ -19,3 +21,19 @@ class UpdateRequestStatus(Enum):
ERROR_ROA = 'error: conflict with existing ROA'
ERROR_SCOPEFILTER = 'error: not in scope'
ERROR_NON_AUTHORITIVE = 'error: attempt to update object in non-authoritive database'


@unique
class RPSLSetAutnumAuthenticationMode(Enum):
DISABLED = 'disabled'
OPPORTUNISTIC = 'opportunistic'
REQUIRED = 'required'

@staticmethod
def for_set_name(set_name: str):
setting = get_setting(f'auth.set_creation.{set_name}.autnum_authentication')
if not setting:
setting = get_setting('auth.set_creation.DEFAULT.autnum_authentication')
if not setting:
return RPSLSetAutnumAuthenticationMode.DISABLED
return getattr(RPSLSetAutnumAuthenticationMode, setting.upper())
146 changes: 143 additions & 3 deletions irrd/updates/tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pytest import raises

from irrd.rpsl.rpsl_objects import rpsl_object_from_text
from irrd.utils.rpsl_samples import (SAMPLE_MNTNER, SAMPLE_MNTNER_CRYPT,
from irrd.utils.rpsl_samples import (SAMPLE_AS_SET, SAMPLE_FILTER_SET, SAMPLE_MNTNER, SAMPLE_MNTNER_CRYPT,
SAMPLE_MNTNER_MD5, SAMPLE_PERSON,
SAMPLE_ROUTE, SAMPLE_ROUTE6)
from irrd.utils.test_utils import flatten_mock_calls
Expand Down Expand Up @@ -146,7 +146,6 @@ def test_existing_person_mntner_change(self, prepare_mocks):
validator.passwords = [SAMPLE_MNTNER_MD5]
result = validator.process_auth(person_new, person_old)
assert not result.is_valid()
print(result.error_messages)
assert result.error_messages == {'Authorisation for person PERSON-TEST failed: '
'must be authenticated by one of: TEST-MNT, TEST-NEW-MNT'}

Expand Down Expand Up @@ -244,7 +243,7 @@ def test_modify_mntner(self, prepare_mocks, config_override):
}


class TestAuthValidatorRelatedObjects:
class TestAuthValidatorRelatedRouteObjects:
def test_related_route_exact_inetnum(self, prepare_mocks, config_override):
validator, mock_dq, mock_dh = prepare_mocks
route = rpsl_object_from_text(SAMPLE_ROUTE)
Expand Down Expand Up @@ -413,6 +412,7 @@ def test_related_route_no_match_v6(self, prepare_mocks, config_override):
result = validator.process_auth(route, None)
assert result.is_valid()

# TODO: this grouping is confusing re sources
assert flatten_mock_calls(mock_dq, flatten_objects=True) == [
['sources', (['TEST'],), {}],
['object_classes', (['mntner'],), {}],
Expand All @@ -433,3 +433,143 @@ def test_related_route_no_match_v6(self, prepare_mocks, config_override):
['first_only', (), {}],
['ip_less_specific_one_level', ('2001:db8::/48',), {}],
]


class TestAuthValidatorRelatedAutNumObjects:
def test_as_set_autnum_disabled(self, prepare_mocks, config_override):
config_override({'auth': {'set_creation': {'as-set': {'autnum_authentication': 'disabled'}}}})
validator, mock_dq, mock_dh = prepare_mocks
as_set = rpsl_object_from_text(SAMPLE_AS_SET)
assert as_set.clean_for_create() # fill pk_first_segment
mock_dh.execute_query = lambda q: [
{'object_text': SAMPLE_MNTNER.replace('MD5', '')}, # mntner for object
]

validator.passwords = [SAMPLE_MNTNER_MD5, SAMPLE_MNTNER_CRYPT]
result = validator.process_auth(as_set, None)
assert result.is_valid()
assert flatten_mock_calls(mock_dq, flatten_objects=True) == [
['sources', (['TEST'],), {}],
['object_classes', (['mntner'],), {}],
['rpsl_pks', ({'TEST-MNT'},), {}],
]

def test_as_set_autnum_opportunistic_exists(self, prepare_mocks, config_override):
config_override({'auth': {'set_creation': {'as-set': {'autnum_authentication': 'opportunistic'}}}})
validator, mock_dq, mock_dh = prepare_mocks
as_set = rpsl_object_from_text(SAMPLE_AS_SET)
assert as_set.clean_for_create() # fill pk_first_segment
query_results = itertools.cycle([
[{'object_text': SAMPLE_MNTNER.replace('MD5', '')}], # mntner for object
[{
# attempt to look for matching aut-num
'object_class': 'aut-num',
'rpsl_pk': 'AS655375',
'parsed_data': {'mnt-by': ['RELATED-MNT']}
}],
[{'object_text': SAMPLE_MNTNER.replace('CRYPT', '')}], # related mntner retrieval
])
mock_dh.execute_query = lambda q: next(query_results)

validator.passwords = [SAMPLE_MNTNER_MD5, SAMPLE_MNTNER_CRYPT]
result = validator.process_auth(as_set, None)
assert result.is_valid()
assert flatten_mock_calls(mock_dq, flatten_objects=True) == [
['sources', (['TEST'],), {}],
['object_classes', (['mntner'],), {}],
['rpsl_pks', ({'TEST-MNT'},), {}],

['sources', (['TEST'],), {}],
['object_classes', (['aut-num'],), {}],
['first_only', (), {}],
['rpsl_pk', ('AS65537',), {}],

['sources', (['TEST'],), {}],
['object_classes', (['mntner'],), {}],
['rpsl_pks', ({'RELATED-MNT'},), {}]
]

validator = AuthValidator(mock_dh, None)
validator.passwords = [SAMPLE_MNTNER_CRYPT] # related only has MD5, so this is invalid
result = validator.process_auth(as_set, None)
assert not result.is_valid()
assert result.error_messages == {
'Authorisation for as-set AS65537:AS-SETTEST failed: must be authenticated by one of: '
'RELATED-MNT - from parent aut-num AS655375'
}

result = validator.process_auth(as_set, as_set)
assert result.is_valid()

config_override({'auth': {'set_creation': {'as-set': {'autnum_authentication': 'disabled'}}}})
result = validator.process_auth(as_set, None)
assert result.is_valid()

# Default is disabled
config_override({})
result = validator.process_auth(as_set, None)
assert result.is_valid()

def test_as_set_autnum_opportunistic_does_not_exist(self, prepare_mocks, config_override):
config_override({'auth': {'set_creation': {'DEFAULT': {'autnum_authentication': 'opportunistic'}}}})
validator, mock_dq, mock_dh = prepare_mocks
as_set = rpsl_object_from_text(SAMPLE_AS_SET)
assert as_set.clean_for_create() # fill pk_first_segment
query_results = itertools.cycle([
[{'object_text': SAMPLE_MNTNER.replace('MD5', '')}], # mntner for object
[], # attempt to look for matching aut-num
])
mock_dh.execute_query = lambda q: next(query_results)

validator.passwords = [SAMPLE_MNTNER_MD5, SAMPLE_MNTNER_CRYPT]
result = validator.process_auth(as_set, None)
assert result.is_valid()
assert flatten_mock_calls(mock_dq, flatten_objects=True) == [
['sources', (['TEST'],), {}],
['object_classes', (['mntner'],), {}],
['rpsl_pks', ({'TEST-MNT'},), {}],

['sources', (['TEST'],), {}],
['object_classes', (['aut-num'],), {}],
['first_only', (), {}],
['rpsl_pk', ('AS65537',), {}],
]

def test_as_set_autnum_required_does_not_exist(self, prepare_mocks, config_override):
config_override({'auth': {'set_creation': {'DEFAULT': {'autnum_authentication': 'required'}}}})
validator, mock_dq, mock_dh = prepare_mocks
as_set = rpsl_object_from_text(SAMPLE_AS_SET)
assert as_set.clean_for_create() # fill pk_first_segment
query_results = itertools.cycle([
[{'object_text': SAMPLE_MNTNER.replace('MD5', '')}], # mntner for object
[], # attempt to look for matching aut-num
])
mock_dh.execute_query = lambda q: next(query_results)

validator.passwords = [SAMPLE_MNTNER_MD5, SAMPLE_MNTNER_CRYPT]
result = validator.process_auth(as_set, None)
assert not result.is_valid()
assert result.error_messages == {
'Creating this object requires an aut-num for AS65537 to exist.',
}

def test_filter_set_autnum_required_no_prefix(self, prepare_mocks, config_override):
config_override({'auth': {'set_creation': {'DEFAULT': {
'autnum_authentication': 'required',
'prefix_required': False,
}}}})
validator, mock_dq, mock_dh = prepare_mocks
filter_set = rpsl_object_from_text(SAMPLE_FILTER_SET)
assert filter_set.clean_for_create()
mock_dh.execute_query = lambda q: [
{'object_text': SAMPLE_MNTNER.replace('MD5', '')}, # mntner for object
]

validator.passwords = [SAMPLE_MNTNER_MD5, SAMPLE_MNTNER_CRYPT]
result = validator.process_auth(filter_set, None)
assert result.is_valid()
assert flatten_mock_calls(mock_dq, flatten_objects=True) == [
['sources', (['TEST'],), {}],
['object_classes', (['mntner'],), {}],
['rpsl_pks', ({'TEST-MNT'},), {}],
]
44 changes: 39 additions & 5 deletions irrd/updates/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

from irrd.conf import get_setting
from irrd.rpsl.parser import RPSLObject
from irrd.rpsl.rpsl_objects import RPSLMntner, rpsl_object_from_text
from irrd.rpsl.rpsl_objects import RPSLMntner, rpsl_object_from_text, RPSLSet
from irrd.storage.database_handler import DatabaseHandler
from irrd.storage.queries import RPSLDatabaseQuery
from .parser_state import UpdateRequestType
from .parser_state import RPSLSetAutnumAuthenticationMode, UpdateRequestType

if TYPE_CHECKING: # pragma: no cover
# http://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles
Expand Down Expand Up @@ -43,6 +43,7 @@ class ReferenceValidator:
in the same update message. To handle this, the validator can be preloaded
with objects that should be considered valid.
"""

def __init__(self, database_handler: DatabaseHandler) -> None:
self.database_handler = database_handler
self._cache: Set[Tuple[str, str, str]] = set()
Expand Down Expand Up @@ -212,7 +213,7 @@ def process_auth(self, rpsl_obj_new: RPSLObject, rpsl_obj_current: Optional[RPSL
else:
result.mntners_notify = mntner_objs_new
if get_setting('auth.authenticate_related_mntners'):
mntners_related = self._find_related_mntners(rpsl_obj_new)
mntners_related = self._find_related_mntners(rpsl_obj_new, result)
if mntners_related:
related_object_class, related_pk, related_mntner_list = mntners_related
logger.debug(f'Checking auth for related object {related_object_class} / '
Expand Down Expand Up @@ -289,8 +290,7 @@ def _generate_failure_message(self, result: ValidatorResult, failed_mntner_list:
msg += f' - from parent {related_object_class} {related_pk}'
result.error_messages.add(msg)

@functools.lru_cache(maxsize=50)
def _find_related_mntners(self, rpsl_obj_new: RPSLObject) -> Optional[Tuple[str, str, List[str]]]:
def _find_related_mntners(self, rpsl_obj_new: RPSLObject, result: ValidatorResult) -> Optional[Tuple[str, str, List[str]]]:
"""
Find the maintainers of the related object to rpsl_obj_new, if any.
This is used to authorise creating objects - authentication may be
Expand All @@ -301,17 +301,22 @@ def _find_related_mntners(self, rpsl_obj_new: RPSLObject) -> Optional[Tuple[str,
- PK of the related object
- List of maintainers for the related object (at least one must pass)
Returns None of no related objects were found that should be authenticated.
Custom error messages may be added directly to the passed ValidatorResult.
"""
related_object = None
if rpsl_obj_new.rpsl_object_class in ['route', 'route6']:
related_object = self._find_related_object_route(rpsl_obj_new)
if issubclass(rpsl_obj_new.__class__, RPSLSet):
related_object = self._find_related_object_set(rpsl_obj_new, result)

if related_object:
mntners = related_object.get('parsed_data', {}).get('mnt-by', [])
return related_object['object_class'], related_object['rpsl_pk'], mntners

return None

@functools.lru_cache(maxsize=50)
def _find_related_object_route(self, rpsl_obj_new: RPSLObject):
"""
Find the related inetnum/route object to rpsl_obj_new, which must be a route(6).
Expand Down Expand Up @@ -341,6 +346,35 @@ def _find_related_object_route(self, rpsl_obj_new: RPSLObject):

return None

def _find_related_object_set(self, rpsl_obj_new: RPSLObject, result: ValidatorResult):
"""
Find the related aut-num object to rpsl_obj_new, which must be a set object,
depending on settings.
Returns a dict as returned by the database handler.
"""
@functools.lru_cache(maxsize=50)
def _find_in_db():
query = _init_related_object_query('aut-num', rpsl_obj_new).rpsl_pk(rpsl_obj_new.pk_first_segment)
aut_nums = list(self.database_handler.execute_query(query))
if aut_nums:
return aut_nums[0]

if not rpsl_obj_new.pk_first_segment:
return None

mode = RPSLSetAutnumAuthenticationMode.for_set_name(rpsl_obj_new.rpsl_object_class)
if mode == RPSLSetAutnumAuthenticationMode.DISABLED:
return None

aut_num = _find_in_db()
if aut_num:
return aut_num
elif mode == RPSLSetAutnumAuthenticationMode.REQUIRED:
result.error_messages.add(
f'Creating this object requires an aut-num for {rpsl_obj_new.pk_first_segment} to exist.'
)
return None


def _init_related_object_query(rpsl_object_class: str, rpsl_obj_new: RPSLObject) -> RPSLDatabaseQuery:
query = RPSLDatabaseQuery().sources([rpsl_obj_new.source()])
Expand Down

0 comments on commit 2f1b467

Please sign in to comment.