Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c1798f90 |
24 changed files with 602 additions and 63 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -8,6 +8,28 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`.
|
|||
|
||||
_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
|
||||
|
||||
### Fixed
|
||||
|
|
@ -63,6 +85,7 @@ First tagged release.
|
|||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||
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.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0
|
||||
|
|
|
|||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 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
|
||||
# GET /api/capabilities ("version").
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.2.0"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
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.):
|
||||
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
|
||||
# on login/POST. The proxy must forward X-Forwarded-Proto: https.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# was HTTPS. Set to the public HTTPS origin(s) users actually visit:
|
||||
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
|
||||
DATABASE_PATH=/data/db.sqlite3
|
||||
ARTIFACTS_ROOT=/data/artifacts_store
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
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**
|
||||
(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your
|
||||
Log in to the app in a browser and create a personal key under **Settings → API Keys**
|
||||
(`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.
|
||||
|
||||
## 1. Check Capabilities
|
||||
|
|
|
|||
39
docs/api.md
39
docs/api.md
|
|
@ -8,13 +8,13 @@ Base URL while running locally:
|
|||
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
|
||||
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
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
- 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
|
||||
per-artifact public share links — see Sharing above).
|
||||
- Content modification/revisions are deferred (`PATCH` edits metadata only,
|
||||
|
|
|
|||
|
|
@ -64,9 +64,10 @@ DATABASE_PATH=/data/db.sqlite3
|
|||
ARTIFACTS_ROOT=/data/artifacts_store
|
||||
```
|
||||
|
||||
There is no shared API token to configure — Daybook uses per-user tokens
|
||||
(DRF `Token` model) issued from Settings → Account once a user exists. Do not
|
||||
commit `deploy/.env`.
|
||||
There is no shared API token to configure — Daybook uses per-user API keys
|
||||
created under Settings → API Keys once a user exists (each user can hold
|
||||
several, one per agent, each individually revocable). Do not commit
|
||||
`deploy/.env`.
|
||||
|
||||
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
|
||||
the login page, which is 200 without auth.) Open
|
||||
`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:
|
||||
|
||||
```bash
|
||||
|
|
@ -191,8 +192,8 @@ Caddy: header_up X-Forwarded-Proto https
|
|||
- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
|
||||
- `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).
|
||||
- At least one superuser exists (`manage.py createsuperuser`) with a token
|
||||
from Settings → Account.
|
||||
- At least one superuser exists (`manage.py createsuperuser`) with a key
|
||||
from Settings → API Keys.
|
||||
|
||||
## Known MVP Constraints
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ DAYBOOK_TOKEN=your-personal-api-token
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ support; a `.env` file in the working directory is not read automatically.
|
|||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
locally on the same machine (its development default). For any remote or
|
||||
deployed instance you **must** set this.
|
||||
- **`DAYBOOK_TOKEN`** — your personal per-user API token, from Settings →
|
||||
Account in the app (not a token shared by everyone). Artifacts posted with
|
||||
your token land only in your feed.
|
||||
- **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings →
|
||||
API Keys (name one per agent so you can revoke them individually). Not a key
|
||||
shared by everyone; artifacts posted with it land only in your feed.
|
||||
- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting
|
||||
(agent-profile, script, or person name). Optional; can also be passed per
|
||||
call with `--source`.
|
||||
|
|
|
|||
|
|
@ -990,6 +990,64 @@ kbd {
|
|||
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 {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
<h2>Account</h2>
|
||||
<p><strong>Username:</strong> {{ request.user.username }}</p>
|
||||
|
||||
<h3>API Token</h3>
|
||||
<p>Use this token as <code>Authorization: Bearer <token></code> when posting artifacts.</p>
|
||||
<p><code class="token-value">{{ token.key }}</code></p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="regenerate">
|
||||
<button type="submit">Regenerate Token</button>
|
||||
</form>
|
||||
<h3>API Keys</h3>
|
||||
<p>
|
||||
Posting to Daybook uses named API keys — one per agent or script, each
|
||||
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
||||
</p>
|
||||
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
66
templates/artifacts/settings_api_keys.html
Normal file
66
templates/artifacts/settings_api_keys.html
Normal 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 <key></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 "{{ k.name|escapejs }}"? 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 %}
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
<nav class="settings-nav">
|
||||
<a class="settings-nav-back" href="{% url 'feed' %}">← Back to Feed</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 == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
|
||||
{% if request.user.is_superuser %}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
{% block settings_content %}
|
||||
<h2>Users</h2>
|
||||
|
||||
{% if created_token %}
|
||||
<p><strong>API token for {{ created_user.username }}</strong> — shown once, copy it now:</p>
|
||||
<p><code class="token-value">{{ created_token.key }}</code></p>
|
||||
{% if created_key %}
|
||||
<p><strong>API key for {{ created_user.username }}</strong> (named "{{ created_key.name }}") — copy it now:</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 → API Keys.</p>
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
<p><label>Username<br><input type="text" name="username" required autocomplete="off"></label></p>
|
||||
|
|
|
|||
109
tests/api/test_api_keys.py
Normal file
109
tests/api/test_api_keys.py
Normal 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"])
|
||||
|
|
@ -9,11 +9,10 @@ from django.contrib.auth import get_user_model
|
|||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from daybook_project import __version__
|
||||
from artifacts.models import Artifact
|
||||
from artifacts.models import ApiKey, Artifact
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -37,9 +36,9 @@ class ArtifactAPITestCase(APITestCase):
|
|||
|
||||
def setUp(self):
|
||||
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_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):
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"}
|
||||
|
|
|
|||
45
tests/views/test_settings_api_keys.py
Normal file
45
tests/views/test_settings_api_keys.py
Normal 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())
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.authtoken.models import Token
|
||||
from artifacts.models import ApiKey
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -38,8 +38,8 @@ class SettingsUsersViewTestCase(TestCase):
|
|||
self.assertTrue(created.check_password("hunter2pw"))
|
||||
self.assertFalse(created.is_superuser)
|
||||
self.assertFalse(created.is_staff)
|
||||
self.assertTrue(Token.objects.filter(user=created).exists())
|
||||
self.assertContains(response, Token.objects.get(user=created).key)
|
||||
self.assertTrue(ApiKey.objects.filter(user=created).exists())
|
||||
self.assertContains(response, ApiKey.objects.get(user=created).key)
|
||||
|
||||
def test_superuser_can_grant_admin(self):
|
||||
self.client.force_login(self.admin)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ from django.contrib.auth import get_user_model
|
|||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
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()
|
||||
|
||||
|
|
@ -173,7 +172,7 @@ class SharingTestCase(TestCase):
|
|||
# -- API surface --------------------------------------------------------
|
||||
|
||||
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.toggle_share(self.client)
|
||||
self.artifact.refresh_from_db()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue