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:
parent
5c46f2aa67
commit
19c1798f90
24 changed files with 602 additions and 63 deletions
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue