Compare commits

...

1 commit

Author SHA1 Message Date
19c1798f90 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>
2026-09-04 10:52:43 +01:00
24 changed files with 602 additions and 63 deletions

View file

@ -8,6 +8,28 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`.
_Nothing yet._ _Nothing yet._
## [0.2.0] — 2026-09-04
### Added
- **Multiple named API keys per user.** Each user can now hold several keys —
one per agent or script — named and revocable individually, managed from a
new **Settings → API Keys** page (a table with name, key, created, last-used,
and per-row revoke). Keys are viewable on demand (`dbk_`-prefixed).
- **Gated key-provisioning API** for automated test installations:
`GET/POST /api/keys` and `DELETE /api/keys/{id}`, controlled by
`DAYBOOK_KEY_PROVISIONING_API` (unset → follows `DJANGO_DEBUG`, so **off in
deployment**). Advertised as `key_provisioning_api` in `/api/capabilities`;
returns 403 when disabled.
- API keys track `last_used_at` (throttled to one write/minute) so stale keys
are visible before you revoke them.
### Changed
- Auth now resolves bearer tokens against the new `ApiKey` model instead of the
single DRF `authtoken.Token`. Existing tokens are migrated 1:1 into named
("default") keys with the same value — no client reconfiguration needed.
- Settings → Account no longer manages a single token; it links to API Keys.
Creating a user (Settings → Users) issues an initial named key.
## [0.1.1] — 2026-09-03 ## [0.1.1] — 2026-09-03
### Fixed ### Fixed
@ -63,6 +85,7 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check. runbook (`deploy/cloud_agent_deploy.md`) and health check.
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...HEAD [Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...HEAD
[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1 [0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0 [0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0

View 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'],
},
),
]

View 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),
]

View file

@ -1,7 +1,14 @@
import secrets
from django.conf import settings from django.conf import settings
from django.db import models 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): class Artifact(models.Model):
HTML_FRAGMENT = "html_fragment" HTML_FRAGMENT = "html_fragment"
HTML_FULL = "html_full" HTML_FULL = "html_full"
@ -92,6 +99,28 @@ class UserProfile(models.Model):
return f"{self.user} quota={self.quota_bytes}" 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): class Asset(models.Model):
"""An image uploaded for embedding inside an artifact's content. """An image uploaded for embedding inside an artifact's content.

View file

@ -15,10 +15,13 @@ urlpatterns = [
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
path("api/skill", views.SkillView.as_view(), name="skill-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", views.ArtifactListCreateView.as_view(), name="artifact-api"),
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-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/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"), 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/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"), path("settings/storage/", views.settings_storage, name="settings-storage"),

View file

@ -29,14 +29,13 @@ from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST 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.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from .demo_data import create_demo_artifacts from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm from .forms import DaybookLoginForm
from .models import Artifact, Asset, TagColor from .models import ApiKey, Artifact, Asset, TagColor
from .storage import ( from .storage import (
QuotaExceeded, QuotaExceeded,
SIDECAR_SUFFIXES, SIDECAR_SUFFIXES,
@ -309,6 +308,17 @@ class CapabilitiesView(APIView):
return HttpResponseForbidden("Login or a valid API token is required") return HttpResponseForbidden("Login or a valid API token is required")
from daybook_project import __version__ 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( return Response(
{ {
"version": __version__, "version": __version__,
@ -318,14 +328,9 @@ class CapabilitiesView(APIView):
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV], "raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES], "source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
"auth": "bearer_token", "auth": "bearer_token",
"methods": { "methods": methods,
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"],
},
"skill_url": request.build_absolute_uri(reverse("skill-api")), "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") 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 FEED_WINDOW_DAYS = 7
@ -992,9 +1062,15 @@ def resolve_token_user(request):
if not key: if not key:
return None return None
try: try:
return Token.objects.select_related("user").get(key=key).user api_key = ApiKey.objects.select_related("user").get(key=key)
except Token.DoesNotExist: except ApiKey.DoesNotExist:
return None 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): def normalize_tags(tags):
@ -1250,16 +1326,36 @@ def settings_home(request):
@login_required @login_required
def settings_account(request): 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( return render(
request, request,
"artifacts/settings_account.html", "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") return HttpResponseForbidden("Admin access required")
created_user = None created_user = None
created_token = None created_key = None
if request.method == "POST": if request.method == "POST":
username = request.POST.get("username", "").strip() username = request.POST.get("username", "").strip()
password = request.POST.get("password", "") password = request.POST.get("password", "")
@ -1283,7 +1379,7 @@ def settings_users(request):
if grant_admin: if grant_admin:
created_user.is_superuser = True created_user.is_superuser = True
created_user.save(update_fields=["is_superuser"]) 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}'.") messages.success(request, f"Created user '{username}'.")
return render( return render(
@ -1293,7 +1389,7 @@ def settings_users(request):
"active_section": "users", "active_section": "users",
"users": User.objects.order_by("username"), "users": User.objects.order_by("username"),
"created_user": created_user, "created_user": created_user,
"created_token": created_token, "created_key": created_key,
}, },
) )

View file

@ -1,4 +1,4 @@
# Single source of truth for the app version. Bump this, commit, then tag the # Single source of truth for the app version. Bump this, commit, then tag the
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
# GET /api/capabilities ("version"). # GET /api/capabilities ("version").
__version__ = "0.1.1" __version__ = "0.2.0"

View file

@ -9,6 +9,17 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys).
# Meant for automated test installations that provision their own agents. It
# exposes key material over the API, so it is OFF in deployment. Unset, it
# follows DEBUG (on locally, off in production); set DAYBOOK_KEY_PROVISIONING_API
# explicitly (0/1) to override either way.
_key_api_env = os.environ.get("DAYBOOK_KEY_PROVISIONING_API")
if _key_api_env is None:
DAYBOOK_KEY_PROVISIONING_API = DEBUG
else:
DAYBOOK_KEY_PROVISIONING_API = _key_api_env.lower() in {"1", "true", "yes", "on"}
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
# on login/POST. The proxy must forward X-Forwarded-Proto: https. # on login/POST. The proxy must forward X-Forwarded-Proto: https.

View file

@ -6,6 +6,11 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example
# "CSRF verification failed" because Django can't tell the original request # "CSRF verification failed" because Django can't tell the original request
# was HTTPS. Set to the public HTTPS origin(s) users actually visit: # was HTTPS. Set to the public HTTPS origin(s) users actually visit:
DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example
# Leave OFF in deployment. When on, POST /api/keys can create/list/revoke API
# keys over the network (it returns key material), which is only appropriate
# for automated test installations. Unset, it follows DJANGO_DEBUG — so with
# DJANGO_DEBUG=0 above it is already off. Set to 1 only on a throwaway test box.
# DAYBOOK_KEY_PROVISIONING_API=0
DAYBOOK_DATA_DIR=/data DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3 DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store ARTIFACTS_ROOT=/data/artifacts_store

View file

@ -2,10 +2,11 @@
This is the shortest path for an agent to publish and retrieve artifacts from Daybook. This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
## 0. Get Your API Token ## 0. Get Your API Key
Log in to the app in a browser and copy your personal token from **Settings → Account** Log in to the app in a browser and create a personal key under **Settings → API Keys**
(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your (`http://127.0.0.1:8001/settings/api-keys/`). Name it after the agent so you can revoke it
later. Every artifact you post with it lands in your
feed, and only yours. feed, and only yours.
## 1. Check Capabilities ## 1. Check Capabilities

