Skip to content

Commit

Permalink
implemented api keys
Browse files Browse the repository at this point in the history
  • Loading branch information
ansibleguy committed Jan 20, 2024
1 parent 758b2ba commit 02250a9
Show file tree
Hide file tree
Showing 29 changed files with 459 additions and 263 deletions.
2 changes: 2 additions & 0 deletions docs/source/_include/warn_develop.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. warning::
This project still in early development! **DO NOT USE IN PRODUCTION!**
2 changes: 2 additions & 0 deletions docs/source/usage/1_install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

.. include:: ../_include/head.rst

.. include:: ../_include/warn_develop.rst

================
1 - Installation
================
Expand Down
2 changes: 2 additions & 0 deletions docs/source/usage/2_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

.. include:: ../_include/head.rst

.. include:: ../_include/warn_develop.rst

==========
2 - Config
==========
Expand Down
28 changes: 28 additions & 0 deletions docs/source/usage/api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.. _usage_api:

.. include:: ../_include/head.rst

.. include:: ../_include/warn_develop.rst

===
API
===

To use the API you have to create an API key: `ui/settings/api_keys <http://localhost:8000/ui/settings/api_keys>`_

You can see the available API-endpoints in the built-in API-docs: `ui/api_docs <http://localhost:8000/ui/api_docs>`_ (*swagger*)

Requests must have the API key set in the :code:`X-Api-Key` header.

Examples
********

.. code-block:: bash
# add another api key
curl -X 'POST' 'http://localhost:8000/api/key' -H 'accept: application/json' -H "X-Api-Key: <KEY>"
> {"token":"ansible-2024-01-20-16-50-51","key":"r6yTsF9G.9qOt8ivfvFbpIkBh228tgAZjaNtnmDpw"}
# list own api keys
curl -X 'GET' 'http://localhost:8000/api/key' -H 'accept: application/json' -H "X-Api-Key: <KEY>"
> {"tokens":["ansible-2024-01-20-16-50-51","ansible-2024-01-20-16-10-42"]}
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pyyaml
ua-parser
user-agents
django-user-agents
django-auto-logout

## api
djangorestframework==3.*
Expand Down
3 changes: 2 additions & 1 deletion src/ansible-webui/aw/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django.contrib import admin

from aw.models import Job, JobExecution, JobPermission, JobPermissionMemberUser, JobPermissionMemberGroup
from aw.models import Job, JobExecution, JobPermission, JobPermissionMemberUser, JobPermissionMemberGroup, AwAPIKey

admin.site.register(Job)
admin.site.register(JobExecution)
admin.site.register(JobPermission)
admin.site.register(JobPermissionMemberUser)
admin.site.register(JobPermissionMemberGroup)
admin.site.register(AwAPIKey)
80 changes: 5 additions & 75 deletions src/ansible-webui/aw/api.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,11 @@
from time import time
from typing import Callable

from django.core.exceptions import ObjectDoesNotExist
from django.urls import path
from rest_framework.views import APIView
from rest_framework.response import Response
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, extend_schema

from aw.model.api import APIKey
from aw.utils.http import deny_request


def endpoint_wrapper(func) -> Callable:
def wrapper(request, *args, **kwargs):
bad, deny = deny_request(request)
if bad:
return deny

return func(request, *args, **kwargs)

return wrapper


class KeyRead(APIView):
http_method_names = ['get']

@staticmethod
@endpoint_wrapper
@extend_schema(
summary='Return a list of all existing API keys of the current user.',
parameters=None,
)
def get(request):
return Response({'tokens': [key.name for key in APIKey.objects.filter(user=request.user)]})


class KeyWrite(APIView):
http_method_names = ['post', 'delete']

@staticmethod
@endpoint_wrapper
@extend_schema(
summary='Create a new API key.',
)
def post(request):
token, secret = APIKey.objects.create_key(
name=f'{request.user}-{time()}',
user=request.user,
)
return Response({'token': token, 'secret': secret})

@staticmethod
@endpoint_wrapper
@extend_schema(
summary='Delete one of the existing API keys of the current user.',
)
def delete(request):
req_key = request.query_params.get('token', None)

if req_key is None:
return Response({'msg': 'No API key provided'})

try:
results = APIKey.objects.filter(user=request.user, name=req_key)
if len(results) == 1:
results.delete()
return Response({'msg': f"API key '{req_key}' revoked"})

except ObjectDoesNotExist:
pass

return Response(data={'msg': f"API key '{req_key}' not found"}, status=404)
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

from aw.api_endpoints.key import KeyReadWrite, KeyDelete

urlpatterns_api = [
path('api/key', KeyRead.as_view()),
path('api/key/<str:token>', KeyWrite.as_view()),
path('api/key', KeyReadWrite.as_view()),
path('api/key/<str:token>', KeyDelete.as_view()),
path('api/_schema/', SpectacularAPIView.as_view(), name='_schema'),
path('api', SpectacularSwaggerView.as_view(url_name='_schema'), name='swagger-ui'),
path('api/_docs', SpectacularSwaggerView.as_view(url_name='_schema'), name='swagger-ui'),
]
Empty file.
29 changes: 29 additions & 0 deletions src/ansible-webui/aw/api_endpoints/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ObjectDoesNotExist
from rest_framework.permissions import IsAuthenticated
from rest_framework_api_key.permissions import BaseHasAPIKey

from aw.model.api import AwAPIKey


class HasAwAPIKey(BaseHasAPIKey):
model = AwAPIKey


