diff --git a/core/http/auth/apikeys.go b/core/http/auth/apikeys.go index 295030f2c..975d1ef5d 100644 --- a/core/http/auth/apikeys.go +++ b/core/http/auth/apikeys.go @@ -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 diff --git a/core/http/auth/apikeys_test.go b/core/http/auth/apikeys_test.go index 4441af07a..ab2253692 100644 --- a/core/http/auth/apikeys_test.go +++ b/core/http/auth/apikeys_test.go @@ -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()) + }) + }) }) diff --git a/core/http/auth/models.go b/core/http/auth/models.go index 854d02e6c..6a8d5260d 100644 --- a/core/http/auth/models.go +++ b/core/http/auth/models.go @@ -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. diff --git a/core/http/react-ui/e2e/account-api-key-pause.spec.js b/core/http/react-ui/e2e/account-api-key-pause.spec.js new file mode 100644 index 000000000..149a9cb85 --- /dev/null +++ b/core/http/react-ui/e2e/account-api-key-pause.spec.js @@ -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 } }]) + }) +}) diff --git a/core/http/react-ui/public/locales/de/auth.json b/core/http/react-ui/public/locales/de/auth.json index a60238a20..286420c52 100644 --- a/core/http/react-ui/public/locales/de/auth.json +++ b/core/http/react-ui/public/locales/de/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/en/auth.json b/core/http/react-ui/public/locales/en/auth.json index 242bb5889..24932fdc4 100644 --- a/core/http/react-ui/public/locales/en/auth.json +++ b/core/http/react-ui/public/locales/en/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/es/auth.json b/core/http/react-ui/public/locales/es/auth.json index 6c867d775..8e54ff809 100644 --- a/core/http/react-ui/public/locales/es/auth.json +++ b/core/http/react-ui/public/locales/es/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/id/auth.json b/core/http/react-ui/public/locales/id/auth.json index ca5387e18..ec764254b 100644 --- a/core/http/react-ui/public/locales/id/auth.json +++ b/core/http/react-ui/public/locales/id/auth.json @@ -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" } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/it/auth.json b/core/http/react-ui/public/locales/it/auth.json index a42258ee6..548553da3 100644 --- a/core/http/react-ui/public/locales/it/auth.json +++ b/core/http/react-ui/public/locales/it/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/ko/auth.json b/core/http/react-ui/public/locales/ko/auth.json index 0bc40391b..09e2e4694 100644 --- a/core/http/react-ui/public/locales/ko/auth.json +++ b/core/http/react-ui/public/locales/ko/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/pt-BR/auth.json b/core/http/react-ui/public/locales/pt-BR/auth.json index 2abf32124..dfc9b3f7b 100644 --- a/core/http/react-ui/public/locales/pt-BR/auth.json +++ b/core/http/react-ui/public/locales/pt-BR/auth.json @@ -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": { diff --git a/core/http/react-ui/public/locales/zh-CN/auth.json b/core/http/react-ui/public/locales/zh-CN/auth.json index 16233fdf1..5ec188691 100644 --- a/core/http/react-ui/public/locales/zh-CN/auth.json +++ b/core/http/react-ui/public/locales/zh-CN/auth.json @@ -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": { diff --git a/core/http/react-ui/src/pages/Account.jsx b/core/http/react-ui/src/pages/Account.jsx index a269fdd7b..38323c222 100644 --- a/core/http/react-ui/src/pages/Account.jsx +++ b/core/http/react-ui/src/pages/Account.jsx @@ -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 }) { ) : (
{keys.map((k) => ( -
- -
-
{k.name}
-
- {k.keyPrefix}... · {formatDate(k.createdAt)} - {k.lastUsed && <> · {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}} +
+
+ +
+
+ {k.name} + {isPaused(k) && ( + + {k.pausedUntil && !k.disabled + ? t('account.apiKeys.pausedUntil', { date: formatDate(k.pausedUntil) }) + : t('account.apiKeys.paused')} + + )} +
+
+ {k.keyPrefix}... · {formatDate(k.createdAt)} + {k.lastUsed && <> · {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}} +
+ {isPaused(k) ? ( + + ) : ( + + )} +
- + {pauseFormId === k.id && !isPaused(k) && ( +
+ + + {pauseMode === 'until' && ( + setPauseUntil(e.target.value)} + /> + )} + +
+ )}
))}
diff --git a/core/http/react-ui/src/pages/auth.css b/core/http/react-ui/src/pages/auth.css index 0860decbc..c3618264c 100644 --- a/core/http/react-ui/src/pages/auth.css +++ b/core/http/react-ui/src/pages/auth.css @@ -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); diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js index 3b9f1496a..65c1aa0a1 100644 --- a/core/http/react-ui/src/utils/api.js +++ b/core/http/react-ui/src/utils/api.js @@ -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 diff --git a/core/http/routes/auth.go b/core/http/routes/auth.go index b4144e0a1..26c724496 100644 --- a/core/http/routes/auth.go +++ b/core/http/routes/auth.go @@ -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 { diff --git a/core/http/routes/auth_test.go b/core/http/routes/auth_test.go index 561d3fde9..3877dce0c 100644 --- a/core/http/routes/auth_test.go +++ b/core/http/routes/auth_test.go @@ -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) diff --git a/docs/content/features/authentication.md b/docs/content/features/authentication.md index 9cf758b95..dc2ae5201 100644 --- a/docs/content/features/authentication.md +++ b/docs/content/features/authentication.md @@ -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/ \ + -H "Cookie: session=" \ + -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/ \ + -H "Cookie: session=" \ + -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/ \ + -H "Cookie: session=" \ + -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 |