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?",
"channelRequired": "Zum Bot-Token gehört ein Kanal, sonst weiß Logbuch nicht, wohin.",
"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": {
"nameRequired": "Trag einen Namen ein.",
@@ -712,7 +715,13 @@
"project": "Ohne Bindung gilt der Zugang für alle Projekte."
},
"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",
"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?",
"channelRequired": "A bot token needs a channel, otherwise Logbuch has no target.",
"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": {
"nameRequired": "Enter a name.",
@@ -712,7 +715,13 @@
"project": "Without a binding the access covers all projects."
},
"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",
"allEntries": "All entries",
+2 -2
View File
@@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
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 { EntryDate } from '~/components/ui/EntryDate'
import { SectionLabel } from '~/components/ui/SectionLabel'
@@ -75,7 +75,7 @@ export default async function AdminClientsPage() {
)}
</td>
<td className={cellClass}>
{client.revokedAt ? null : <ClientRevokeButton id={client.id} />}
<ClientRowActions id={client.id} revoked={client.revokedAt !== null} />
</td>
</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]
}
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 { redirect } from 'next/navigation'
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 { performSaveProject } from './admin-projects'
import { performDeleteMessage, performResetWindow, performSaveSlack, performSendReport } from './admin-settings'
@@ -177,3 +182,21 @@ export async function resetReportWindow(_state: AdminFormState): Promise<AdminFo
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 { z } from 'zod'
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 { hashToken } from './api-auth'
import {
@@ -112,3 +118,68 @@ export async function performRevokeClient(viewer: Viewer, formData: FormData): P
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 { 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 {
adminViewer,
@@ -157,3 +163,76 @@ describe('performRevokeClient', () => {
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 () => {
await seed()