Commit 1f54c7bd authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

manage API keys in the TUI

parent c6db5d5c
Loading
Loading
Loading
Loading
+88 −0
Original line number Diff line number Diff line
@@ -34,11 +34,13 @@ from jama.admin_services import config as config_services
from jama.admin_services import tasks as task_services
from resources import acl
from resources.admin_services import access as access_services
from resources.admin_services import api_keys as api_key_services
from resources.admin_services import filetypes as filetype_services
from resources.admin_services import metadata as metadata_services
from resources.admin_services import projects as project_services
from resources.admin_services import users as user_services
from resources.models import (
    APIKey,
    FileType,
    Metadata,
    MetadataSet,
@@ -595,6 +597,7 @@ class JamaAdminApp(App):
        ("ctrl+r", "roles", "Roles"),
        ("m", "metadata", "Metadata"),
        ("ctrl+u", "members", "Members"),
        ("ctrl+k", "api_keys", "API keys"),
        ("s", "delete_successful_tasks", "Delete successful"),
        ("f", "delete_failed_tasks", "Delete failed"),
        ("space", "toggle", "Toggle"),
@@ -670,6 +673,7 @@ class JamaAdminApp(App):
        ],
        "users": [
            FooterCommand("Enter project roles", "open_selected"),
            FooterCommand("Ctrl+K API keys", "api_keys"),
            FooterCommand("A add user", "add"),
            FooterCommand("E edit user", "edit"),
            FooterCommand("D delete user", "delete"),
@@ -677,12 +681,21 @@ class JamaAdminApp(App):
            FooterCommand("Q quit", "quit"),
        ],
        "user_accesses": [
            FooterCommand("Ctrl+K API keys", "api_keys"),
            FooterCommand("A add project role", "add"),
            FooterCommand("D remove project role", "delete"),
            FooterCommand("Esc back", "back"),
            FooterCommand("R refresh", "refresh"),
            FooterCommand("Q quit", "quit"),
        ],
        "user_api_keys": [
            FooterCommand("A add API key", "add"),
            FooterCommand("E edit API key", "edit"),
            FooterCommand("D delete API key", "delete"),
            FooterCommand("Esc back", "back"),
            FooterCommand("R refresh", "refresh"),
            FooterCommand("Q quit", "quit"),
        ],
        "tasks": [
            FooterCommand("Enter details", "open_selected"),
            FooterCommand("A filter", "add"),
@@ -810,6 +823,11 @@ class JamaAdminApp(App):
        if project:
            self.open_mode("project_members", {"project_id": project.pk})

    def action_api_keys(self) -> None:
        user = self.selected_user()
        if user:
            self.open_mode("user_api_keys", {"user_id": user.pk})

    def action_open_selected(self) -> None:
        handlers = {
            "projects": self.open_project_stats,
@@ -837,6 +855,7 @@ class JamaAdminApp(App):
            "filetypes": self.add_filetype,
            "tasks": self.filter_tasks,
            "cache": self.clear_all_caches,
            "user_api_keys": self.add_api_key,
        }
        handler = handlers.get(self.mode_name)
        if handler:
@@ -852,6 +871,7 @@ class JamaAdminApp(App):
            "metadata": self.edit_metadata,
            "filetypes": self.edit_filetype,
            "config": self.edit_env_entry,
            "user_api_keys": self.edit_api_key,
        }
        handler = handlers.get(self.mode_name)
        if handler:
@@ -867,6 +887,7 @@ class JamaAdminApp(App):
            "metadata_sets": self.delete_metadata_set,
            "metadata": self.delete_metadata,
            "filetypes": self.delete_filetype,
            "user_api_keys": self.delete_api_key,
        }
        handler = handlers.get(self.mode_name)
        if handler:
@@ -979,6 +1000,14 @@ class JamaAdminApp(App):
            )
        return None

    def selected_user(self) -> User | None:
        if self.mode_name == "users":
            selected = self.selected_object()
            return selected if isinstance(selected, User) else None
        if self.context.get("user_id"):
            return User.objects.filter(pk=self.context["user_id"]).first()
        return None

    def refresh_table(self) -> None:
        try:
            getattr(self, f"load_{self.mode_name}")()
