Skip to content

Commit

Permalink
fix: basic and no authentication integrated
Browse files Browse the repository at this point in the history
  • Loading branch information
jjaakola-aiven committed Nov 19, 2024
1 parent 17c90f7 commit 1363d55
Show file tree
Hide file tree
Showing 25 changed files with 554 additions and 301 deletions.
Empty file added src/karapace/auth/__init__.py
Empty file.
104 changes: 72 additions & 32 deletions src/karapace/auth.py → src/karapace/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@
from enum import Enum, unique
from hmac import compare_digest
from karapace.config import InvalidConfiguration
from karapace.rapu import JSON_CONTENT_TYPE
from karapace.statsd import StatsClient
from karapace.utils import json_decode, json_encode
from typing import override, Protocol
from typing_extensions import TypedDict
from watchfiles import awatch, Change

import aiohttp
import aiohttp.web
import argparse
import asyncio
import base64
Expand All @@ -30,6 +28,10 @@
log = logging.getLogger(__name__)


class AuthenticationError(Exception):
pass


@unique
class Operation(Enum):
Read = "Read"
Expand Down Expand Up @@ -95,13 +97,66 @@ class AuthData(TypedDict):
permissions: list[ACLEntryData]


class ACLAuthorizer:
class AuthenticateProtocol(Protocol):
def authenticate(self, *, username: str, password: str) -> User:
...


class AuthorizeProtocol(Protocol):
def get_user(self, username: str) -> User:
...

def check_authorization(self, user: User | None, operation: Operation, resource: str) -> bool:
...

def check_authorization_any(self, user: User | None, operation: Operation, resources: list[str]) -> bool:
...


class AuthenticatorAndAuthorizer(AuthenticateProtocol, AuthorizeProtocol):
async def close(self) -> None:
...

async def start(self, stats: StatsClient) -> None:
...


class NoAuthAndAuthz(AuthenticatorAndAuthorizer):
@override
def authenticate(self, *, username: str, password: str) -> User:
return None

@override
def get_user(self, username: str) -> User:
return None

@override
def check_authorization(self, user: User | None, operation: Operation, resource: str) -> bool:
return True

@override
def check_authorization_any(self, user: User | None, operation: Operation, resources: list[str]) -> bool:
return True

@override
async def close(self) -> None:
pass

@override
async def start(self, stats: StatsClient) -> None:
pass


class ACLAuthorizer(AuthorizeProtocol):
def __init__(self, *, user_db: dict[str, User] | None = None, permissions: list[ACLEntry] | None = None) -> None:
self.user_db = user_db or {}
self.permissions = permissions or []

def get_user(self, username: str) -> User | None:
return self.user_db.get(username)
def get_user(self, username: str) -> User:
user = self.user_db.get(username)
if not user:
raise ValueError("No user found")
return user