View file

@ -8,13 +8,13 @@ Base URL while running locally:
http://127.0.0.1:8001 http://127.0.0.1:8001
``` ```
Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: Authentication for write endpoints (POST/PATCH/DELETE) — a personal API key, created in the app under Settings → API Keys. Each user can hold several (one per agent/script) and revoke them individually:
```http ```http
Authorization: Bearer <your-personal-token> Authorization: Bearer <your-personal-key>
``` ```
Every artifact belongs to the user whose token authenticated the POST — feeds are private per user. Every artifact belongs to the user whose key authenticated the POST — feeds are private per user.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
logged-in browser session, not a bearer token — visit the app in a browser and log in first. logged-in browser session, not a bearer token — visit the app in a browser and log in first.
@ -575,9 +575,40 @@ curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/
Discover it programmatically via `skill_url` in the capabilities response. Discover it programmatically via `skill_url` in the capabilities response.
## API Keys (provisioning)
Keys are normally created and revoked in the app under **Settings → API Keys**.
For automated test installations, a programmatic surface can provision them
too — but it is **gated** and **off in deployment**, because it returns key
material over the API.
The gate is `DAYBOOK_KEY_PROVISIONING_API`. Unset, it follows `DJANGO_DEBUG`
(on locally, off in production); set it explicitly (`0`/`1`) to override.
`GET /api/capabilities` reports the current state as `key_provisioning_api`,
and only lists the `/api/keys` methods when it's enabled. When disabled, every
method below returns `403`.
```http
GET /api/keys # list this user's keys (incl. key values)
POST /api/keys # body {"name": "hermes-agent"} -> the new key
DELETE /api/keys/{id} # revoke one key
```
Authenticated by a browser session **or** an existing bearer key, and only
ever operates on the authenticated user's own keys.
```bash
# Create a new named key (test installations only)
curl -X POST -H "Authorization: Bearer <existing-key>" \
-H "Content-Type: application/json" \
-d '{"name": "hermes-agent"}' \
http://127.0.0.1:8001/api/keys
```
## Current MVP Constraints ## Current MVP Constraints
- Each user has a personal ingest API token; there is no shared token. - Each user can hold several personal API keys (Settings → API Keys), each
individually revocable; there is no shared key.
- Reader/browser access requires login; feeds are private per user (except - Reader/browser access requires login; feeds are private per user (except
per-artifact public share links — see Sharing above). per-artifact public share links — see Sharing above).
- Content modification/revisions are deferred (`PATCH` edits metadata only, - Content modification/revisions are deferred (`PATCH` edits metadata only,

View file

@ -64,9 +64,10 @@ DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store ARTIFACTS_ROOT=/data/artifacts_store
``` ```
There is no shared API token to configure — Daybook uses per-user tokens There is no shared API token to configure — Daybook uses per-user API keys
(DRF `Token` model) issued from Settings → Account once a user exists. Do not created under Settings → API Keys once a user exists (each user can hold
commit `deploy/.env`. several, one per agent, each individually revocable). Do not commit
`deploy/.env`.
Create the persistent data directory and bring the stack up: Create the persistent data directory and bring the stack up:
@ -94,7 +95,7 @@ that's expected; it confirms the app is up and enforcing auth. (Don't add
`curl -f` — it treats the 403 as an error. The container healthcheck uses `curl -f` — it treats the 403 as an error. The container healthcheck uses
the login page, which is 200 without auth.) Open the login page, which is 200 without auth.) Open
`http://<vm-ip>:8000/` in a browser and log in with the superuser you just `http://<vm-ip>:8000/` in a browser and log in with the superuser you just
created, then grab a personal token from Settings → Account and use it for a created, then create a personal key under Settings → API Keys and use it for a
smoke-test post: smoke-test post:
```bash ```bash
@ -191,8 +192,8 @@ Caddy: header_up X-Forwarded-Proto https
- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
- `GET /api/capabilities` returns 403 without auth, 200 with a valid token or - `GET /api/capabilities` returns 403 without auth, 200 with a valid token or
session (check the status code — don't use `curl -f`, which errors on 403). session (check the status code — don't use `curl -f`, which errors on 403).
- At least one superuser exists (`manage.py createsuperuser`) with a token - At least one superuser exists (`manage.py createsuperuser`) with a key
from Settings → Account. from Settings → API Keys.
## Known MVP Constraints ## Known MVP Constraints

