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:
+11
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user