API_PERMISSION = [IsAuthenticated | HasAwAPIKey]


# see: rest_framework_api_key.permissions.BaseHasAPIKey:get_from_header
def get_api_user(request) -> settings.AUTH_USER_MODEL:
if isinstance(request.user, AnonymousUser):
try:
return AwAPIKey.objects.get_from_key(
request.META.get(getattr(settings, 'API_KEY_CUSTOM_HEADER'))
).user

except ObjectDoesNotExist:
# invalid api key
pass

return request.user
92 changes: 92 additions & 0 deletions src/ansible-webui/aw/api_endpoints/key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework.views import APIView
from rest_framework import serializers
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiResponse

from aw.utils.util import datetime_w_tz
from aw.config.hardcoded import KEY_TIME_FORMAT
from aw.model.api import AwAPIKey
from aw.api_endpoints.base import API_PERMISSION, get_api_user


# todo: allow user to add comment to easier identify token
class KeyReadResponse(serializers.Serializer):
tokens = serializers.ListSerializer(child=serializers.CharField())


class KeyWriteResponse(serializers.Serializer):
token = serializers.CharField()
secret = serializers.CharField()


class KeyReadWrite(APIView):
http_method_names = ['post', 'get']
serializer_class = KeyReadResponse
permission_classes = API_PERMISSION

@staticmethod
@extend_schema(
request=None,
responses={200: KeyReadResponse},
summary='Return a list of all existing API keys of the current user.',
)
def get(request):
return Response({'tokens': [key.name for key in AwAPIKey.objects.filter(user=get_api_user(request))]})

@extend_schema(
request=None,
responses={200: OpenApiResponse(KeyWriteResponse, description='Returns generated API token & key')},
summary='Create a new API key.',
)
def post(self, request):
self.serializer_class = KeyWriteResponse
user = get_api_user(request)
token = f'{user}-{datetime_w_tz().strftime(KEY_TIME_FORMAT)}'
_, key = AwAPIKey.objects.create_key(
name=token,
user=user,
)
return Response({'token': token, 'key': key})


class KeyDeleteResponse(serializers.Serializer):
msg = serializers.CharField()


class KeyDelete(APIView):
http_method_names = ['post', 'delete']
serializer_class = KeyDeleteResponse
permission_classes = API_PERMISSION
_schema_responses = {
200: OpenApiResponse(response=KeyDeleteResponse, description='API key deleted'),
404: OpenApiResponse(response=KeyDeleteResponse, description='API key does not exist'),
}
_schema_summary = 'Delete one of the existing API keys of the current user.'

@extend_schema(
request=None,
responses=_schema_responses,
summary=_schema_summary,
)
def delete(self, request, token: str):
try:
results = AwAPIKey.objects.filter(user=get_api_user(request), name=token)

if len(results) == 1:
results.delete()
return Response({'msg': f"API key '{token}' deleted"})

except ObjectDoesNotExist:
pass

return Response(data={'msg': f"API key '{token}' not found"}, status=404)

@extend_schema(
request=None,
responses=_schema_responses,
summary=_schema_summary,
operation_id='del',
)
def post(self, request, token: str):
return self.delete(request, token)
2 changes: 2 additions & 0 deletions src/ansible-webui/aw/config/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
'run_isolate_process_path_ro': ['AW_RUN_ISOLATE_PS_PATH_RO'],
'ansible_config': ['ANSIBLE_CONFIG'],
'path_log': ['AW_PATH_LOG'],
'session_timeout': ['AW_SESSION_TIMEOUT'],
}

# todo: move typing to config-init
Expand Down Expand Up @@ -62,6 +63,7 @@ def _get_existing_ansible_config_file() -> (str, None):
'timezone': datetime.now().astimezone().tzname(),
'_secret': ''.join(random_choice(ascii_letters + digits + punctuation) for _ in range(50)),
'ansible_config': _get_existing_ansible_config_file(),
'session_timeout': 12 * 60 * 60, # 12h
}


Expand Down
1 change: 1 addition & 0 deletions src/ansible-webui/aw/config/hardcoded.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
LOG_TIME_FORMAT = '%Y-%m-%d %H:%M:%S %z'
SHORT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
FILE_TIME_FORMAT = '%Y-%m-%d_%H-%M-%S'
KEY_TIME_FORMAT = '%Y-%m-%d-%H-%M-%S'
10 changes: 6 additions & 4 deletions src/ansible-webui/aw/config/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
'Manage': '/ui/job/manage/',
'Logs': '/ui/job/log/',
},
'Manage': {
'Settings': '/ui/settings/',
'System': '/ui/mgmt/',
'API Docs': '/api',
'Settings': {
'API Keys': '/ui/settings/api_keys',
},
'System': {
'Admin': '/ui/admin/',
'API Docs': '/ui/api_docs',
},
},
'right': {
Expand Down
4 changes: 2 additions & 2 deletions src/ansible-webui/aw/model/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
from rest_framework_api_key.models import AbstractAPIKey


class APIKey(AbstractAPIKey):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class AwAPIKey(AbstractAPIKey):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, editable=False)
1 change: 1 addition & 0 deletions src/ansible-webui/aw/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# pylint: disable=W0611
from aw.model.job import JobError, JobExecution, Job, JobPermission, JobPermissionMemberGroup, \
JobPermissionMemberUser, JobExecutionResult
from aw.model.api import AwAPIKey
65 changes: 0 additions & 65 deletions src/ansible-webui/aw/route.py

This file was deleted.

Loading

0 comments on commit 02250a9

Please sign in to comment.