View file

@ -24,7 +24,7 @@ DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent DAYBOOK_SOURCE=agent
``` ```
`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. `DAYBOOK_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment.
## Commands ## Commands

View file

@ -18,7 +18,7 @@ support; a `.env` file in the working directory is not read automatically.
```text ```text
DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL
DAYBOOK_TOKEN=your-personal-api-token # from Settings -> Account DAYBOOK_TOKEN=your-personal-api-key # from Settings -> API Keys
DAYBOOK_SOURCE=agent # a stable label for what's posting DAYBOOK_SOURCE=agent # a stable label for what's posting
``` ```
@ -27,9 +27,9 @@ DAYBOOK_SOURCE=agent # a stable label for wha
`http://127.0.0.1:8001`, which is only correct when Daybook is running `http://127.0.0.1:8001`, which is only correct when Daybook is running
locally on the same machine (its development default). For any remote or locally on the same machine (its development default). For any remote or
deployed instance you **must** set this. deployed instance you **must** set this.
- **`DAYBOOK_TOKEN`** — your personal per-user API token, from Settings → - **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings →
Account in the app (not a token shared by everyone). Artifacts posted with API Keys (name one per agent so you can revoke them individually). Not a key
your token land only in your feed. shared by everyone; artifacts posted with it land only in your feed.
- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting - **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting
(agent-profile, script, or person name). Optional; can also be passed per (agent-profile, script, or person name). Optional; can also be passed per
call with `--source`. call with `--source`.

View file

