Skip to content

Commit

Permalink
CI and permissions
Browse files Browse the repository at this point in the history
- Made it unnecessary to have testing api creds in CI.
- Added a way to disallow certain boolean settings in prod
- Renamed some authentication stuff to be less confusing
- UI permissions endpoint and tests
  • Loading branch information
alexlambson committed Mar 1, 2024
1 parent 1574ee2 commit 49c355b
Show file tree
Hide file tree
Showing 15 changed files with 251 additions and 24 deletions.
3 changes: 1 addition & 2 deletions ci.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@ MEDIA_ROOT=/data/cnc_net_files/
STATIC_ROOT=/data/cnc_net_static/
POSTGRES_TEST_HOST=db
SECRET_KEY=";thetechnologyofpeaceforcidevwork6352722!@#$$#@"
TESTING_API_USERNAME=[email protected]
TESTING_API_PASSWORD=ripbozo2
RUN_ENVIRONMENT=ci
13 changes: 7 additions & 6 deletions kirovy/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,19 @@
from kirovy.models import CncUser


class CncNetAuthenticator:
class _CncNetAuthenticator:
"""The class for sending requests to the cncnet Ladder API.
This exists to house logic related to authenticating with CnCNet,
and to easily monkey-patch CnCNet auth in tests.
"""