def _check_resources(self, resources: list[str], aclentry: ACLEntry) -> bool:
for resource in resources:
Expand All @@ -115,6 +170,7 @@ def _check_operation(self, operation: Operation, aclentry: ACLEntry) -> bool:
An entry at minimum gives Read permission. Write permission implies Read."""
return operation == Operation.Read or aclentry.operation == Operation.Write

@override
def check_authorization(self, user: User | None, operation: Operation, resource: str) -> bool:
if user is None:
return False
Expand All @@ -128,6 +184,7 @@ def check_authorization(self, user: User | None, operation: Operation, resource:
return True
return False

@override
def check_authorization_any(self, user: User | None, operation: Operation, resources: list[str]) -> bool:
"""Checks that user is authorized to one of the resources in the list.
Expand All @@ -147,7 +204,7 @@ def check_authorization_any(self, user: User | None, operation: Operation, resou
return False


class HTTPAuthorizer(ACLAuthorizer):
class HTTPAuthorizer(ACLAuthorizer, AuthenticatorAndAuthorizer):
def __init__(self, filename: str) -> None:
super().__init__()
self._auth_filename: str = filename
Expand All @@ -161,7 +218,8 @@ def __init__(self, filename: str) -> None:
def authfile_last_modified(self) -> float:
return self._auth_mtime

async def start_refresh_task(self, stats: StatsClient) -> None:
@override
async def start(self, stats: StatsClient) -> None:
"""Start authfile refresher task"""

async def _refresh_authfile() -> None:
Expand All @@ -187,6 +245,7 @@ async def _refresh_authfile() -> None:

self._refresh_auth_task = asyncio.create_task(_refresh_authfile())

@override
async def close(self) -> None:
if self._refresh_auth_task is not None:
self._refresh_auth_awatch_stop_event.set()
Expand Down Expand Up @@ -226,30 +285,11 @@ def _load_authfile(self) -> None:
except Exception as ex:
raise InvalidConfiguration("Failed to load auth file") from ex

def authenticate(self, request: aiohttp.web.Request) -> User:
auth_header = request.headers.get("Authorization")
if auth_header is None:
raise aiohttp.web.HTTPUnauthorized(
headers={"WWW-Authenticate": 'Basic realm="Karapace Schema Registry"'},
text='{"message": "Unauthorized"}',
content_type=JSON_CONTENT_TYPE,
)
try:
auth = aiohttp.BasicAuth.decode(auth_header)
except ValueError:
# pylint: disable=raise-missing-from
raise aiohttp.web.HTTPUnauthorized(
headers={"WWW-Authenticate": 'Basic realm="Karapace Schema Registry"'},
text='{"message": "Unauthorized"}',
content_type=JSON_CONTENT_TYPE,
)
user = self.get_user(auth.login)
if user is None or not user.compare_password(auth.password):
raise aiohttp.web.HTTPUnauthorized(
headers={"WWW-Authenticate": 'Basic realm="Karapace Schema Registry"'},
text='{"message": "Unauthorized"}',
content_type=JSON_CONTENT_TYPE,
)
@override
def authenticate(self, *, username: str, password: str) -> User:
user = self.get_user(username)
if user is None or not user.compare_password(password):
raise AuthenticationError()

return user

Expand Down
77 changes: 77 additions & 0 deletions src/karapace/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Copyright (c) 2024 Aiven Ltd
See LICENSE for details
"""

from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.security.base import SecurityBase
from karapace.auth.auth import AuthenticationError, AuthenticatorAndAuthorizer, HTTPAuthorizer, NoAuthAndAuthz, User
from karapace.dependencies.config_dependency import ConfigDependencyManager
from typing import Annotated, Optional

import logging

LOG = logging.getLogger(__name__)


class AuthorizationDependencyManager:
AUTHORIZER: AuthenticatorAndAuthorizer | None = None
AUTH_SET: bool = False
SECURITY: SecurityBase | None = None

@classmethod
def get_authorizer(cls) -> AuthenticatorAndAuthorizer:
if AuthorizationDependencyManager.AUTH_SET:
assert AuthorizationDependencyManager.AUTHORIZER
return AuthorizationDependencyManager.AUTHORIZER

config = ConfigDependencyManager.get_config()
if config.registry_authfile:
AuthorizationDependencyManager.AUTHORIZER = HTTPAuthorizer(config.registry_authfile)
else:
# TODO: remove the need for empty authorization logic.
AuthorizationDependencyManager.AUTHORIZER = NoAuthAndAuthz()
AuthorizationDependencyManager.AUTH_SET = True
return AuthorizationDependencyManager.AUTHORIZER


AuthenticatorAndAuthorizerDep = Annotated[AuthenticatorAndAuthorizer, Depends(AuthorizationDependencyManager.get_authorizer)]

# TODO Karapace can have authentication/authorization enabled or disabled. This code needs cleanup and better
# injection mechanism, this is fast workaround for optional user authentication and authorization.
SECURITY: SecurityBase | None = None
config = ConfigDependencyManager.get_config()
if config.registry_authfile:
SECURITY = HTTPBasic(auto_error=False)

def get_current_user(
credentials: Annotated[Optional[HTTPBasicCredentials], Security(SECURITY)],
authorizer: AuthenticatorAndAuthorizerDep,
) -> User:
if authorizer and not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Unauthorized"},
headers={"WWW-Authenticate": 'Basic realm="Karapace Schema Registry"'},
)
assert authorizer is not None
assert credentials is not None
username: str = credentials.username
password: str = credentials.password
try:
return authorizer.authenticate(username=username, password=password)
except AuthenticationError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Unauthorized"},
headers={"WWW-Authenticate": 'Basic realm="Karapace Schema Registry"'},
)

else:

def get_current_user() -> None:
return None


CurrentUserDep = Annotated[Optional[User], Depends(get_current_user)]
60 changes: 0 additions & 60 deletions src/karapace/dependencies.py

This file was deleted.

23 changes: 23 additions & 0 deletions src/karapace/dependencies/config_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Copyright (c) 2024 Aiven Ltd
See LICENSE for details
"""

from fastapi import Depends
from karapace.config import Config
from typing import Annotated

import os

env_file = os.environ.get("KARAPACE_DOTENV", None)


class ConfigDependencyManager:
CONFIG = Config(_env_file=env_file, _env_file_encoding="utf-8")

@classmethod
def get_config(cls) -> Config:
return ConfigDependencyManager.CONFIG


ConfigDep = Annotated[Config, Depends(ConfigDependencyManager.get_config)]
23 changes: 23 additions & 0 deletions src/karapace/dependencies/controller_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Copyright (c) 2024 Aiven Ltd
See LICENSE for details
"""


from fastapi import Depends
from karapace.dependencies.config_dependency import ConfigDep
from karapace.dependencies.schema_registry_dependency import SchemaRegistryDep
from karapace.dependencies.stats_dependeny import StatsDep
from karapace.schema_registry_apis import KarapaceSchemaRegistryController
from typing import Annotated


async def get_controller(
config: ConfigDep,
stats: StatsDep,
schema_registry: SchemaRegistryDep,
) -> KarapaceSchemaRegistryController:
return KarapaceSchemaRegistryController(config=config, schema_registry=schema_registry, stats=stats)


KarapaceSchemaRegistryControllerDep = Annotated[KarapaceSchemaRegistryController, Depends(get_controller)]
20 changes: 20 additions & 0 deletions src/karapace/dependencies/forward_client_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Copyright (c) 2024 Aiven Ltd
See LICENSE for details
"""

from fastapi import Depends
from karapace.forward_client import ForwardClient
from typing import Annotated

FORWARD_CLIENT: ForwardClient | None = None


def get_forward_client() -> ForwardClient:
global FORWARD_CLIENT
if not FORWARD_CLIENT:
FORWARD_CLIENT = ForwardClient()
return FORWARD_CLIENT


ForwardClientDep = Annotated[ForwardClient, Depends(get_forward_client)]
Loading

0 comments on commit 1363d55

Please sign in to comment.