@ -990,6 +990,64 @@ kbd {
word-break: break-all; word-break: break-all;
} }
.muted {
color: var(--muted);
}
.api-key-create {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.api-key-create label {
display: flex;
flex-direction: column;
font-weight: 600;
gap: 0.3rem;
}
.api-key-table {
border-collapse: collapse;
width: 100%;
}
.api-key-table th,
.api-key-table td {
border-bottom: 1px solid var(--accent-border);
padding: 0.55rem 0.7rem;
text-align: left;
vertical-align: middle;
}
.api-key-table th {
color: var(--muted);
font-size: 0.85em;
font-weight: 600;
}
.api-key-cell {
align-items: center;
display: flex;
gap: 0.5rem;
}
.api-key-cell .token-value {
font-size: 0.85em;
}
.api-key-copy {
flex-shrink: 0;
}
.api-key-revoke {
background: var(--danger-bg);
border: 1px solid var(--danger-text);
color: var(--danger-text);
}
.about-head { .about-head {
align-items: center; align-items: center;
display: flex; display: flex;

View file

@ -4,12 +4,10 @@
<h2>Account</h2> <h2>Account</h2>
<p><strong>Username:</strong> {{ request.user.username }}</p> <p><strong>Username:</strong> {{ request.user.username }}</p>
<h3>API Token</h3> <h3>API Keys</h3>
<p>Use this token as <code>Authorization: Bearer &lt;token&gt;</code> when posting artifacts.</p> <p>
<p><code class="token-value">{{ token.key }}</code></p> Posting to Daybook uses named API keys — one per agent or script, each
<form method="post"> revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
{% csrf_token %} </p>
<input type="hidden" name="action" value="regenerate"> <p><a href="{% url 'settings-api-keys' %}">Manage API keys &rarr;</a></p>
<button type="submit">Regenerate Token</button>
</form>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,66 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>API Keys</h2>
<p>
One key per agent or script. Name each key so you know which agent it's
for, copy it into that agent's <code>DAYBOOK_TOKEN</code>, and revoke keys
individually here. Use a key as
<code>Authorization: Bearer &lt;key&gt;</code> when posting.
</p>
<h3>Create a key</h3>
<form method="post" class="api-key-create">
{% csrf_token %}
<input type="hidden" name="action" value="create">
<label>Name <input type="text" name="name" placeholder="e.g. hermes-agent" required autocomplete="off"></label>
<button type="submit">Create key</button>
</form>
<h3>Your keys</h3>
{% if api_keys %}
<table class="api-key-table">
<thead>
<tr><th>Name</th><th>Key</th><th>Created</th><th>Last used</th><th></th></tr>
</thead>
<tbody>
{% for k in api_keys %}
<tr>
<td>{{ k.name }}</td>
<td class="api-key-cell">
<code class="token-value">{{ k.key }}</code>
<button type="button" class="api-key-copy" data-copy="{{ k.key }}">Copy</button>
</td>
<td>{{ k.created_at|date:"Y-m-d" }}</td>
<td>{% if k.last_used_at %}{{ k.last_used_at|date:"Y-m-d H:i" }}{% else %}<span class="muted">never</span>{% endif %}</td>
<td>
<form method="post" onsubmit="return confirm('Revoke key &quot;{{ k.name|escapejs }}&quot;? Agents using it will stop working immediately.');">
{% csrf_token %}
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="key_id" value="{{ k.pk }}">
<button type="submit" class="api-key-revoke">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No keys yet. Create one above.</p>
{% endif %}
<script>
(() => {
document.querySelectorAll(".api-key-copy").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(btn.dataset.copy);
const prev = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = prev; }, 1200);
} catch (e) { /* clipboard unavailable; the key is visible to select manually */ }
});
});
})();
</script>
{% endblock %}

View file

@ -25,6 +25,7 @@
<nav class="settings-nav"> <nav class="settings-nav">
<a class="settings-nav-back" href="{% url 'feed' %}">&larr; Back to Feed</a> <a class="settings-nav-back" href="{% url 'feed' %}">&larr; Back to Feed</a>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a> <a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a> <a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a> <a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}

View file

