Rotate and delete API access, not just revoke

Rotating hands out a fresh token, voids the old one and lifts a revocation.
Deleting removes the row for good, taking its idempotency keys with it.
Both are admin only and land in the audit log.
This commit is contained in:
Matthias G
2026-08-14 09:08:43 +02:00
parent 7b4b5e1a14
commit 97b8092914
10 changed files with 325 additions and 43 deletions
+11 -2
View File
@@ -302,7 +302,10 @@
"runNotFound": "Im Kanal war keine passende Nachricht zu finden. Braucht der Bot noch das Recht channels:history?", "runNotFound": "Im Kanal war keine passende Nachricht zu finden. Braucht der Bot noch das Recht channels:history?",
"channelRequired": "Zum Bot-Token gehört ein Kanal, sonst weiß Logbuch nicht, wohin.", "channelRequired": "Zum Bot-Token gehört ein Kanal, sonst weiß Logbuch nicht, wohin.",
"slackSaid": "Slack sagt: {detail}", "slackSaid": "Slack sagt: {detail}",
"windowReset": "Der Zeitraum ist zurückgesetzt. Der nächste Bericht umfasst wieder die letzten sieben Tage." "windowReset": "Der Zeitraum ist zurückgesetzt. Der nächste Bericht umfasst wieder die letzten sieben Tage.",
"clientRotated": "Das alte Token gilt nicht mehr. Das neue steht einmalig hier.",
"clientDeleted": "Der Zugang ist gelöscht.",
"clientUnknown": "Diesen Zugang gibt es nicht mehr."
}, },
"errors": { "errors": {
"nameRequired": "Trag einen Namen ein.", "nameRequired": "Trag einen Namen ein.",
@@ -712,7 +715,13 @@
"project": "Ohne Bindung gilt der Zugang für alle Projekte." "project": "Ohne Bindung gilt der Zugang für alle Projekte."
}, },
"publishTitle": "Veröffentlichen", "publishTitle": "Veröffentlichen",
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden." "publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden.",
"rotate": "Neues Token",
"rotating": "Wird erneuert",
"delete": "Löschen",
"deleteConfirm": "Wirklich löschen?",
"deleting": "Wird gelöscht",
"keep": "Behalten"
}, },
"recentEntries": "Zuletzt bearbeitet", "recentEntries": "Zuletzt bearbeitet",
"allEntries": "Alle Beiträge", "allEntries": "Alle Beiträge",
+11 -2
View File
@@ -302,7 +302,10 @@
"runNotFound": "No matching message found in the channel. Does the bot still need channels:history?", "runNotFound": "No matching message found in the channel. Does the bot still need channels:history?",
"channelRequired": "A bot token needs a channel, otherwise Logbuch has no target.", "channelRequired": "A bot token needs a channel, otherwise Logbuch has no target.",
"slackSaid": "Slack says: {detail}", "slackSaid": "Slack says: {detail}",
"windowReset": "The window is reset. The next digest covers the last seven days again." "windowReset": "The window is reset. The next digest covers the last seven days again.",
"clientRotated": "The old token is void. The new one is shown here once.",
"clientDeleted": "The access is deleted.",
"clientUnknown": "That access no longer exists."
}, },
"errors": { "errors": {
"nameRequired": "Enter a name.", "nameRequired": "Enter a name.",
@@ -712,7 +715,13 @@
"project": "Without a binding the access covers all projects." "project": "Without a binding the access covers all projects."
}, },
"publishTitle": "Publishing", "publishTitle": "Publishing",
"publishHint": "No access may publish. That stays bound to accounts." "publishHint": "No access may publish. That stays bound to accounts.",
"rotate": "New token",
"rotating": "Rotating",
"delete": "Delete",
"deleteConfirm": "Really delete?",
"deleting": "Deleting",
"keep": "Keep"
}, },
"recentEntries": "Recently edited", "recentEntries": "Recently edited",
"allEntries": "All entries", "allEntries": "All entries",
+2 -2
View File
@@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading' import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice' import { AdminNotice } from '~/components/admin/AdminNotice'
import { ClientForm } from '~/components/admin/ClientForm' import { ClientForm } from '~/components/admin/ClientForm'
import { ClientRevokeButton } from '~/components/admin/ClientRevokeButton' import { ClientRowActions } from '~/components/admin/ClientRowActions'
import { cellClass, headCellClass } from '~/components/admin/styles' import { cellClass, headCellClass } from '~/components/admin/styles'
import { EntryDate } from '~/components/ui/EntryDate' import { EntryDate } from '~/components/ui/EntryDate'
import { SectionLabel } from '~/components/ui/SectionLabel' import { SectionLabel } from '~/components/ui/SectionLabel'
@@ -75,7 +75,7 @@ export default async function AdminClientsPage() {
)} )}
</td> </td>
<td className={cellClass}> <td className={cellClass}>
{client.revokedAt ? null : <ClientRevokeButton id={client.id} />} <ClientRowActions id={client.id} revoked={client.revokedAt !== null} />
</td> </td>
</tr> </tr>
))} ))}
@@ -1,33 +0,0 @@
'use client'
import { useActionState } from 'react'
import { useTranslations } from 'next-intl'
import { TbBan } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass } from './styles'
import { revokeApiClient } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type ClientRevokeButtonProps = {
id: string
}
export function ClientRevokeButton({ id }: ClientRevokeButtonProps) {
const t = useTranslations('admin.clients')
const messages = useTranslations('admin.messages')
const [state, formAction] = useActionState(revokeApiClient, emptyAdminFormState)
return (
<form action={formAction} className="flex flex-col gap-2">
<input type="hidden" name="id" value={id} />
<AdminSubmit quiet pendingLabel={t('revoking')} icon={<TbBan className="size-4" />}>
{t('revoke')}
</AdminSubmit>
{state.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>
{messages(state.message)}
</span>
) : null}
</form>
)
}
+108
View File
@@ -0,0 +1,108 @@
'use client'
import { useActionState, useState } from 'react'
import { useTranslations } from 'next-intl'
import { TbBan, TbCopy, TbRefresh, TbTrash } from 'react-icons/tb'
import { AdminSubmit } from './AdminSubmit'
import { errorClass, ghostButtonClass } from './styles'
import { deleteApiClient, revokeApiClient, rotateApiClient } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type ClientRowActionsProps = {
id: string
revoked: boolean
}
export function ClientRowActions({ id, revoked }: ClientRowActionsProps) {
const t = useTranslations('admin.clients')
const messages = useTranslations('admin.messages')
const [revokeState, revokeAction] = useActionState(revokeApiClient, emptyAdminFormState)
const [rotateState, rotateAction] = useActionState(rotateApiClient, emptyAdminFormState)
const [removeState, removeAction] = useActionState(deleteApiClient, emptyAdminFormState)
const [asking, setAsking] = useState(false)
const [copied, setCopied] = useState(false)
const state = [rotateState, removeState, revokeState].find(entry => entry.status !== 'idle')
async function copy(token: string) {
try {
await navigator.clipboard.writeText(token)
setCopied(true)
} catch {
setCopied(false)
}
}
return (
<div className="flex flex-col items-end gap-2">
<span className="flex flex-wrap items-center justify-end gap-3">
<form action={rotateAction}>
<input type="hidden" name="id" value={id} />
<AdminSubmit quiet pendingLabel={t('rotating')} icon={<TbRefresh className="size-4" />}>
{t('rotate')}
</AdminSubmit>
</form>
{revoked ? null : (
<form action={revokeAction}>
<input type="hidden" name="id" value={id} />
<AdminSubmit quiet pendingLabel={t('revoking')} icon={<TbBan className="size-4" />}>
{t('revoke')}
</AdminSubmit>
</form>
)}
{asking ? (
<form action={removeAction} className="flex items-center gap-3">
<input type="hidden" name="id" value={id} />
<AdminSubmit quiet pendingLabel={t('deleting')} icon={<TbTrash className="size-4" />}>
{t('deleteConfirm')}
</AdminSubmit>
<button
type="button"
onClick={() => setAsking(false)}
className="cursor-pointer bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-3 hover:text-ink"
>
{t('keep')}
</button>
</form>
) : (
<button
type="button"
onClick={() => setAsking(true)}
className={`${ghostButtonClass} hover:border-signal hover:text-signal`}
>
<TbTrash aria-hidden="true" className="size-4 shrink-0" />
{t('delete')}
</button>
)}
</span>
{rotateState.token ? (
<span className="flex max-w-md flex-col items-end gap-2 border-l-2 border-signal bg-surface px-4 py-3">
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
{t('tokenTitle')}
</span>
<code className="block break-all text-left font-mono text-small text-ink">{rotateState.token}</code>
<button
type="button"
onClick={() => void copy(rotateState.token ?? '')}
className={ghostButtonClass}
>
<TbCopy aria-hidden="true" className="size-4 shrink-0" />
{copied ? t('copied') : t('copy')}
</button>
<span className="text-right text-small text-ink-2">{t('tokenHint')}</span>
</span>
) : null}
{state?.status === 'error' && state.message ? (
<span role="alert" className={errorClass}>{messages(state.message)}</span>
) : null}
{state?.status === 'ok' && state.message && !rotateState.token ? (
<span className="font-mono text-micro text-ink-3">{messages(state.message)}</span>
) : null}
</div>
)
}
+16
View File
@@ -51,3 +51,19 @@ export async function revokeClient(id: string): Promise<ApiClient | undefined> {
return rows[0] return rows[0]
} }
export async function rotateClientToken(id: string, tokenHash: string): Promise<ApiClient | undefined> {
const rows = await db
.update(apiClients)
.set({ tokenHash, revokedAt: null, lastUsedAt: null })
.where(eq(apiClients.id, id))
.returning()
return rows[0]
}
export async function deleteClient(id: string): Promise<ApiClient | undefined> {
const rows = await db.delete(apiClients).where(eq(apiClients.id, id)).returning()
return rows[0]
}
+24 -1
View File
@@ -3,7 +3,12 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { performSaveBrand } from './admin-brands' import { performSaveBrand } from './admin-brands'
import { performCreateClient, performRevokeClient } from './admin-clients' import {
performCreateClient,
performDeleteClient,
performRevokeClient,
performRotateClient,
} from './admin-clients'
import { performDeletePostType, performSavePostType } from './admin-post-types' import { performDeletePostType, performSavePostType } from './admin-post-types'
import { performSaveProject } from './admin-projects' import { performSaveProject } from './admin-projects'
import { performDeleteMessage, performResetWindow, performSaveSlack, performSendReport } from './admin-settings' import { performDeleteMessage, performResetWindow, performSaveSlack, performSendReport } from './admin-settings'
@@ -177,3 +182,21 @@ export async function resetReportWindow(_state: AdminFormState): Promise<AdminFo
return result return result
} }
export async function rotateApiClient(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
const viewer = await requireAdmin()
const result = await performRotateClient(viewer, formData)
revalidatePath(adminClientsPath)
return result
}
export async function deleteApiClient(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
const viewer = await requireAdmin()
const result = await performDeleteClient(viewer, formData)
revalidatePath(adminClientsPath)
return result
}
+72 -1
View File
@@ -1,7 +1,13 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { z } from 'zod' import { z } from 'zod'
import { recordAudit } from '~/data/repositories/audit' import { recordAudit } from '~/data/repositories/audit'
import { createClient, findClientById, revokeClient } from '~/data/repositories/clients' import {
createClient,
deleteClient,
findClientById,
revokeClient,
rotateClientToken,
} from '~/data/repositories/clients'
import { findProjectById } from '~/data/repositories/projects' import { findProjectById } from '~/data/repositories/projects'
import { hashToken } from './api-auth' import { hashToken } from './api-auth'
import { import {
@@ -112,3 +118,68 @@ export async function performRevokeClient(viewer: Viewer, formData: FormData): P
return saved(id, false) return saved(id, false)
} }
export async function performRotateClient(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
if (!isAdmin(viewer)) {
return invalid('forbidden')
}
const id = readField(formData, 'id')
if (!isUuid(id)) {
return invalid('clientUnknown')
}
const known = await findClientById(id)
if (!known) {
return invalid('clientUnknown')
}
const token = createClientToken()
const rotated = await rotateClientToken(id, hashToken(token))
if (!rotated) {
return invalid('clientUnknown')
}
await recordAudit({
actorId: viewer.id,
actorLabel: viewer.email,
action: 'client.rotate',
entity: 'api_client',
entityId: rotated.id,
data: { name: rotated.name, wasRevoked: known.revokedAt !== null },
})
return { status: 'ok', message: 'clientRotated', id: rotated.id, token }
}
export async function performDeleteClient(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
if (!isAdmin(viewer)) {
return invalid('forbidden')
}
const id = readField(formData, 'id')
if (!isUuid(id)) {
return invalid('clientUnknown')
}
const removed = await deleteClient(id)
if (!removed) {
return invalid('clientUnknown')
}
await recordAudit({
actorId: viewer.id,
actorLabel: viewer.email,
action: 'client.delete',
entity: 'api_client',
entityId: removed.id,
data: { name: removed.name, mode: removed.mode, scope: removed.scope },
})
return { status: 'ok', message: 'clientDeleted', id: removed.id }
}
+80 -1
View File
@@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients' import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients'
import { performCreateClient, performRevokeClient, tokenPrefix } from '~/lib/admin-clients' import {
performCreateClient,
performDeleteClient,
performRevokeClient,
performRotateClient,
tokenPrefix,
} from '~/lib/admin-clients'
import { hashToken } from '~/lib/api-auth' import { hashToken } from '~/lib/api-auth'
import { import {
adminViewer, adminViewer,
@@ -157,3 +163,76 @@ describe('performRevokeClient', () => {
expect((await listClients())[0]?.revokedAt).toBeNull() expect((await listClients())[0]?.revokedAt).toBeNull()
}) })
}) })
async function makeClient(name = 'Claude'): Promise<{ id: string, token: string }> {
const state = await performCreateClient(
adminViewer(),
form({ name, mode: 'write', scope: 'internal' }),
)
if (state.status !== 'ok' || !state.id || !state.token) {
throw new Error('Zugang nicht angelegt')
}
return { id: state.id, token: state.token }
}
function withId(id: string): FormData {
return form({ id })
}
describe('Token erneuern', () => {
it('macht das alte Token ungültig und gibt ein neues zurück', async () => {
const { id, token } = await makeClient()
const state = await performRotateClient(adminViewer(), withId(id))
expect(state.status).toBe('ok')
expect(state.token).toBeDefined()
expect(state.token).not.toBe(token)
expect(await findActiveClientByTokenHash(hashToken(token))).toBeUndefined()
expect(await findActiveClientByTokenHash(hashToken(state.token!))).toMatchObject({ id })
})
it('weckt einen widerrufenen Zugang wieder auf', async () => {
const { id } = await makeClient()
await performRevokeClient(adminViewer(), withId(id))
const state = await performRotateClient(adminViewer(), withId(id))
expect(await findActiveClientByTokenHash(hashToken(state.token!))).toMatchObject({ id, revokedAt: null })
})
it('bleibt Moderatoren verwehrt', async () => {
const { id } = await makeClient()
const state = await performRotateClient(moderatorViewer(), withId(id))
expect(state).toMatchObject({ status: 'error', message: 'forbidden' })
})
})
describe('Zugang löschen', () => {
it('entfernt ihn wirklich, nicht nur als widerrufen', async () => {
const { id, token } = await makeClient()
const state = await performDeleteClient(adminViewer(), withId(id))
expect(state).toMatchObject({ status: 'ok', message: 'clientDeleted' })
expect(await listClients()).toHaveLength(0)
expect(await findActiveClientByTokenHash(hashToken(token))).toBeUndefined()
})
it('meldet einen unbekannten Zugang', async () => {
const state = await performDeleteClient(adminViewer(), withId('11111111-1111-4111-8111-111111111111'))
expect(state).toMatchObject({ status: 'error', message: 'clientUnknown' })
})
it('bleibt Moderatoren verwehrt', async () => {
const { id } = await makeClient()
const state = await performDeleteClient(moderatorViewer(), withId(id))
expect(state).toMatchObject({ status: 'error', message: 'forbidden' })
expect(await listClients()).toHaveLength(1)
})
})
+1 -1
View File
@@ -14,7 +14,7 @@ async function counts() {
} }
} }
describe('seed', () => { describe('seed', { timeout: 30_000 }, () => {
it('legt Marken, Projekte und Beiträge an', async () => { it('legt Marken, Projekte und Beiträge an', async () => {
await seed() await seed()