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
Changes for src/jama/adminui/app.py: 88 added lines, 0 removed lines.
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
Changes for src/jama/adminui/i18n.py: 13 added lines, 0 removed lines.
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
Changes for src/jama/tests.py: 21 added lines, 0 removed lines.
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
Changes for src/resources/admin_services/api_keys.py: 109 added lines, 0 removed lines.
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
+34 −73
Changes for src/resources/management/commands/apikey.py: 34 added lines, 73 removed lines.
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