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
31
artifacts/migrations/0014_apikey.py
Normal file
31
artifacts/migrations/0014_apikey.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.2.15 on 2026-09-04 09:35
|
||||
|
||||
import artifacts.models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0013_artifact_share_token'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApiKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('key', models.CharField(db_index=True, default=artifacts.models.generate_api_key, max_length=64, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
32
artifacts/migrations/0015_migrate_tokens_to_apikeys.py
Normal file
32
artifacts/migrations/0015_migrate_tokens_to_apikeys.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_tokens_to_apikeys(apps, schema_editor):
|
||||
"""Carry every existing DRF authtoken over to a named ApiKey so keys issued
|
||||
before the multi-key feature keep authenticating (incl. the permanent test
|
||||
user and any deployed agents). Same key string -> no client reconfiguration."""
|
||||
Token = apps.get_model("authtoken", "Token")
|
||||
ApiKey = apps.get_model("artifacts", "ApiKey")
|
||||
for token in Token.objects.all():
|
||||
if ApiKey.objects.filter(key=token.key).exists():
|
||||
continue
|
||||
api_key = ApiKey.objects.create(user_id=token.user_id, name="default", key=token.key)
|
||||
# created_at is auto_now_add, so set it after the fact to preserve the
|
||||
# original issue date (queryset.update bypasses auto_now_add).
|
||||
ApiKey.objects.filter(pk=api_key.pk).update(created_at=token.created)
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("artifacts", "0014_apikey"),
|
||||
("authtoken", "0004_alter_tokenproxy_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_tokens_to_apikeys, noop),
|
||||
]
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
def generate_api_key():
|
||||
"""`dbk_` prefix (recognizable as a Daybook key) + 40 hex chars."""
|
||||
return "dbk_" + secrets.token_hex(20)
|
||||
|
||||
|
||||
class Artifact(models.Model):
|
||||
HTML_FRAGMENT = "html_fragment"
|
||||
HTML_FULL = "html_full"
|
||||
|
|
@ -92,6 +99,28 @@ class UserProfile(models.Model):
|
|||
return f"{self.user} quota={self.quota_bytes}"
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
"""A named, per-user API key for ingest. A user can hold several — one per
|
||||
agent or script — and revoke them individually. Stored and shown in
|
||||
plaintext: this is a self-hosted, single-operator app and keys are
|
||||
viewable on demand by design.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys"
|
||||
)
|
||||
name = models.CharField(max_length=100)
|
||||
key = models.CharField(max_length=64, unique=True, default=generate_api_key, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.user})"
|
||||
|
||||
|
||||
class Asset(models.Model):
|
||||
"""An image uploaded for embedding inside an artifact's content.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@ urlpatterns = [
|
|||
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||
path("api/skill", views.SkillView.as_view(), name="skill-api"),
|
||||
path("api/keys", views.ApiKeyListCreateView.as_view(), name="api-keys"),
|
||||
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
|
||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||
path("settings/", views.settings_home, name="settings-home"),
|
||||
path("settings/account/", views.settings_account, name="settings-account"),
|
||||
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
|
||||
path("settings/users/", views.settings_users, name="settings-users"),
|
||||
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
|
||||
path("settings/storage/", views.settings_storage, name="settings-storage"),
|
||||
|
|
|
|||
|
|
@ -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