@@ -1730,6 +1759,65 @@ class JamaAdminApp(App):
                ),
            )

    def load_user_api_keys(self) -> None:
        user = get_object_or_404(User, pk=self.context["user_id"])
        self.set_title(f"{self.translate('API keys')}: {user.username}")
        self.reset_table(["id", "active", "key hash"])
        for api_key in api_key_services.list_api_keys(user=user):
            self.add_row(
                f"api-key:{api_key.pk}",
                api_key,
                [api_key.pk, api_key.active, api_key.key_hash],
            )
        self.set_status("A add API key. E activate/deactivate. D delete.")

    def add_api_key(self) -> None:
        user = get_object_or_404(User, pk=self.context["user_id"])

        def create(values):
            result = api_key_services.create_api_key(
                user, values["key"], active=values["active"]
            )
            if result.generated:
                return f"New API key (copy it now): {result.raw_key}"
            return "API key saved."

        self.form(
            "Add API key",
            [
                Field("key", "API key (blank = generate)", "", "password"),
                Field("active", "Active", True, "bool"),
            ],
            create,
        )

    def edit_api_key(self) -> None:
        api_key = self.selected_object()
        if isinstance(api_key, APIKey):

            def update(values):
                api_key_services.update_api_key(api_key, active=values["active"])
                return "API key updated."

            self.form(
                f"{self.translate('Edit API key')} {api_key.pk}",
                [Field("active", "Active", api_key.active, "bool")],
                update,
            )

    def delete_api_key(self) -> None:
        api_key = self.selected_object()
        if isinstance(api_key, APIKey):

            def delete(_):
                api_key_services.delete_api_key(api_key)
                return "API key deleted."

            self.confirm(
                f"Delete API key {api_key.pk}?",
                delete,
            )

    def load_tasks(self) -> None:
        self.set_title("Task results")
        self.reset_table(["id", "project", "task_path", "enqueued_at", "status"])