@classmethod
def authenticate(
def authenticate_with_cncnet(
cls, request: HttpRequest
) -> t.Tuple[CncUser, t.Optional[objects.CncnetUserInfo]]:
"""Authenticate a request's JWT with CnCNet.
Monkeypatch this function in tests to return whichever value you need
for testing endpoint permissions.
:param request:
The request to Kirovy. We will send its header to CnCNet.
:return:
Expand Down Expand Up @@ -87,6 +84,10 @@ def authenticate(
Extracts the JWT from ``request.headers`` then forwards that to CnCNet.
If the JWT authenticates, then get, or create, the Kirovy user object for the CnCNet user.
If you don't want to deal with token headers in tests, then monkeypatch this function to return
whichever value you need for testing endpoint permissions.
:param request:
The raw request.
:return:
Expand All @@ -101,4 +102,4 @@ def authenticate(
if len(token) != 2 or token[0].lower() != "bearer":
raise exceptions.MalformedTokenError()

return CncNetAuthenticator.authenticate(request)
return _CncNetAuthenticator.authenticate_with_cncnet(request)
20 changes: 15 additions & 5 deletions kirovy/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
from kirovy.models.cnc_base_model import CncNetBaseModel
from kirovy.request import KirovyRequest

_C = t.TypeVar("_C", bound=t.Callable)

class StaticPermission(t.Protocol):

class StaticPermission(t.Protocol[_C]):
"""Static permissions are permissions that just have a ``has_permission`` method.
Object permissions have ``has_object_permission`` and are specific to an object.
"""

__call__: _C # Make callable so type checker doesn't whine about calling the classes.

def has_permission(self, request: KirovyRequest, view: View) -> bool:
...

Expand Down Expand Up @@ -99,10 +103,14 @@ class UiPermissions:
[DRF permission workflow](https://www.django-rest-framework.org/api-guide/permissions/).
"""

SHOW_STAFF_CONTROLS: t.Final[str] = "show_staff_controls"
SHOW_UPLOAD_BUTTON: t.Final[str] = "show_upload_button"
SHOW_ADMIN_CONTROLS: t.Final[str] = "show_admin_controls"

static_permissions: t.Dict[t.UiPermissionName, StaticPermission] = {
"show_staff_controls": IsStaff,
"show_upload_button": CanUpload,
"show_admin_controls": IsAdmin,
SHOW_STAFF_CONTROLS: IsStaff,
SHOW_UPLOAD_BUTTON: CanUpload,
SHOW_ADMIN_CONTROLS: IsAdmin,
}

@classmethod
Expand All @@ -116,12 +124,14 @@ def render_static(
permission checks on the views.
:param request:
The request for the API call.
:param view:
The view instance itself.
:return:
The dictionary of permission names with a bool representing if the user has that permission.
"""
ui_permissions: t.Dict[t.UiPermissionName, bool] = {}
for ui_name, permission_cls in cls.static_permissions.items():
ui_permissions[ui_name] = permission_cls.has_permission(request, view)
ui_permissions[ui_name] = permission_cls().has_permission(request, view)

return ui_permissions
15 changes: 15 additions & 0 deletions kirovy/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from rest_framework.response import Response
from kirovy import typing as t


class KirovyResponse(Response):
def __init__(
self,
data: t.Optional[t.Union[t.ListResponseData, t.ResponseData]] = None,
status: t.Optional[int] = None,
template_name: t.Optional[str] = None,
headers: t.Optional[t.DictStrAny] = None,
exception: bool = False,
content_type: t.Optional[str] = None,
):
super().__init__(data, status, template_name, headers, exception, content_type)
11 changes: 9 additions & 2 deletions kirovy/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
"""

from pathlib import Path
from kirovy.utils.settings_utils import get_env_var, secret_key_validator
from kirovy.utils.settings_utils import (
get_env_var,
secret_key_validator,
not_allowed_on_prod,
)

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
Expand All @@ -23,7 +27,7 @@
SECRET_KEY = get_env_var("SECRET_KEY", validation_callback=secret_key_validator)

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = get_env_var("DEBUG", False)
DEBUG = get_env_var("DEBUG", False, validation_callback=not_allowed_on_prod)

ALLOWED_HOSTS = []

Expand Down Expand Up @@ -147,3 +151,6 @@
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

AUTH_USER_MODEL = "kirovy.CncUser"


RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", "dev")
4 changes: 2 additions & 2 deletions kirovy/settings/testing.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from kirovy.settings.base import *

TESTING_API_USERNAME = get_env_var("TESTING_API_USERNAME")
TESTING_API_PASSWORD = get_env_var("TESTING_API_PASSWORD")
TESTING_API_USERNAME = get_env_var("TESTING_API_USERNAME", "[email protected]")
TESTING_API_PASSWORD = get_env_var("TESTING_API_PASSWORD", "ripbozo1")

DATABASES = {
"default": {
Expand Down
10 changes: 9 additions & 1 deletion kirovy/typing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ class PaginationMetadata(TypedDict):
remaining_count: NotRequired[int]


class ListResponseData(TypedDict):
class BaseResponseData(TypedDict):
message: NotRequired[str]


class ListResponseData(BaseResponseData):
results: List[DictStrAny]
pagination_metadata: NotRequired[PaginationMetadata]


class ResponseData(TypedDict):
result: DictStrAny
3 changes: 2 additions & 1 deletion kirovy/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
from django.contrib import admin
from django.urls import path

from kirovy.views import test, cnc_map_views
from kirovy.views import test, cnc_map_views, permission_views

urlpatterns = [
path("admin/", admin.site.urls),
path("test/jwt", test.TestJwt.as_view()),
path("map-categories/", cnc_map_views.MapCategoryListCreateView.as_view()),
path("ui-permissions/", permission_views.ListPermissionForAuthUser.as_view()),
]
5 changes: 5 additions & 0 deletions kirovy/utils/settings_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,8 @@ def secret_key_validator(key: str, value: str) -> NoReturn:
key,
f"EnvVar failed validation, length less than {MINIMUM_SECRET_KEY_LENGTH}",
)


def not_allowed_on_prod(key: str, value: bool) -> None:
if value and "prod" in get_env_var("RUN_ENVIRONMENT", "dev").lower():
raise exceptions.ConfigurationException(key, "Cannot be enabled on prod.")
25 changes: 25 additions & 0 deletions kirovy/views/permission_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from rest_framework import status
from rest_framework.views import APIView

from kirovy import permissions, typing as t
from kirovy.request import KirovyRequest
from kirovy.response import KirovyResponse


class ListPermissionForAuthUser(APIView):
"""End point to check which buttons / views the UI should show.
The UI showing the buttons / views will not guarantee access. The backend still checks permissions for all
requests. This just helps the UI know what to render. DO NOT use for permission checks within Kirovy.
"""

permission_classes = [permissions.ReadOnly]
http_method_names = [
"get",
]

def get(self, request: KirovyRequest, *args, **kwargs) -> KirovyResponse:
data = t.ResponseData(
result=permissions.UiPermissions.render_static(request, self)
)
return KirovyResponse(data=data, status=status.HTTP_200_OK)
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-r requirements.txt
pytest==7.*
pytest-mock==3.*
black==22.*
pre-commit==2.*
pytest-django==4.5.2
Expand Down
52 changes: 49 additions & 3 deletions tests/fixtures/common_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
import requests
import ujson
from django.contrib.auth.models import AnonymousUser
from django.test import Client
from django.conf import (
settings as _settings,
Expand Down Expand Up @@ -77,7 +78,7 @@ def __convert_data(self, data: JsonLike, content_type: str) -> str:

def set_active_user(
self,
kirovy_user: t.Optional[CncUser] = None,
kirovy_user: t.Optional[t.Union[CncUser, AnonymousUser]] = None,
cncnet_user_info: t.Optional[objects.CncnetUserInfo] = None,
) -> None:
"""Set the active user for requests.
Expand Down Expand Up @@ -184,7 +185,7 @@ def create_client(db):
"""

def _inner(
active_user: t.Optional[CncUser] = None,
active_user: t.Optional[t.Union[CncUser, AnonymousUser]] = None,
cnc_user_info: t.Optional[objects.CncnetUserInfo] = None,
) -> KirovyClient:
skip_if_no_django()
Expand Down Expand Up @@ -243,11 +244,38 @@ def _inner(


@pytest.fixture
def user(create_kirovy_user):
def user(create_kirovy_user) -> CncUser:
"""Convenience method to create a user."""
return create_kirovy_user()


@pytest.fixture
def banned_user(create_kirovy_user) -> CncUser:
"""Returns a user that is verified, but is banned."""
return create_kirovy_user(
username="MendicantBias",
verified_email=True,
verified_map_uploader=True,
cncnet_id=49,
is_banned=True,
ban_count=2,
ban_date=datetime.datetime.min,
ban_expires=datetime.datetime(2552, 12, 11),
ban_reason="Siding with the shaping sickness",
)


@pytest.fixture
def non_verified_user(create_kirovy_user) -> CncUser:
"""Returns a user that doesn't have a verified uploader badge, nor verified email."""
return create_kirovy_user(
cncnet_id=123123,
username="[email protected]",
verified_email=False,
verified_map_uploader=False,
)


@pytest.fixture
def moderator(create_kirovy_user) -> CncUser:
"""Convenience method to create a moderator."""
Expand All @@ -272,12 +300,30 @@ def god(create_kirovy_user) -> CncUser:
)


@pytest.fixture
def client_anonymous(create_client) -> KirovyClient:
"""Returns a client with a user that isn't signed in."""
return create_client(AnonymousUser())


@pytest.fixture
def client_user(user, create_client) -> KirovyClient:
"""Returns a client with an active admin user."""
return create_client(user)


@pytest.fixture
def client_not_verified(non_verified_user, create_client) -> KirovyClient:
"""Returns a client for a user that hasn't verified their email, and doesn't have a verified uploader badge."""
return create_client(non_verified_user)


@pytest.fixture
def client_banned(banned_user, create_client) -> KirovyClient:
"""Returns a client for a banned user."""
return create_client(banned_user)


@pytest.fixture
def client_moderator(moderator, create_client) -> KirovyClient:
"""Returns a client with an active moderator user."""
Expand Down
16 changes: 14 additions & 2 deletions tests/test_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
from django.conf import settings as test_settings
from rest_framework import status

needs_cncnet_auth = pytest.mark.skipif(
test_settings.RUN_ENVIRONMENT == "ci",
reason="Need to provide CnCNet credentials to call CnCNet endpoints in tests. "
"Set TESTING_API_USERNAME/PASSWORD in .env. Do NOT commit your env.",
)


@needs_cncnet_auth
def test_jwt():
"""This test exists purely so that I can look at things coming from cncnet.
Expand All @@ -20,8 +27,8 @@ def test_jwt():
response = requests.post("https://ladder.cncnet.org/api/v1/auth/login", email_pass)

assert response.status_code == 200
data = json.loads(response.content)
token = data.get("token")
login_data = json.loads(response.content)
token = login_data.get("token")

test = jwt.decode(
token,
Expand All @@ -36,10 +43,14 @@ def test_jwt():
response = requests.get(
"https://ladder.cncnet.org/api/v1/user/info", headers=header
)
info_data = json.loads(response.content)

assert response.status_code == 200
assert info_data["id"] == user_id
assert expires > datetime.datetime.now()


@needs_cncnet_auth
def test_jwt_endpoint(raw_client, jwt_header, settings):
"""Test that the JWT test endpoint works when debug is enabled."""
settings.DEBUG = True
Expand All @@ -48,6 +59,7 @@ def test_jwt_endpoint(raw_client, jwt_header, settings):
assert response.data == test_settings.TESTING_API_USERNAME


@needs_cncnet_auth
def test_jwt_endpoint__debug_disabled(raw_client, jwt_header):
response = raw_client.get("/test/jwt", headers=jwt_header)
assert response.status_code == status.HTTP_403_FORBIDDEN
Loading

0 comments on commit 49c355b

Please sign in to comment.