feat(auth): let users pause and resume their API keys (#12521)

A key can now be paused until the owner resumes it, or until a given
time. A paused key is rejected by validation before last_used is
updated, and a pause time that has passed lifts the pause by itself.
Existing keys stay active.

PATCH /api/auth/api-keys/:id takes {"disabled": bool, "paused_until":
RFC 3339 string or null}. Only the key owner can change it, and a
paused_until in the past is rejected. The key list returns the pause
fields. The Account page gets a Pause and Resume button for each key
and a Paused badge that shows the resume time.

Assisted-by: Claude Code:claude-sonnet-5-5

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-06 18:25:21 +02:00
1 parent 1cd96e496b
commit 690f3994b0
18 files changed
+630 -30

No files matched your search

+30 -1
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
@@ -12,6 +13,9 @@ import (
"gorm.io/gorm"
)
// ErrPauseInPast is returned when a pause end time is not in the future.
var ErrPauseInPast = errors.New("paused_until must be in the future")
const (
apiKeyPrefix = "lai-"
apiKeyRandBytes = 32 // 32 bytes = 64 hex chars
@@ -90,8 +94,12 @@ func ValidateAPIKey(db *gorm.DB, plaintext, hmacSecret string) (*UserAPIKey, err
return nil, fmt.Errorf("user account is not active")
}
// Update LastUsed
now := time.Now()
if key.IsPaused(now) {
return nil, fmt.Errorf("API key is paused")
}
// Update LastUsed
db.Model(&key).Update("last_used", now)
return &key, nil
@@ -115,6 +123,27 @@ func RevokeAPIKey(db *gorm.DB, keyID, userID string) error {
return result.Error
}
// SetAPIKeyPause pauses or resumes an API key. Only the owner can change it.
// A paused key is rejected by ValidateAPIKey. Pass disabled=true to pause
// until resumed, or a pausedUntil in the future to pause until that time.
// Pass disabled=false and a nil pausedUntil to resume.
func SetAPIKeyPause(db *gorm.DB, keyID, userID string, disabled bool, pausedUntil *time.Time) error {
if pausedUntil != nil && !pausedUntil.After(time.Now()) {
return ErrPauseInPast
}
result := db.Model(&UserAPIKey{}).
Where("id = ? AND user_id = ?", keyID, userID).
Updates(map[string]any{"disabled": disabled, "paused_until": pausedUntil})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("API key not found or not owned by user")
}
return nil
}
// CleanExpiredAPIKeys removes all API keys that have passed their expiry time.
func CleanExpiredAPIKeys(db *gorm.DB) error {
return db.Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&UserAPIKey{}).Error
+71
View File
@@ -4,6 +4,7 @@ package auth_test
import (
"strings"
"time"
"github.com/mudler/LocalAI/core/http/auth"
. "github.com/onsi/ginkgo/v2"
@@ -209,4 +210,74 @@ var _ = Describe("API Keys", func() {
Expect(err).To(HaveOccurred())
})
})
Describe("SetAPIKeyPause", func() {
var (
plaintext string
record *auth.UserAPIKey
)
BeforeEach(func() {
var err error
plaintext, record, err = auth.CreateAPIKey(db, user.ID, "pausable", auth.RoleUser, hmacSecret, nil)
Expect(err).ToNot(HaveOccurred())
})
It("keeps new keys active", func() {
Expect(record.Disabled).To(BeFalse())
Expect(record.PausedUntil).To(BeNil())
})
It("rejects a key paused indefinitely and accepts it after resume", func() {
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, true, nil)).To(Succeed())
_, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
Expect(err).To(MatchError(ContainSubstring("paused")))
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, false, nil)).To(Succeed())
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
Expect(err).ToNot(HaveOccurred())
})
It("rejects a key paused until a future time", func() {
until := time.Now().Add(time.Hour)
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, false, &until)).To(Succeed())
_, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
Expect(err).To(MatchError(ContainSubstring("paused")))
})
It("does not update last_used for a paused key", func() {
Expect(auth.SetAPIKeyPause(db, record.ID, user.ID, true, nil)).To(Succeed())
_, _ = auth.ValidateAPIKey(db, plaintext, hmacSecret)
keys, err := auth.ListAPIKeys(db, user.ID)
Expect(err).ToNot(HaveOccurred())
Expect(keys[0].LastUsed).To(BeNil())
})
It("treats a pause time that has passed as active again", func() {
past := time.Now().Add(-time.Minute)
Expect(db.Model(&auth.UserAPIKey{}).Where("id = ?", record.ID).
Update("paused_until", past).Error).To(Succeed())
_, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
Expect(err).ToNot(HaveOccurred())
})
It("rejects a pause time in the past", func() {
past := time.Now().Add(-time.Minute)
err := auth.SetAPIKeyPause(db, record.ID, user.ID, false, &past)
Expect(err).To(MatchError(auth.ErrPauseInPast))
})
It("only allows the owner to pause a key", func() {
other := createTestUser(db, "other-pauser@example.com", auth.RoleAdmin, auth.ProviderGitHub)
err := auth.SetAPIKeyPause(db, record.ID, other.ID, true, nil)
Expect(err).To(HaveOccurred())
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
Expect(err).ToNot(HaveOccurred())
})
})
})
+11 -1
View File
@@ -51,7 +51,17 @@ type UserAPIKey struct {
CreatedAt time.Time
ExpiresAt *time.Time `gorm:"index"`
LastUsed *time.Time
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
// Disabled pauses the key until the owner resumes it.
Disabled bool
// PausedUntil pauses the key until the given time; the key becomes
// active again by itself once that time has passed.
PausedUntil *time.Time
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
}
// IsPaused reports whether the key is paused at the given time.
func (k *UserAPIKey) IsPaused(now time.Time) bool {
return k.Disabled || (k.PausedUntil != nil && k.PausedUntil.After(now))
}
// PermissionMap is a flexible map of feature -> enabled, stored as JSON text.
@@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test'
// Account > API Keys: pause and resume a key without deleting it.
const soon = new Date(Date.now() + 3 * 3600 * 1000).toISOString()
function json(body) {
return { contentType: 'application/json', body: JSON.stringify(body) }
}
test.describe('Account API keys pause', () => {
let keys
let patches
test.beforeEach(async ({ page }) => {
patches = []
keys = [
{ id: 'k1', name: 'active-key', keyPrefix: 'lai-aaaaaaaa', role: 'user', createdAt: new Date().toISOString(), disabled: false },
{ id: 'k2', name: 'paused-key', keyPrefix: 'lai-bbbbbbbb', role: 'user', createdAt: new Date().toISOString(), disabled: true },
{ id: 'k3', name: 'timed-key', keyPrefix: 'lai-cccccccc', role: 'user', createdAt: new Date().toISOString(), disabled: false, pausedUntil: soon },
]
await page.route('**/api/auth/status', (route) =>
route.fulfill(json({
authEnabled: true,
providers: ['local'],
hasUsers: true,
user: { id: 'u1', email: 'u@example.com', name: 'U', role: 'user' },
}))
)
await page.route('**/api/auth/api-keys', (route) => route.fulfill(json({ keys })))
await page.route('**/api/auth/api-keys/*', async (route) => {
const req = route.request()
if (req.method() === 'PATCH') {
const id = req.url().split('/').pop()
const body = req.postDataJSON()
patches.push({ id, body })
const key = keys.find((k) => k.id === id)
key.disabled = body.disabled
key.pausedUntil = body.paused_until || undefined
return route.fulfill(json({ message: 'API key updated' }))
}
return route.continue()
})
await page.goto('/app/account')
await page.getByRole('button', { name: /API Keys/ }).click()
})
test('shows paused badges and resume buttons', async ({ page }) => {
await expect(page.locator('.apikey-item')).toHaveCount(3)
await expect(page.locator('.apikey-item').nth(0).locator('.apikey-paused-badge')).toHaveCount(0)
await expect(page.locator('.apikey-item').nth(1).locator('.apikey-paused-badge')).toHaveText('Paused')
await expect(page.locator('.apikey-item').nth(2).locator('.apikey-paused-badge')).toContainText('Paused until')
})
test('pauses a key indefinitely', async ({ page }) => {
const item = page.locator('.apikey-item').nth(0)
await item.getByRole('button', { name: 'Pause' }).click()
await item.getByRole('button', { name: 'Pause key' }).click()
await expect(item.locator('.apikey-paused-badge')).toHaveText('Paused')
expect(patches).toEqual([{ id: 'k1', body: { disabled: true, paused_until: null } }])
})
test('pauses a key until a chosen time', async ({ page }) => {
const item = page.locator('.apikey-item').nth(0)
await item.getByRole('button', { name: 'Pause' }).click()
await item.getByLabel('Until', { exact: true }).first().check()
await item.locator('input[type="datetime-local"]').fill('2099-01-02T03:04')
await item.getByRole('button', { name: 'Pause key' }).click()
await expect(item.locator('.apikey-paused-badge')).toContainText('Paused until')
expect(patches[0].body.disabled).toBe(false)
expect(patches[0].body.paused_until).toMatch(/^2099-01-0[12]T/)
})
test('resumes a paused key', async ({ page }) => {
const item = page.locator('.apikey-item').nth(1)
await item.getByRole('button', { name: 'Resume' }).click()
await expect(item.locator('.apikey-paused-badge')).toHaveCount(0)
expect(patches).toEqual([{ id: 'k2', body: { disabled: false, paused_until: null } }])
})
})
+11 -1
View File
@@ -101,7 +101,17 @@
"copiedToast": "In die Zwischenablage kopiert",
"copyFailed": "Kopieren fehlgeschlagen",
"empty": "Noch keine API-Schlüssel. Erstellen Sie oben einen für programmgesteuerten Zugriff.",
"lastUsed": "zuletzt verwendet {{date}}"
"lastUsed": "zuletzt verwendet {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
+11 -1
View File
@@ -101,7 +101,17 @@
"copiedToast": "Copied to clipboard",
"copyFailed": "Failed to copy",
"empty": "No API keys yet. Create one above to get programmatic access.",
"lastUsed": "last used {{date}}"
"lastUsed": "last used {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
+11 -1
View File
@@ -101,7 +101,17 @@
"copiedToast": "Copiado al portapapeles",
"copyFailed": "Error al copiar",
"empty": "Aún no hay claves API. Crea una arriba para obtener acceso programático.",
"lastUsed": "último uso {{date}}"
"lastUsed": "último uso {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
+12 -2
View File
@@ -101,7 +101,17 @@
"copiedToast": "Berhasil disalin ke papan klip",
"copyFailed": "Gagal menyalin",
"empty": "Belum ada API key. Buat satu di atas untuk akses terprogram.",
"lastUsed": "terakhir digunakan {{date}}"
"lastUsed": "terakhir digunakan {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
@@ -109,4 +119,4 @@
"text": "Sepertinya halaman yang Anda cari tidak ditemukan. Mari kembalikan ke halaman sebelumnya.",
"goHome": "Kembali ke Beranda"
}
}
}
+11 -1
View File
@@ -101,7 +101,17 @@
"copiedToast": "Copiato negli appunti",
"copyFailed": "Copia non riuscita",
"empty": "Nessuna chiave API. Creane una sopra per ottenere l'accesso programmatico.",
"lastUsed": "ultimo utilizzo {{date}}"
"lastUsed": "ultimo utilizzo {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
+11 -1
View File
@@ -101,7 +101,17 @@
"copiedToast": "클립보드에 복사되었습니다",
"copyFailed": "복사하지 못했습니다",
"empty": "아직 API 키가 없습니다. 위에서 하나를 만들어 프로그래밍 방식 접근을 시작하세요.",
"lastUsed": "마지막 사용 {{date}}"
"lastUsed": "마지막 사용 {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
@@ -101,7 +101,17 @@
"copiedToast": "Copiado para a área de transferência",
"copyFailed": "Falha ao copiar",
"empty": "Nenhuma chave de API ainda. Crie uma acima para obter acesso programático.",
"lastUsed": "último uso em {{date}}"
"lastUsed": "último uso em {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
@@ -101,7 +101,17 @@
"copiedToast": "已复制到剪贴板",
"copyFailed": "复制失败",
"empty": "尚无 API 密钥。在上方创建一个以获得程序化访问。",
"lastUsed": "上次使用 {{date}}"
"lastUsed": "上次使用 {{date}}",
"pause": "Pause",
"resume": "Resume",
"paused": "Paused",
"pausedUntil": "Paused until {{date}}",
"pauseIndefinitely": "Indefinitely",
"pauseUntil": "Until",
"pauseConfirm": "Pause key",
"pausedToast": "API key paused",
"resumedToast": "API key resumed",
"pauseFailed": "Failed to update API key: {{message}}"
}
},
"notFound": {
+116 -15
View File
@@ -253,6 +253,10 @@ function ApiKeysTab({ addToast }) {
const [newKeyPlaintext, setNewKeyPlaintext] = useState(null)
const [revokingId, setRevokingId] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
const [pauseFormId, setPauseFormId] = useState(null)
const [pauseMode, setPauseMode] = useState('indefinite')
const [pauseUntil, setPauseUntil] = useState('')
const [pauseBusyId, setPauseBusyId] = useState(null)
const fetchKeys = useCallback(async () => {
setLoading(true)
@@ -307,6 +311,38 @@ function ApiKeysTab({ addToast }) {
})
}
const isPaused = (k) => k.disabled || (k.pausedUntil && new Date(k.pausedUntil) > new Date())
const applyPause = async (id, disabled, pausedUntil) => {
setPauseBusyId(id)
try {
await apiKeysApi.setPause(id, disabled, pausedUntil)
setPauseFormId(null)
setPauseUntil('')
await fetchKeys()
addToast(t(disabled || pausedUntil ? 'account.apiKeys.pausedToast' : 'account.apiKeys.resumedToast'), 'success')
} catch (err) {
addToast(t('account.apiKeys.pauseFailed', { message: err.message }), 'error')
} finally {
setPauseBusyId(null)
}
}
const submitPause = (id) => {
if (pauseMode === 'until') {
if (!pauseUntil) return
applyPause(id, false, new Date(pauseUntil).toISOString())
} else {
applyPause(id, true, null)
}
}
const openPauseForm = (id) => {
setPauseMode('indefinite')
setPauseUntil('')
setPauseFormId(id)
}
const copyToClipboard = (text) => {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(
@@ -394,23 +430,88 @@ function ApiKeysTab({ addToast }) {
) : (
<div className="card">
{keys.map((k) => (
<div key={k.id} className="apikey-row">
<i className="fas fa-key apikey-icon" />
<div className="apikey-info">
<div className="apikey-name">{k.name}</div>
<div className="apikey-details">
{k.keyPrefix}... &middot; {formatDate(k.createdAt)}
{k.lastUsed && <> &middot; {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}</>}
<div key={k.id} className="apikey-item">
<div className="apikey-row">
<i className="fas fa-key apikey-icon" />
<div className="apikey-info">
<div className="apikey-name">
{k.name}
{isPaused(k) && (
<span className="apikey-paused-badge">
{k.pausedUntil && !k.disabled
? t('account.apiKeys.pausedUntil', { date: formatDate(k.pausedUntil) })
: t('account.apiKeys.paused')}
</span>
)}
</div>
<div className="apikey-details">
{k.keyPrefix}... &middot; {formatDate(k.createdAt)}
{k.lastUsed && <> &middot; {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}</>}
</div>
</div>
{isPaused(k) ? (
<button
className="btn btn-secondary btn-sm"
onClick={() => applyPause(k.id, false, null)}
disabled={pauseBusyId === k.id}
>
{pauseBusyId === k.id ? <LoadingSpinner size="sm" /> : <><i className="fas fa-play" /> {t('account.apiKeys.resume')}</>}
</button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={() => (pauseFormId === k.id ? setPauseFormId(null) : openPauseForm(k.id))}
>
<i className="fas fa-pause" /> {t('account.apiKeys.pause')}
</button>
)}
<button
className="btn btn-sm apikey-revoke-btn"
onClick={() => handleRevoke(k.id, k.name)}
disabled={revokingId === k.id}
title={t('account.apiKeys.revokeKey')}
>
{revokingId === k.id ? <LoadingSpinner size="sm" /> : <i className="fas fa-trash" />}
</button>
</div>
<button
className="btn btn-sm apikey-revoke-btn"
onClick={() => handleRevoke(k.id, k.name)}
disabled={revokingId === k.id}
title={t('account.apiKeys.revokeKey')}
>
{revokingId === k.id ? <LoadingSpinner size="sm" /> : <i className="fas fa-trash" />}
</button>
{pauseFormId === k.id && !isPaused(k) && (
<div className="apikey-pause-form">
<label className="apikey-pause-option">
<input
type="radio"
name={`pause-mode-${k.id}`}
checked={pauseMode === 'indefinite'}
onChange={() => setPauseMode('indefinite')}
/>
{t('account.apiKeys.pauseIndefinitely')}
</label>
<label className="apikey-pause-option">
<input
type="radio"
name={`pause-mode-${k.id}`}
checked={pauseMode === 'until'}
onChange={() => setPauseMode('until')}
/>
{t('account.apiKeys.pauseUntil')}
</label>
{pauseMode === 'until' && (
<input
type="datetime-local"
className="input apikey-pause-date"
aria-label={t('account.apiKeys.pauseUntil')}
value={pauseUntil}
onChange={(e) => setPauseUntil(e.target.value)}
/>
)}
<button
className="btn btn-primary btn-sm"
onClick={() => submitPause(k.id)}
disabled={pauseBusyId === k.id || (pauseMode === 'until' && !pauseUntil)}
>
{t('account.apiKeys.pauseConfirm')}
</button>
</div>
)}
</div>
))}
</div>
+30 -1
View File
@@ -468,10 +468,39 @@
padding: var(--spacing-sm) 0;
}
.apikey-row:not(:last-child) {
.apikey-item:not(:last-child) {
border-bottom: 1px solid var(--color-border-subtle);
}
.apikey-paused-badge {
margin-left: var(--spacing-sm);
font-size: 0.6875rem;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--color-warning-light);
color: var(--color-warning);
}
.apikey-pause-form {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-sm);
padding: 0 0 var(--spacing-sm) 24px;
font-size: 0.8125rem;
}
.apikey-pause-option {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.apikey-pause-date {
width: auto;
}
.apikey-icon {
font-size: 0.6875rem;
color: var(--color-text-muted);
+6
View File
@@ -567,6 +567,12 @@ export const apiKeysApi = {
list: () => fetchJSON('/api/auth/api-keys'),
create: (name) => postJSON('/api/auth/api-keys', { name }),
revoke: (id) => fetchJSON(`/api/auth/api-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// pausedUntil is an RFC3339 string or null; disabled pauses until resumed.
setPause: (id, disabled, pausedUntil = null) => fetchJSON(`/api/auth/api-keys/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled, paused_until: pausedUntil }),
}),
}
// Fine-tuning API
+39
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/mail"
@@ -732,10 +733,14 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
"role": k.Role,
"createdAt": k.CreatedAt,
"lastUsed": k.LastUsed,
"disabled": k.Disabled,
}
if k.ExpiresAt != nil {
entry["expiresAt"] = k.ExpiresAt
}
if k.PausedUntil != nil {
entry["pausedUntil"] = k.PausedUntil
}
result = append(result, entry)
}
@@ -757,6 +762,40 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
})
// PATCH /api/auth/api-keys/:id - pause or resume an API key
e.PATCH("/api/auth/api-keys/:id", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
var body struct {
Disabled bool `json:"disabled"`
PausedUntil *string `json:"paused_until"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
var pausedUntil *time.Time
if body.PausedUntil != nil && *body.PausedUntil != "" {
t, err := time.Parse(time.RFC3339, *body.PausedUntil)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid paused_until format, use RFC3339"})
}
pausedUntil = &t
}
if err := auth.SetAPIKeyPause(db, c.Param("id"), user.ID, body.Disabled, pausedUntil); err != nil {
if errors.Is(err, auth.ErrPauseInPast) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "API key updated"})
})
// Usage endpoints
// GET /api/auth/usage - user's own usage
e.GET("/api/auth/usage", func(c echo.Context) error {
+131 -3
View File
@@ -5,6 +5,7 @@ package routes_test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -217,9 +218,11 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
result := make([]map[string]any, 0, len(keys))
for _, k := range keys {
result = append(result, map[string]any{
"id": k.ID,
"name": k.Name,
"keyPrefix": k.KeyPrefix,
"id": k.ID,
"name": k.Name,
"keyPrefix": k.KeyPrefix,
"disabled": k.Disabled,
"pausedUntil": k.PausedUntil,
})
}
return c.JSON(http.StatusOK, map[string]any{"keys": result})
@@ -238,6 +241,40 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
})
// PATCH /api/auth/api-keys/:id - pause or resume an API key
e.PATCH("/api/auth/api-keys/:id", func(c echo.Context) error {
user := auth.GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
}
var body struct {
Disabled bool `json:"disabled"`
PausedUntil *string `json:"paused_until"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
var pausedUntil *time.Time
if body.PausedUntil != nil && *body.PausedUntil != "" {
t, err := time.Parse(time.RFC3339, *body.PausedUntil)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid paused_until format, use RFC3339"})
}
pausedUntil = &t
}
if err := auth.SetAPIKeyPause(db, c.Param("id"), user.ID, body.Disabled, pausedUntil); err != nil {
if errors.Is(err, auth.ErrPauseInPast) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "API key updated"})
})
// Admin: GET /api/auth/admin/users
adminMw := auth.RequireAdmin()
e.GET("/api/auth/admin/users", func(c echo.Context) error {
@@ -619,6 +656,97 @@ var _ = Describe("Auth Routes", Label("auth"), func() {
})
})
Context("PATCH /api/auth/api-keys/:id", func() {
patchKey := func(app *echo.Echo, id, sessionID string, body map[string]any) *httptest.ResponseRecorder {
b, _ := json.Marshal(body)
return doAuthRequest(app, "PATCH", "/api/auth/api-keys/"+id, b, withSession(sessionID))
}
It("pauses a key indefinitely and resumes it", func() {
user := createRouteTestUser(db, "pause@test.com", auth.RoleUser)
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "pausable", auth.RoleUser, "", nil)
Expect(err).ToNot(HaveOccurred())
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := patchKey(app, record.ID, sessionID, map[string]any{"disabled": true, "paused_until": nil})
Expect(rec.Code).To(Equal(http.StatusOK))
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
rec = doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
entry := resp["keys"].([]any)[0].(map[string]any)
Expect(entry["disabled"]).To(BeTrue())
rec = patchKey(app, record.ID, sessionID, map[string]any{"disabled": false, "paused_until": nil})
Expect(rec.Code).To(Equal(http.StatusOK))
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
Expect(rec.Code).To(Equal(http.StatusOK))
})
It("pauses a key until a future time and lists the resume time", func() {
user := createRouteTestUser(db, "pause-until@test.com", auth.RoleUser)
plaintext, record, _ := auth.CreateAPIKey(db, user.ID, "timed", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
until := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
rec := patchKey(app, record.ID, sessionID, map[string]any{"disabled": false, "paused_until": until})
Expect(rec.Code).To(Equal(http.StatusOK))
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
rec = doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
entry := resp["keys"].([]any)[0].(map[string]any)
Expect(entry["pausedUntil"]).ToNot(BeNil())
})
It("rejects a pause time in the past", func() {
user := createRouteTestUser(db, "pause-past@test.com", auth.RoleUser)
_, record, _ := auth.CreateAPIKey(db, user.ID, "k", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
rec := patchKey(app, record.ID, sessionID, map[string]any{"paused_until": past})
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("rejects a malformed pause time", func() {
user := createRouteTestUser(db, "pause-bad@test.com", auth.RoleUser)
_, record, _ := auth.CreateAPIKey(db, user.ID, "k", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, user.ID, "")
app := newTestAuthApp(db, appConfig)
rec := patchKey(app, record.ID, sessionID, map[string]any{"paused_until": "tomorrow"})
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 404 for another user's key", func() {
owner := createRouteTestUser(db, "pause-owner@test.com", auth.RoleUser)
other := createRouteTestUser(db, "pause-other@test.com", auth.RoleAdmin)
_, record, _ := auth.CreateAPIKey(db, owner.ID, "k", auth.RoleUser, "", nil)
sessionID, _ := auth.CreateSession(db, other.ID, "")
app := newTestAuthApp(db, appConfig)
rec := patchKey(app, record.ID, sessionID, map[string]any{"disabled": true})
Expect(rec.Code).To(Equal(http.StatusNotFound))
})
It("returns 401 when not authenticated", func() {
app := newTestAuthApp(db, appConfig)
rec := doAuthRequest(app, "PATCH", "/api/auth/api-keys/x", []byte(`{"disabled":true}`))
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
})
Context("Admin: GET /api/auth/admin/users", func() {
It("returns all users for admin", func() {
admin := createRouteTestUser(db, "admin@test.com", auth.RoleAdmin)
+25
View File
@@ -291,6 +291,30 @@ curl -X POST http://localhost:8080/api/auth/api-keys \
User API keys inherit the creating user's role. Admin keys grant admin access; user keys grant user-level access.
You can pause a key without deleting it, and resume it later. Only the key's owner can pause it. A paused key is rejected like an invalid key until it is resumed. Pause it until you resume it, or until a time in the future, after which it works again by itself:
```bash
# Pause until resumed
curl -X PATCH http://localhost:8080/api/auth/api-keys/<key-id> \
-H "Cookie: session=<session-id>" \
-H "Content-Type: application/json" \
-d '{"disabled": true, "paused_until": null}'
# Pause until a given time (RFC 3339, must be in the future)
curl -X PATCH http://localhost:8080/api/auth/api-keys/<key-id> \
-H "Cookie: session=<session-id>" \
-H "Content-Type: application/json" \
-d '{"disabled": false, "paused_until": "2030-01-01T00:00:00Z"}'
# Resume
curl -X PATCH http://localhost:8080/api/auth/api-keys/<key-id> \
-H "Cookie: session=<session-id>" \
-H "Content-Type: application/json" \
-d '{"disabled": false, "paused_until": null}'
```
The key list returns `disabled` and, when set, `pausedUntil` for each key. The Account page in the web UI has a Pause and Resume button for each key.
### Auth API Endpoints
| Method | Endpoint | Description | Auth Required |
@@ -307,6 +331,7 @@ User API keys inherit the creating user's role. Admin keys grant admin access; u
| `GET` | `/api/auth/me` | Current user info | Yes |
| `POST` | `/api/auth/api-keys` | Create API key | Yes |
| `GET` | `/api/auth/api-keys` | List user's API keys | Yes |
| `PATCH` | `/api/auth/api-keys/:id` | Pause or resume API key | Yes |
| `DELETE` | `/api/auth/api-keys/:id` | Revoke API key | Yes |
| `GET` | `/api/auth/usage` | User's own usage stats | Yes |
| `GET` | `/api/auth/usage/sources` | User's own per-API-key / per-source breakdown | Yes |