Skip to content

Commit

Permalink
Ref #617 - Add API token support with newauth (#792)
Browse files Browse the repository at this point in the history
  • Loading branch information
mxsasha authored May 16, 2023
1 parent b828eaa commit 2d95984
Show file tree
Hide file tree
Showing 24 changed files with 767 additions and 61 deletions.
8 changes: 8 additions & 0 deletions docs/admins/webui.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 10 additions & 3 deletions docs/users/database-changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -160,8 +164,8 @@ To delete an object, submit the current version of the object with a
[other object data]
delete: <your deletion reason>

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
Expand All @@ -170,9 +174,12 @@ in the submission, on their own or as part of objects, e.g.::
password: <password for MNT-EXAMPLE>


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.
Expand Down
4 changes: 3 additions & 1 deletion irrd/scripts/submit_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@
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
from irrd.updates.handler import ChangeSubmissionHandler


def main(data):
handler = ChangeSubmissionHandler().load_text_blob(data)
handler = ChangeSubmissionHandler().load_text_blob(data, AuthoritativeChangeOrigin.other)
print(handler.submitter_report_human())


Expand Down
2 changes: 1 addition & 1 deletion irrd/scripts/tests/test_submit_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 11 additions & 1 deletion irrd/server/http/endpoints_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions irrd/server/http/tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
49 changes: 49 additions & 0 deletions irrd/storage/alembic/versions/500027f85a55_add_api_tokens.py
Original file line number Diff line number Diff line change
@@ -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")
68 changes: 52 additions & 16 deletions irrd/storage/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion irrd/updates/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from irrd.utils import email

from ..storage.models import AuthoritativeChangeOrigin
from .handler import ChangeSubmissionHandler

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 10 additions & 3 deletions irrd/updates/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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
)
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 2d95984

Please sign in to comment.