+13 −0
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ FR_TRANSLATIONS = {
    "Ctrl+R roles": "Ctrl+R rôles",
    "M metadata": "M métadonnées",
    "Ctrl+U members": "Ctrl+U membres",
    "Ctrl+K API keys": "Ctrl+K clés API",
    "R refresh": "R actualiser",
    "Q quit": "Q quitter",
    "Esc back": "Esc retour",
@@ -44,6 +45,9 @@ FR_TRANSLATIONS = {
    "D delete scoped": "D supprimer ciblée",
    "A add member": "A ajouter membre",
    "D remove selected role": "D retirer rôle sélectionné",
    "A add API key": "A ajouter clé API",
    "E edit API key": "E modifier clé API",
    "D delete API key": "D supprimer clé API",
    "Enter metadata": "Entrée métadonnées",
    "A add set": "A ajouter jeu",
    "E rename set": "E renommer jeu",
@@ -111,6 +115,7 @@ FR_TRANSLATIONS = {
    "iiif": "IIIF",
    "hls": "HLS",
    "key": "clé",
    "key hash": "empreinte de clé",
    "utility": "utilitaire",
    "alias": "alias",
    "line": "ligne",
@@ -163,6 +168,10 @@ FR_TRANSLATIONS = {
    "Last name": "Nom",
    "Password (blank = unusable)": "Mot de passe (vide = inutilisable)",
    "Active": "Actif",
    "API keys": "Clés API",
    "Add API key": "Ajouter une clé API",
    "Edit API key": "Modifier la clé API",
    "API key (blank = generate)": "Clé API (vide = générer)",
    "Staff": "Équipe",
    "Superuser": "Superutilisateur",
    "New password (blank = unchanged)": "Nouveau mot de passe (vide = inchangé)",
@@ -214,6 +223,10 @@ FR_TRANSLATIONS = {
    "A add metadata. E edit. D delete.": "A ajouter métadonnée. E modifier. D supprimer.",
    "Enter opens project roles. A add, E edit, D delete.": "Entrée ouvre les rôles projet. A ajouter, E modifier, D supprimer.",
    "A add by project/role ids. D remove selected access.": "A ajoute par projet/rôle. D retire l'accès sélectionné.",
    "A add API key. E activate/deactivate. D delete.": "A ajoute une clé API. E active/désactive. D supprime.",
    "API key saved.": "Clé API enregistrée.",
    "API key updated.": "Clé API mise à jour.",
    "API key deleted.": "Clé API supprimée.",
    "A filters. Enter details. S deletes SUCCESSFUL. F deletes FAILED.": "A filtre. Entrée détails. S supprime SUCCESSFUL. F supprime FAILED.",
    "Esc back.": "Esc retour.",
    "A add, E edit, D delete. Utilities can load built-ins.": "A ajouter, E modifier, D supprimer. Les utilitaires peuvent charger les types intégrés.",
+21 −0
Original line number Diff line number Diff line
@@ -379,6 +379,27 @@ class AdminUiAppTestCase(TransactionTestCase):
        with patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}):
            asyncio.run(run_app())

    def test_ctrl_k_on_user_opens_api_keys(self):
        user = User.objects.create(username="api-key-user")
        api_key, _ = APIKey.create_for_key(user, "raw-key")

        async def run_app():
            from jama.adminui.app import JamaAdminApp

            app = JamaAdminApp()
            async with app.run_test() as pilot:
                app.load("users")
                await pilot.pause()
                await pilot.press("ctrl+k")
                await pilot.pause()

                self.assertEqual(app.mode_name, "user_api_keys")
                self.assertEqual(app.context["user_id"], user.pk)
                self.assertEqual(app.selected_object().pk, api_key.pk)

        with patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}):
            asyncio.run(run_app())

    def test_m_on_project_opens_metadata_sets(self):
        project = Project.objects.create(label="Metadata project", description="")

+109 −0
Original line number Diff line number Diff line
import secrets
import string
from dataclasses import dataclass

from django.contrib.auth.models import User

from resources.models import APIKey


@dataclass(frozen=True)
class APIKeyCreation:
    api_key: APIKey
    raw_key: str
    created: bool
    generated: bool


def generate_api_key(length: int = 32) -> str:
    alphabet = string.ascii_letters + string.digits
    return "".join(secrets.choice(alphabet) for _ in range(length))


def list_api_keys(*, user: User | None = None, active_only: bool = False):
    api_keys = APIKey.objects.select_related("user").order_by("user__username", "id")
    if user is not None:
        api_keys = api_keys.filter(user=user)
    if active_only:
        api_keys = api_keys.filter(active=True)
    return api_keys


def create_api_key(
    user: User, raw_key: str | None = None, *, active: bool = True
) -> APIKeyCreation:
    generated = not raw_key
    raw_key = raw_key or generate_api_key()
    api_key, created = APIKey.create_for_key(user=user, key=raw_key)
    if api_key.active != active:
        api_key.active = active
        api_key.save(update_fields=["active"])
    return APIKeyCreation(api_key, raw_key, created, generated)


def create_api_key_for_username(
    username: str,
    raw_key: str | None = None,
    *,
    active: bool = True,
    create_user: bool = False,
) -> APIKeyCreation:
    user = User.objects.filter(username=username).first()
    if user is None:
        if not create_user:
            raise ValueError(
                f'User "{username}" does not exist. Use --create-user to create it.'
            )
        user = User.objects.create(username=username)
    return create_api_key(user, raw_key, active=active)


def update_api_key(api_key: APIKey, *, active: bool) -> APIKey:
    api_key.active = active
    api_key.save(update_fields=["active"])
    return api_key


def get_api_key(
    *,
    key_id: int | None = None,
    key_hash: str | None = None,
    username: str | None = None,
    raw_key: str | None = None,
) -> APIKey:
    selectors = sum(
        selected
        for selected in (
            key_id is not None,
            key_hash is not None,
            username is not None or raw_key is not None,
        )
    )
    if selectors != 1:
        raise ValueError(
            "Delete exactly one key using --id, --hash, or username plus raw key."
        )
    if (username is None) != (raw_key is None):
        raise ValueError("Deleting by raw key requires both username and key.")

    filters = {}
    if key_id is not None:
        filters["id"] = key_id
    elif key_hash is not None:
        filters["key_hash"] = key_hash
    else:
        user = User.objects.filter(username=username).first()
        if user is None:
            raise ValueError(f'User "{username}" does not exist.')
        filters.update(user=user, key_hash=APIKey.hash_key(raw_key))
    try:
        return APIKey.objects.select_related("user").get(**filters)
    except APIKey.DoesNotExist:
        raise ValueError("API key does not exist.") from None


def delete_api_key(api_key: APIKey) -> tuple[int, str]:
    deleted_id = api_key.pk
    username = api_key.user.username
    api_key.delete()
    return deleted_id, username
+33 −72
Original line number Diff line number Diff line
import secrets
import string

from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError

from resources.models import APIKey


def generate_api_key(length=32):
    return "".join(
        secrets.choice(string.ascii_letters + string.digits) for _ in range(length)
    )
from resources.admin_services import api_keys as api_key_services


class Command(BaseCommand):
@@ -75,35 +65,34 @@ class Command(BaseCommand):
            self.handle_delete(options)

    def handle_add(self, options):
        username = options["username"]
        user = User.objects.filter(username=username).first()
        if user is None:
            if not options["create_user"]:
                raise CommandError(
                    f'User "{username}" does not exist. Use --create-user to create it.'
                )
            user = User.objects.create(username=username)

        raw_key = options["key"] or generate_api_key()
        api_key, created = APIKey.create_for_key(user=user, key=raw_key)
        api_key.active = not options["inactive"]
        api_key.save(update_fields=["active"])

        status = "created" if created else "updated"
        try:
            result = api_key_services.create_api_key_for_username(
                options["username"],
                options["key"],
                active=not options["inactive"],
                create_user=options["create_user"],
            )
        except ValueError as error:
            raise CommandError(str(error)) from error
        status = "created" if result.created else "updated"
        self.stdout.write(
            f"{status}\tid={api_key.pk}\tuser={user.username}\tactive={api_key.active}"
            f"{status}\tid={result.api_key.pk}\tuser={result.api_key.user.username}"
            f"\tactive={result.api_key.active}"
        )
        if options["key"] is None:
            self.stdout.write(f"key\t{raw_key}")
        if result.generated:
            self.stdout.write(f"key\t{result.raw_key}")

    def handle_list(self, options):
        api_keys = APIKey.objects.select_related("user").order_by(
            "user__username", "id"
        )
        user = None
        if options["username"]:
            api_keys = api_keys.filter(user__username=options["username"])
        if options["active_only"]:
            api_keys = api_keys.filter(active=True)
            from django.contrib.auth.models import User

            user = User.objects.filter(username=options["username"]).first()
            if user is None:
                return self.stdout.write("id\tuser\tactive\tkey_hash")
        api_keys = api_key_services.list_api_keys(
            user=user, active_only=options["active_only"]
        )

        self.stdout.write("id\tuser\tactive\tkey_hash")
        for api_key in api_keys:
@@ -117,42 +106,14 @@ class Command(BaseCommand):
        username = options["username"]
        raw_key = options["key"]

        selectors = sum(
            1
            for is_selected in (
                key_id is not None,
                key_hash is not None,
                username is not None or raw_key is not None,
            )
            if is_selected
        )
        if selectors != 1:
            raise CommandError(
                "Delete exactly one key using --id, --hash, or username plus raw key."
            )
        if (username is None) != (raw_key is None):
            raise CommandError("Deleting by raw key requires both username and key.")

        api_keys = APIKey.objects.select_related("user")
        if key_id is not None:
            api_key = self.get_api_key(api_keys, id=key_id)
        elif key_hash is not None:
            api_key = self.get_api_key(api_keys, key_hash=key_hash)
        else:
            user = User.objects.filter(username=username).first()
            if user is None:
                raise CommandError(f'User "{username}" does not exist.')
            api_key = self.get_api_key(
                api_keys, user=user, key_hash=APIKey.hash_key(raw_key)
            )

        deleted_id = api_key.pk
        deleted_user = api_key.user.username
        api_key.delete()
        self.stdout.write(f"deleted\tid={deleted_id}\tuser={deleted_user}")

    def get_api_key(self, api_keys, **filters):
        try:
            return api_keys.get(**filters)
        except APIKey.DoesNotExist:
            raise CommandError("API key does not exist.") from None
            api_key = api_key_services.get_api_key(
                key_id=key_id,
                key_hash=key_hash,
                username=username,
                raw_key=raw_key,
            )
            deleted_id, deleted_user = api_key_services.delete_api_key(api_key)
        except ValueError as error:
            raise CommandError(str(error)) from error
        self.stdout.write(f"deleted\tid={deleted_id}\tuser={deleted_user}")
Loading