Release v0.2.0: multiple named API keys per user

Replaces the single per-user DRF token with named, individually-revocable
API keys, so you can run one key per agent and cut off a leaked one without
disrupting the others.

- New ApiKey model (user, name, dbk_-prefixed key, created_at, last_used_at).
  Data migration copies every existing authtoken 1:1 into a "default" named
  key with the same value, so no client (incl. the sibling test project's
  hardcoded test-user key) needs reconfiguring.
- Auth (resolve_token_user) resolves against ApiKey and stamps last_used_at,
  throttled to one write/minute.
- Settings -> API Keys: table with name/key/created/last-used + per-row
  revoke and a create form; keys viewable on demand. Settings -> Account now
  links here; Settings -> Users issues a named initial key.
- Gated provisioning API for test installations: GET/POST /api/keys and
  DELETE /api/keys/{id}, controlled by DAYBOOK_KEY_PROVISIONING_API (unset ->
  follows DEBUG, so off in deployment). Advertised via key_provisioning_api
  in /api/capabilities; 403 when disabled.
- Docs (api/deployment/quickstart/posting-helper), SKILL.md, and env.example
  updated from "token / Settings -> Account" to "key / Settings -> API Keys".
- Tests: new API + UI suites for keys; existing token-based tests moved to
  ApiKey. Full suite 129 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-04 10:52:43 +01:00
parent 5c46f2aa67
commit 19c1798f90
24 changed files with 602 additions and 63 deletions

View file

@ -29,14 +29,13 @@ from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST
from rest_framework.authtoken.models import Token
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm
from .models import Artifact, Asset, TagColor
from .models import ApiKey, Artifact, Asset, TagColor
from .storage import (
QuotaExceeded,
SIDECAR_SUFFIXES,
@ -309,6 +308,17 @@ class CapabilitiesView(APIView):
return HttpResponseForbidden("Login or a valid API token is required")
from daybook_project import __version__
methods = {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"],
}
if settings.DAYBOOK_KEY_PROVISIONING_API:
methods["/api/keys"] = ["GET", "POST"]
methods["/api/keys/{id}"] = ["DELETE"]
return Response(
{
"version": __version__,
@ -318,14 +328,9 @@ class CapabilitiesView(APIView):
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
"auth": "bearer_token",
"methods": {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"],
},
"methods": methods,
"skill_url": request.build_absolute_uri(reverse("skill-api")),
"key_provisioning_api": settings.DAYBOOK_KEY_PROVISIONING_API,
}
)
@ -373,6 +378,71 @@ class SkillView(APIView):
return HttpResponse(skill, content_type="text/markdown; charset=utf-8")
def serialize_api_key(api_key):
return {
"id": api_key.pk,
"name": api_key.name,
"key": api_key.key,
"created_at": api_key.created_at.isoformat(),
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
}
class ApiKeyListCreateView(APIView):
"""Programmatic API-key management, for automated test installations that
provision their own agents. Gated by settings.DAYBOOK_KEY_PROVISIONING_API,
which is OFF in deployment (it hands out key material over the API). When
off, every method returns 403. Authenticated by session or an existing
bearer token, and only ever operates on that user's own keys.
GET /api/keys -> list this user's keys (incl. key values)
POST /api/keys -> create {"name": ...} -> the new key
"""
def _resolve_user(self, request):
if request.user.is_authenticated:
return request.user
return resolve_token_user(request)
def get(self, request):
if not settings.DAYBOOK_KEY_PROVISIONING_API:
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
user = self._resolve_user(request)
if user is None:
return HttpResponseForbidden("Login or a valid API token is required")
return Response([serialize_api_key(k) for k in user.api_keys.all()])
def post(self, request):
if not settings.DAYBOOK_KEY_PROVISIONING_API:
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
user = self._resolve_user(request)
if user is None:
return HttpResponseForbidden("Login or a valid API token is required")
name = (request.data.get("name") or "").strip()
if not name:
return Response({"detail": "A non-empty 'name' is required."}, status=400)
api_key = ApiKey.objects.create(user=user, name=name)
return Response(serialize_api_key(api_key), status=201)
class ApiKeyDetailView(APIView):
"""Revoke a single key by id, same gating and auth as the list/create view.
DELETE /api/keys/{id}
"""
def delete(self, request, key_id):
if not settings.DAYBOOK_KEY_PROVISIONING_API:
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
user = request.user if request.user.is_authenticated else resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Login or a valid API token is required")
deleted, _ = ApiKey.objects.filter(pk=key_id, user=user).delete()
if not deleted:
raise Http404("No such key")
return Response(status=204)
FEED_WINDOW_DAYS = 7
@ -992,9 +1062,15 @@ def resolve_token_user(request):
if not key:
return None
try:
return Token.objects.select_related("user").get(key=key).user
except Token.DoesNotExist:
api_key = ApiKey.objects.select_related("user").get(key=key)
except ApiKey.DoesNotExist:
return None
# Stamp last-used so stale keys are visible in the UI, but throttle the
# write to at most once a minute to avoid a DB write on every request.
now = timezone.now()
if api_key.last_used_at is None or (now - api_key.last_used_at).total_seconds() > 60:
ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now)
return api_key.user
def normalize_tags(tags):
@ -1250,16 +1326,36 @@ def settings_home(request):
@login_required
def settings_account(request):
token, _ = Token.objects.get_or_create(user=request.user)
if request.method == "POST" and request.POST.get("action") == "regenerate":
token.delete()
token = Token.objects.create(user=request.user)
messages.success(request, "API token regenerated.")
return redirect("settings-account")
return render(
request,
"artifacts/settings_account.html",
{"token": token, "active_section": "account"},
{"active_section": "account", "key_count": request.user.api_keys.count()},
)
@login_required
def settings_api_keys(request):
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = (request.POST.get("name") or "").strip()
if not name:
messages.error(request, "Give the key a name so you know which agent it's for.")
else:
ApiKey.objects.create(user=request.user, name=name)
messages.success(request, f"Created API key '{name}'.")
elif action == "revoke":
key_id = request.POST.get("key_id") or ""
deleted, _ = ApiKey.objects.filter(pk=key_id, user=request.user).delete()
if deleted:
messages.success(request, "API key revoked.")
else:
messages.error(request, "No such key.")
return redirect("settings-api-keys")
return render(
request,
"artifacts/settings_api_keys.html",
{"active_section": "api-keys", "api_keys": request.user.api_keys.all()},
)
@ -1269,7 +1365,7 @@ def settings_users(request):
return HttpResponseForbidden("Admin access required")
created_user = None
created_token = None
created_key = None
if request.method == "POST":
username = request.POST.get("username", "").strip()
password = request.POST.get("password", "")
@ -1283,7 +1379,7 @@ def settings_users(request):
if grant_admin:
created_user.is_superuser = True
created_user.save(update_fields=["is_superuser"])
created_token = Token.objects.create(user=created_user)
created_key = ApiKey.objects.create(user=created_user, name="default")
messages.success(request, f"Created user '{username}'.")
return render(
@ -1293,7 +1389,7 @@ def settings_users(request):
"active_section": "users",
"users": User.objects.order_by("username"),
"created_user": created_user,
"created_token": created_token,
"created_key": created_key,
},
)