@ -3,13 +3,14 @@
{% block settings_content %} {% block settings_content %}
<h2>Users</h2> <h2>Users</h2>
{% if created_token %} {% if created_key %}
<p><strong>API token for {{ created_user.username }}</strong> — shown once, copy it now:</p> <p><strong>API key for {{ created_user.username }}</strong> (named "{{ created_key.name }}") — copy it now:</p>
<p><code class="token-value">{{ created_token.key }}</code></p> <p><code class="token-value">{{ created_key.key }}</code></p>
<p class="muted">They can view, rename via new keys, and revoke it from Settings &rarr; API Keys.</p>
{% endif %} {% endif %}
<h3>Add User</h3> <h3>Add User</h3>
<p>New users are regular accounts by default — they can log in and use their own token, but cannot reach Django admin unless you grant admin access below.</p> <p>New users are regular accounts by default — they can log in and use their own API keys, but cannot reach Django admin unless you grant admin access below.</p>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<p><label>Username<br><input type="text" name="username" required autocomplete="off"></label></p> <p><label>Username<br><input type="text" name="username" required autocomplete="off"></label></p>

109
tests/api/test_api_keys.py Normal file
View file

@ -0,0 +1,109 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from rest_framework.test import APITestCase
from artifacts.models import ApiKey
User = get_user_model()
@override_settings(DAYBOOK_KEY_PROVISIONING_API=True)
class ApiKeyProvisioningEnabledTestCase(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.bootstrap = ApiKey.objects.create(user=self.user, name="bootstrap")
self.other = User.objects.create_user(username="bob", password="pw")
def auth(self, key):
return {"HTTP_AUTHORIZATION": f"Bearer {key.key}"}
def test_create_key_with_token_auth(self):
response = self.client.post(
reverse("api-keys"), {"name": "hermes-agent"}, format="json", **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["name"], "hermes-agent")
self.assertTrue(response.data["key"].startswith("dbk_"))
self.assertTrue(ApiKey.objects.filter(user=self.user, name="hermes-agent").exists())
def test_created_key_can_authenticate(self):
created = self.client.post(
reverse("api-keys"), {"name": "worker"}, format="json", **self.auth(self.bootstrap)
).data
# The brand-new key works against another token-authed endpoint.
response = self.client.get(
reverse("capabilities-api"), HTTP_AUTHORIZATION=f"Bearer {created['key']}"
)
self.assertEqual(response.status_code, 200)
def test_create_requires_name(self):
response = self.client.post(
reverse("api-keys"), {"name": " "}, format="json", **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 400)
def test_list_returns_only_own_keys(self):
ApiKey.objects.create(user=self.other, name="not-yours")
response = self.client.get(reverse("api-keys"), **self.auth(self.bootstrap))
self.assertEqual(response.status_code, 200)
names = {k["name"] for k in response.data}
self.assertEqual(names, {"bootstrap"})
def test_revoke_key(self):
victim = ApiKey.objects.create(user=self.user, name="doomed")
response = self.client.delete(
reverse("api-key-detail", args=[victim.pk]), **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 204)
self.assertFalse(ApiKey.objects.filter(pk=victim.pk).exists())
def test_revoke_one_does_not_affect_others(self):
keep = ApiKey.objects.create(user=self.user, name="keep")
drop = ApiKey.objects.create(user=self.user, name="drop")
self.client.delete(reverse("api-key-detail", args=[drop.pk]), **self.auth(self.bootstrap))
self.assertTrue(ApiKey.objects.filter(pk=keep.pk).exists())
def test_cannot_revoke_another_users_key(self):
theirs = ApiKey.objects.create(user=self.other, name="theirs")
response = self.client.delete(
reverse("api-key-detail", args=[theirs.pk]), **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 404)
self.assertTrue(ApiKey.objects.filter(pk=theirs.pk).exists())
def test_capabilities_advertises_provisioning(self):
response = self.client.get(reverse("capabilities-api"), **self.auth(self.bootstrap))
self.assertTrue(response.data["key_provisioning_api"])
self.assertEqual(response.data["methods"]["/api/keys"], ["GET", "POST"])
@override_settings(DAYBOOK_KEY_PROVISIONING_API=False)
class ApiKeyProvisioningDisabledTestCase(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.key = ApiKey.objects.create(user=self.user, name="bootstrap")
def auth(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.key.key}"}
def test_create_disabled_returns_403(self):
response = self.client.post(
reverse("api-keys"), {"name": "nope"}, format="json", **self.auth()
)
self.assertEqual(response.status_code, 403)
self.assertFalse(ApiKey.objects.filter(name="nope").exists())
def test_list_disabled_returns_403(self):
response = self.client.get(reverse("api-keys"), **self.auth())
self.assertEqual(response.status_code, 403)
def test_revoke_disabled_returns_403(self):
response = self.client.delete(reverse("api-key-detail", args=[self.key.pk]), **self.auth())
self.assertEqual(response.status_code, 403)
self.assertTrue(ApiKey.objects.filter(pk=self.key.pk).exists())
def test_capabilities_hides_provisioning(self):
response = self.client.get(reverse("capabilities-api"), **self.auth())
self.assertFalse(response.data["key_provisioning_api"])
self.assertNotIn("/api/keys", response.data["methods"])

View file

@ -9,11 +9,10 @@ from django.contrib.auth import get_user_model
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from daybook_project import __version__ from daybook_project import __version__
from artifacts.models import Artifact from artifacts.models import ApiKey, Artifact
User = get_user_model() User = get_user_model()
@ -37,9 +36,9 @@ class ArtifactAPITestCase(APITestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw") self.user = User.objects.create_user(username="alice", password="pw")
self.token = Token.objects.create(user=self.user) self.token = ApiKey.objects.create(user=self.user, name="alice-key")
self.other_user = User.objects.create_user(username="bob", password="pw") self.other_user = User.objects.create_user(username="bob", password="pw")
self.other_token = Token.objects.create(user=self.other_user) self.other_token = ApiKey.objects.create(user=self.other_user, name="bob-key")
def auth_headers(self, token): def auth_headers(self, token):
return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"} return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"}

View file

@ -0,0 +1,45 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from artifacts.models import ApiKey
User = get_user_model()
class SettingsApiKeysViewTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get(reverse("settings-api-keys"))
self.assertEqual(response.status_code, 302)
def test_create_key(self):
response = self.client.post(
reverse("settings-api-keys"), {"action": "create", "name": "hermes-agent"}, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertTrue(ApiKey.objects.filter(user=self.user, name="hermes-agent").exists())
def test_create_requires_name(self):
self.client.post(reverse("settings-api-keys"), {"action": "create", "name": " "})
self.assertFalse(self.user.api_keys.exists())
def test_key_value_is_viewable_in_list(self):
key = ApiKey.objects.create(user=self.user, name="visible")
response = self.client.get(reverse("settings-api-keys"))
self.assertContains(response, key.key) # view-anytime: full key rendered
def test_revoke_key(self):
key = ApiKey.objects.create(user=self.user, name="doomed")
self.client.post(reverse("settings-api-keys"), {"action": "revoke", "key_id": key.pk})
self.assertFalse(ApiKey.objects.filter(pk=key.pk).exists())
def test_cannot_revoke_another_users_key(self):
other = User.objects.create_user(username="bob", password="pw")
theirs = ApiKey.objects.create(user=other, name="theirs")
self.client.post(reverse("settings-api-keys"), {"action": "revoke", "key_id": theirs.pk})
self.assertTrue(ApiKey.objects.filter(pk=theirs.pk).exists())

View file

@ -1,7 +1,7 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.authtoken.models import Token from artifacts.models import ApiKey
User = get_user_model() User = get_user_model()
@ -38,8 +38,8 @@ class SettingsUsersViewTestCase(TestCase):
self.assertTrue(created.check_password("hunter2pw")) self.assertTrue(created.check_password("hunter2pw"))
self.assertFalse(created.is_superuser) self.assertFalse(created.is_superuser)
self.assertFalse(created.is_staff) self.assertFalse(created.is_staff)
self.assertTrue(Token.objects.filter(user=created).exists()) self.assertTrue(ApiKey.objects.filter(user=created).exists())
self.assertContains(response, Token.objects.get(user=created).key) self.assertContains(response, ApiKey.objects.get(user=created).key)
def test_superuser_can_grant_admin(self): def test_superuser_can_grant_admin(self):
self.client.force_login(self.admin) self.client.force_login(self.admin)

View file

@ -6,9 +6,8 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.authtoken.models import Token
from artifacts.models import Artifact, Asset from artifacts.models import ApiKey, Artifact, Asset
User = get_user_model() User = get_user_model()
@ -173,7 +172,7 @@ class SharingTestCase(TestCase):
# -- API surface -------------------------------------------------------- # -- API surface --------------------------------------------------------
def test_api_detail_reports_share_status(self): def test_api_detail_reports_share_status(self):
token = Token.objects.create(user=self.owner) token = ApiKey.objects.create(user=self.owner, name="owner-key")
self.client.force_login(self.owner) self.client.force_login(self.owner)
self.toggle_share(self.client) self.toggle_share(self.client)
self.artifact.refresh_from_db() self.artifact.refresh_from_db()