mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat: add workspace agent API keys (#538)
Enforce owner/admin management, terminal expired/revoked lifecycle, explicit workspace scopes, current membership rechecks, and authenticated management UI.
This commit is contained in:
committed by
GitHub
parent
4817afecdb
commit
1b1b1db772
File diff suppressed because it is too large
Load Diff
@@ -275,11 +275,13 @@ Manual API-key creation is the initial CI path:
|
||||
|
||||
New Agent keys never use `scope.mode = "user-workspaces"`. One key authorizes one
|
||||
workspace. Expiry is required, defaults to 90 days, and cannot exceed one year.
|
||||
Use one key per CI environment.
|
||||
Use one key per CI environment. Personal workspace owners and team
|
||||
owners/admins can manage Agent keys; team editors cannot issue credentials.
|
||||
|
||||
The UI lists name, workspace, permission summary, creation, expiry, last use,
|
||||
and status. Revocation is immediate. Rotation creates a new key and never
|
||||
reveals or mutates the old secret.
|
||||
and status. Revocation is immediate. Only active keys can rotate. Rotation
|
||||
creates a new key and never reveals or mutates the old secret; expired and
|
||||
revoked keys are terminal, so the user creates a new key instead.
|
||||
|
||||
## 11. OpenAPI and Restish v2 Binding
|
||||
|
||||
@@ -417,7 +419,7 @@ Agent Access settings show two sections:
|
||||
|
||||
- delegated OAuth grants, with client, workspace, scopes, last use, and revoke;
|
||||
- service API keys, with name, workspace, permissions, expiry, last use, and
|
||||
revoke/replace.
|
||||
revoke/rotate for active keys.
|
||||
|
||||
Revoking a delegated grant invalidates its refresh tokens and prevents new
|
||||
access tokens. Short access-token lifetime bounds any validation-cache delay.
|
||||
|
||||
@@ -226,6 +226,7 @@ Defaults:
|
||||
- exactly one workspace
|
||||
- least-privilege permissions selected by the user
|
||||
- explicit name, expiry, last-used time, and revocation
|
||||
- personal owners and team owners/admins manage keys; editors cannot issue credentials
|
||||
- separate keys for separate Agents and environments
|
||||
- no admin, billing, membership, entitlement, or credential-management access
|
||||
- team membership and role rechecked at authorization boundaries
|
||||
@@ -234,6 +235,9 @@ Defaults:
|
||||
The plaintext key is returned once and stored in a CI secret or another
|
||||
non-interactive secret store.
|
||||
|
||||
Only active keys can be rotated. Expired and revoked keys are terminal; create
|
||||
a new key when a new lifetime or credential is required.
|
||||
|
||||
### Scopes and Presets
|
||||
|
||||
The server has one canonical authorization vocabulary. OAuth grants, API keys,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { inArray } from 'drizzle-orm'
|
||||
import { apikey } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
|
||||
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD]
|
||||
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD, ApiKeyTemplate.AGENT]
|
||||
|
||||
export function scopeForApiKey(configId: string, metadata: unknown): ApiKeyScope | null {
|
||||
const scope = parseApiKeyScope(metadata)
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { defaultKeyHasher } from '@better-auth/api-key'
|
||||
import { API_KEY_TEMPLATES, type ApiKeyPermissions, type ApiKeyTemplate } from '@shared/api-key-templates'
|
||||
import { authorizationScope, hasAuthorizationScope } from '@shared/authorization'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { apikey } from '../../db/auth-schema'
|
||||
import {
|
||||
AGENT_GRANTABLE_API_KEY_SCOPES,
|
||||
API_KEY_TEMPLATES,
|
||||
type ApiKeyPermissions,
|
||||
ApiKeyTemplate,
|
||||
type ApiKeyTemplate as ApiKeyTemplateId,
|
||||
apiKeyMetadata,
|
||||
} from '@shared/api-key-templates'
|
||||
import {
|
||||
type AuthorizationScope,
|
||||
authorizationScope,
|
||||
hasAuthorizationScope,
|
||||
permissionScopes,
|
||||
scopePermissions,
|
||||
} from '@shared/authorization'
|
||||
import type { AgentApiKey, AgentGrantableScope } from '@shared/schemas'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { apikey, organization } from '../../db/auth-schema'
|
||||
import { executeWriteTransaction } from '../../db/transaction'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports'
|
||||
import { scopeForApiKey } from './api-key-scopes'
|
||||
|
||||
const AGENT_API_KEY_PREFIX = 'zpan_agent_'
|
||||
const AGENT_GRANTABLE_SCOPE_SET = new Set<AuthorizationScope>(AGENT_GRANTABLE_API_KEY_SCOPES)
|
||||
|
||||
type VerifyApiKeyResult = {
|
||||
valid: boolean
|
||||
error: { message: string; code: string; details?: { tryAgainIn?: number } } | null
|
||||
@@ -53,9 +72,155 @@ export function createApiKeyGateway(): ApiKeyGateway {
|
||||
hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope) {
|
||||
return hasAuthorizationScope(permissions, scope)
|
||||
},
|
||||
|
||||
async listAgentApiKeys(db, userId, orgId, now) {
|
||||
const rows = await listAgentRows(db, userId, orgId)
|
||||
return rows.map((row) => toAgentApiKeyDTO(row, now))
|
||||
},
|
||||
|
||||
async getAgentApiKey(db, userId, orgId, keyId, now) {
|
||||
const row = await getAgentRow(db, userId, orgId, keyId)
|
||||
return row ? toAgentApiKeyDTO(row, now) : null
|
||||
},
|
||||
|
||||
async issueAgentApiKey(db, input) {
|
||||
const now = new Date()
|
||||
const id = crypto.randomUUID()
|
||||
const key = `${AGENT_API_KEY_PREFIX}${nanoid(48)}`
|
||||
const hashedKey = await defaultKeyHasher(key)
|
||||
const insert = db.insert(apikey).values({
|
||||
id,
|
||||
configId: ApiKeyTemplate.AGENT,
|
||||
name: input.name,
|
||||
start: key.slice(0, AGENT_API_KEY_PREFIX.length + 6),
|
||||
referenceId: input.userId,
|
||||
prefix: AGENT_API_KEY_PREFIX,
|
||||
key: hashedKey,
|
||||
enabled: true,
|
||||
rateLimitEnabled: true,
|
||||
rateLimitTimeWindow: 60_000,
|
||||
rateLimitMax: 600,
|
||||
requestCount: 0,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
permissions: JSON.stringify(scopePermissions(input.scopes)),
|
||||
metadata: JSON.stringify(apiKeyMetadata({ mode: 'workspace', orgId: input.orgId })),
|
||||
})
|
||||
const revoke = input.revokeKeyId
|
||||
? db.update(apikey).set({ enabled: false, updatedAt: now }).where(eq(apikey.id, input.revokeKeyId))
|
||||
: null
|
||||
await executeWriteTransaction(db, revoke ? [insert, revoke] : [insert])
|
||||
const row = await getAgentRow(db, input.userId, input.orgId, id)
|
||||
if (!row) throw new Error('agent_api_key_create_failed')
|
||||
return { key, item: toAgentApiKeyDTO(row, now) }
|
||||
},
|
||||
|
||||
async revokeAgentApiKey(db, keyId) {
|
||||
await db.update(apikey).set({ enabled: false, updatedAt: new Date() }).where(eq(apikey.id, keyId))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type AgentApiKeyRow = {
|
||||
id: string
|
||||
name: string | null
|
||||
permissions: string | null
|
||||
metadata: string | null
|
||||
enabled: boolean
|
||||
createdAt: Date | number | string
|
||||
expiresAt: Date | number | string | null
|
||||
lastRequest: Date | number | string | null
|
||||
workspaceName: string | null
|
||||
}
|
||||
|
||||
async function listAgentRows(db: Database, userId: string, orgId: string): Promise<AgentApiKeyRow[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apikey.id,
|
||||
name: apikey.name,
|
||||
permissions: apikey.permissions,
|
||||
metadata: apikey.metadata,
|
||||
enabled: apikey.enabled,
|
||||
createdAt: apikey.createdAt,
|
||||
expiresAt: apikey.expiresAt,
|
||||
lastRequest: apikey.lastRequest,
|
||||
workspaceName: organization.name,
|
||||
})
|
||||
.from(apikey)
|
||||
.leftJoin(organization, eq(organization.id, orgId))
|
||||
.where(and(eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId)))
|
||||
.orderBy(desc(apikey.createdAt))
|
||||
return rows.filter((row) => parseWorkspaceMetadata(row.metadata)?.orgId === orgId)
|
||||
}
|
||||
|
||||
async function getAgentRow(db: Database, userId: string, orgId: string, keyId: string): Promise<AgentApiKeyRow | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apikey.id,
|
||||
name: apikey.name,
|
||||
permissions: apikey.permissions,
|
||||
metadata: apikey.metadata,
|
||||
enabled: apikey.enabled,
|
||||
createdAt: apikey.createdAt,
|
||||
expiresAt: apikey.expiresAt,
|
||||
lastRequest: apikey.lastRequest,
|
||||
workspaceName: organization.name,
|
||||
})
|
||||
.from(apikey)
|
||||
.leftJoin(organization, eq(organization.id, orgId))
|
||||
.where(and(eq(apikey.id, keyId), eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId)))
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
return row && parseWorkspaceMetadata(row.metadata)?.orgId === orgId ? row : null
|
||||
}
|
||||
|
||||
function toAgentApiKeyDTO(row: AgentApiKeyRow, now: Date): AgentApiKey {
|
||||
const scope = parseWorkspaceMetadata(row.metadata)
|
||||
if (!scope) throw new Error('agent_api_key_workspace_scope_missing')
|
||||
const expiresAt = requireDate(row.expiresAt, 'agent_api_key_expiry_missing')
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name ?? row.id,
|
||||
orgId: scope.orgId,
|
||||
workspaceName: row.workspaceName,
|
||||
scopes: parseStoredScopes(row.permissions),
|
||||
createdAt: toIso(row.createdAt),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
lastUsedAt: row.lastRequest ? toIso(row.lastRequest) : null,
|
||||
status: !row.enabled ? 'revoked' : expiresAt <= now ? 'expired' : 'active',
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkspaceMetadata(value: string | null): { orgId: string } | null {
|
||||
if (!value) return null
|
||||
const parsed = JSON.parse(value) as { scope?: { mode?: unknown; orgId?: unknown } }
|
||||
return parsed.scope?.mode === 'workspace' && typeof parsed.scope.orgId === 'string'
|
||||
? { orgId: parsed.scope.orgId }
|
||||
: null
|
||||
}
|
||||
|
||||
function parseStoredScopes(value: string | null): AgentGrantableScope[] {
|
||||
if (!value) return []
|
||||
const permissions = JSON.parse(value) as ApiKeyPermissions
|
||||
return permissionScopes(permissions).filter((scope): scope is AgentGrantableScope =>
|
||||
AGENT_GRANTABLE_SCOPE_SET.has(scope),
|
||||
)
|
||||
}
|
||||
|
||||
function requireDate(value: Date | number | string | null, message: string): Date {
|
||||
if (value === null) throw new Error(message)
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) throw new Error(message)
|
||||
return date
|
||||
}
|
||||
|
||||
function toIso(value: Date | number | string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) throw new Error('invalid_agent_api_key_date')
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
async function normalizeVerifiedApiKey(key: NonNullable<VerifyApiKeyResult['key']>): Promise<VerifiedApiKey | null> {
|
||||
const scope = scopeForApiKey(key.configId, key.metadata)
|
||||
if (!scope) return null
|
||||
@@ -82,9 +247,9 @@ function throwIfRateLimited(result: VerifyApiKeyResult | null) {
|
||||
throw new ApiKeyRateLimitError(result.error.message, result.error.details?.tryAgainIn)
|
||||
}
|
||||
|
||||
async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise<ApiKeyTemplate | null> {
|
||||
async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise<ApiKeyTemplateId | null> {
|
||||
const hashedKey = await defaultKeyHasher(rawKey)
|
||||
const rows = await db.select({ configId: apikey.configId }).from(apikey).where(eq(apikey.key, hashedKey)).limit(1)
|
||||
const configId = rows[0]?.configId
|
||||
return configId && API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate) ? (configId as ApiKeyTemplate) : null
|
||||
return configId && API_KEY_TEMPLATES.includes(configId as ApiKeyTemplateId) ? (configId as ApiKeyTemplateId) : null
|
||||
}
|
||||
|
||||
@@ -159,3 +159,19 @@ describe('isPersonalOrg', () => {
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canManageAgentAccess', () => {
|
||||
it.each([
|
||||
['owner', true],
|
||||
['admin', true],
|
||||
['editor', false],
|
||||
['viewer', false],
|
||||
])('allows Agent Access management for %s: %s', async (role, expected) => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db, { metadata: '{"type":"team"}' })
|
||||
await insertMember(db, orgId, userId, role)
|
||||
|
||||
expect(await createOrgRepo(db).canManageAgentAccess(userId, orgId)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { member, organization } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { OrgRepo } from '../../usecases/ports'
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
|
||||
|
||||
export function createOrgRepo(db: Database): OrgRepo {
|
||||
// Find the user's personal org, if they still belong to it. New personal orgs
|
||||
@@ -55,5 +55,11 @@ export function createOrgRepo(db: Database): OrgRepo {
|
||||
return orgId === (await findPersonalOrg(userId))
|
||||
}
|
||||
|
||||
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, isPersonalOrg }
|
||||
async function canManageAgentAccess(userId: string, orgId: string): Promise<boolean> {
|
||||
const role = await getMemberRole(orgId, userId)
|
||||
if (role !== null) return role === 'owner' || role === 'admin'
|
||||
return orgId === (await findPersonalOrg(userId))
|
||||
}
|
||||
|
||||
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createDeps } from './composition'
|
||||
import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url'
|
||||
import { adminOverview } from './http/admin-overview'
|
||||
import { adminStats } from './http/admin-stats'
|
||||
import agentApiKeys from './http/agent-api-keys'
|
||||
import { serveAvatarBlob } from './http/avatar-blobs'
|
||||
import backgroundJobs from './http/background-jobs'
|
||||
import { configz } from './http/configz'
|
||||
@@ -253,6 +254,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
app.route('/api/objects', objects)
|
||||
app.route('/api/shares', authedShares)
|
||||
app.route('/api/trash', trash)
|
||||
app.route('/api/workspaces', agentApiKeys)
|
||||
app.route('/api/teams', teams)
|
||||
app.route('/api/teams', adminTeams)
|
||||
app.route('/api/site/storages', storages)
|
||||
@@ -400,3 +402,4 @@ export type AdminAuditRoute = typeof adminAudit
|
||||
export type AdminOverviewRoute = typeof adminOverview
|
||||
export type AdminStatsRoute = typeof adminStats
|
||||
export type StorageUsageRoute = typeof storageUsage
|
||||
export type AgentApiKeysRoute = typeof agentApiKeys
|
||||
|
||||
@@ -429,6 +429,9 @@ export async function createAuth(
|
||||
if (!body) return
|
||||
const configId = body.configId
|
||||
if (typeof configId !== 'string' || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return
|
||||
if (configId === ApiKeyTemplate.AGENT) {
|
||||
throw new APIError('BAD_REQUEST', { message: 'Create Agent API keys from the Agent Access API' })
|
||||
}
|
||||
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
const userId = session?.user.id ?? (typeof body?.userId === 'string' ? body.userId : null)
|
||||
@@ -652,6 +655,19 @@ export async function createAuth(
|
||||
defaultPermissions: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
|
||||
},
|
||||
},
|
||||
{
|
||||
configId: ApiKeyTemplate.AGENT,
|
||||
references: 'user',
|
||||
enableMetadata: true,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
timeWindow: 60_000,
|
||||
maxRequests: 600,
|
||||
},
|
||||
permissions: {
|
||||
defaultPermissions: {},
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
databaseHooks: {
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { defaultKeyHasher } from '@better-auth/api-key'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
type TestApp = Awaited<ReturnType<typeof createTestApp>>
|
||||
|
||||
function futureIso(days: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
async function getUserAndPersonalOrg(db: TestApp['db'], email = 'test@example.com') {
|
||||
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
|
||||
const orgs = await db.all<{ id: string }>(sql`
|
||||
SELECT o.id
|
||||
FROM organization o
|
||||
INNER JOIN member m ON m.organization_id = o.id
|
||||
WHERE m.user_id = ${users[0]?.id} AND o.metadata LIKE '%"type":"personal"%'
|
||||
LIMIT 1
|
||||
`)
|
||||
if (!users[0] || !orgs[0]) throw new Error('expected user and personal org')
|
||||
return { userId: users[0].id, orgId: orgs[0].id }
|
||||
}
|
||||
|
||||
async function insertStorage(db: TestApp['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, enabled, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'st-agent', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1',
|
||||
'AKIAIOSFODNN7EXAMPLE', 'secret', '', '', 0, 0, 1, 'untested',
|
||||
0, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
async function insertFile(db: TestApp['db'], orgId: string, id: string) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
|
||||
VALUES (${id}, ${orgId}, ${`${id}-alias`}, ${`${id}.txt`}, 'text/plain', 100, 0, '', 'some/key.txt', 'st-agent', 'active', NULL, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
async function insertLandingShare(
|
||||
db: TestApp['db'],
|
||||
input: { token: string; orgId: string; matterId: string; userId: string },
|
||||
) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, status, private, created_at)
|
||||
VALUES (${`${input.token}-id`}, ${input.token}, 'landing', ${input.matterId}, ${input.orgId}, ${input.userId}, 'active', 0, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
async function insertTeamOrg(db: TestApp['db'], orgId: string, userId: string, role = 'editor') {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
|
||||
VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO member (id, organization_id, user_id, role, created_at)
|
||||
VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, ${role}, ${now})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
|
||||
VALUES (${`${orgId}-quota`}, ${orgId}, 1000000, 0, 0, 0, '1970-01')
|
||||
`)
|
||||
}
|
||||
|
||||
async function createAgentKey(app: TestApp['app'], headers: Record<string, string>, orgId: string, scopes: string[]) {
|
||||
const res = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'CI', scopes, expiresAt: futureIso(90) }),
|
||||
})
|
||||
if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`)
|
||||
return (await res.json()) as { key: string; item: { id: string; orgId: string; scopes: string[]; status: string } }
|
||||
}
|
||||
|
||||
async function insertLegacyAgentKey(db: TestApp['db'], userId: string): Promise<string> {
|
||||
const now = Date.now()
|
||||
const key = 'zpan_agent_legacy_integration_key'
|
||||
const hashedKey = await defaultKeyHasher(key)
|
||||
await db.run(sql`
|
||||
INSERT INTO apikey (
|
||||
id, config_id, name, start, reference_id, prefix, key,
|
||||
enabled, rate_limit_enabled, rate_limit_time_window, rate_limit_max, request_count,
|
||||
expires_at, created_at, updated_at, permissions, metadata
|
||||
)
|
||||
VALUES (
|
||||
'legacy-agent-key', 'agent', 'Legacy Agent key', 'zpan_age', ${userId}, 'zpan_agent_', ${hashedKey},
|
||||
1, 1, 60000, 600, 0,
|
||||
${now + 90 * 24 * 60 * 60 * 1000}, ${now}, ${now}, '{"objects":["read"]}', NULL
|
||||
)
|
||||
`)
|
||||
return key
|
||||
}
|
||||
|
||||
describe('Agent API keys', () => {
|
||||
it('creates, lists, rotates, and revokes a personal workspace key [spec: agent-api-keys/lifecycle]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { orgId } = await getUserAndPersonalOrg(db)
|
||||
|
||||
const created = await createAgentKey(app, headers, orgId, ['objects:read'])
|
||||
expect(created.key).toMatch(/^zpan_agent_/)
|
||||
expect(created.item).toMatchObject({ orgId, scopes: ['objects:read'], status: 'active' })
|
||||
|
||||
const list = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, { headers })
|
||||
expect(list.status).toBe(200)
|
||||
const listed = (await list.json()) as { items: Array<{ id: string; key?: string }> }
|
||||
expect(listed.items.map((item) => item.id)).toContain(created.item.id)
|
||||
expect(listed.items[0]?.key).toBeUndefined()
|
||||
|
||||
const rotated = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}/rotations`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(rotated.status).toBe(201)
|
||||
const rotatedBody = (await rotated.json()) as { key: string; item: { id: string } }
|
||||
expect(rotatedBody.key).toMatch(/^zpan_agent_/)
|
||||
expect(rotatedBody.item.id).not.toBe(created.item.id)
|
||||
|
||||
const revoke = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${rotatedBody.item.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(revoke.status).toBe(204)
|
||||
})
|
||||
|
||||
it('creates and uses a team workspace key for allowed file operations [spec: agent-api-keys/team-file-ops]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const { userId } = await getUserAndPersonalOrg(db)
|
||||
await insertTeamOrg(db, 'agent-team', userId, 'owner')
|
||||
await insertFile(db, 'agent-team', 'agent-readable')
|
||||
const created = await createAgentKey(app, headers, 'agent-team', ['objects:read', 'objects:create'])
|
||||
await db.run(sql`UPDATE member SET role = 'editor' WHERE organization_id = 'agent-team' AND user_id = ${userId}`)
|
||||
const auth = { Authorization: `Bearer ${created.key}` }
|
||||
|
||||
const list = await app.request('/api/objects', { headers: auth })
|
||||
expect(list.status).toBe(200)
|
||||
const listBody = (await list.json()) as { items: Array<{ id: string }> }
|
||||
expect(listBody.items.map((item) => item.id)).toContain('agent-readable')
|
||||
|
||||
const create = await app.request('/api/objects', {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'agent-folder', type: 'folder', dirtype: 1, parent: '' }),
|
||||
})
|
||||
expect(create.status).toBe(201)
|
||||
})
|
||||
|
||||
it('allows team owners and admins to manage keys but denies editors [spec: agent-api-keys/management-role]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { userId } = await getUserAndPersonalOrg(db)
|
||||
await insertStorage(db)
|
||||
await insertTeamOrg(db, 'agent-editor-team', userId, 'editor')
|
||||
await insertTeamOrg(db, 'agent-admin-team', userId, 'admin')
|
||||
await insertFile(db, 'agent-admin-team', 'agent-admin-readable')
|
||||
|
||||
const editorList = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', { headers })
|
||||
expect(editorList.status).toBe(403)
|
||||
const editorCreate = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Denied', scopes: ['objects:read'], expiresAt: futureIso(90) }),
|
||||
})
|
||||
expect(editorCreate.status).toBe(403)
|
||||
|
||||
const adminCreated = await createAgentKey(app, headers, 'agent-admin-team', ['objects:read'])
|
||||
expect(adminCreated.item.orgId).toBe('agent-admin-team')
|
||||
const adminList = await app.request('/api/objects', {
|
||||
headers: { Authorization: `Bearer ${adminCreated.key}` },
|
||||
})
|
||||
expect(adminList.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects disallowed scopes and raw Better Auth Agent key creation [spec: agent-api-keys/scope-boundary]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { orgId } = await getUserAndPersonalOrg(db)
|
||||
|
||||
const disallowed = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'bad', scopes: ['images:upload'], expiresAt: futureIso(90) }),
|
||||
})
|
||||
expect(disallowed.status).toBe(400)
|
||||
|
||||
const raw = await app.request('/api/auth/api-key/create', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ configId: 'agent', organizationId: orgId, permissions: { images: ['upload'] } }),
|
||||
})
|
||||
expect(raw.status).toBe(400)
|
||||
})
|
||||
|
||||
it('denies missing scope, wrong workspace, revoked key, expired key, and banned owner [spec: agent-api-keys/denials]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { orgId, userId } = await getUserAndPersonalOrg(db)
|
||||
await insertStorage(db)
|
||||
const created = await createAgentKey(app, headers, orgId, ['objects:create'])
|
||||
const auth = { Authorization: `Bearer ${created.key}` }
|
||||
|
||||
const missingScope = await app.request('/api/objects', { headers: auth })
|
||||
expect(missingScope.status).toBe(403)
|
||||
|
||||
const wrongWorkspace = await app.request('/api/objects?orgId=agent-other-workspace', { headers: auth })
|
||||
expect(wrongWorkspace.status).toBe(403)
|
||||
|
||||
await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}`, { method: 'DELETE', headers })
|
||||
const revoked = await app.request('/api/objects', { headers: auth })
|
||||
expect(revoked.status).toBe(401)
|
||||
|
||||
const expired = await createAgentKey(app, headers, orgId, ['objects:read'])
|
||||
await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`)
|
||||
const expiredRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${expired.key}` } })
|
||||
expect(expiredRes.status).toBe(401)
|
||||
|
||||
const banned = await createAgentKey(app, headers, orgId, ['objects:read'])
|
||||
await db.run(sql`UPDATE user SET banned = 1 WHERE id = ${userId}`)
|
||||
const bannedRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${banned.key}` } })
|
||||
expect(bannedRes.status).toBe(401)
|
||||
})
|
||||
|
||||
it('treats expired and revoked keys as terminal for rotation [spec: agent-api-keys/terminal-rotation]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { orgId } = await getUserAndPersonalOrg(db)
|
||||
|
||||
const expired = await createAgentKey(app, headers, orgId, ['objects:read'])
|
||||
await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`)
|
||||
const expiredRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${expired.item.id}/rotations`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expiresAt: futureIso(90) }),
|
||||
})
|
||||
expect(expiredRotation.status).toBe(409)
|
||||
await expect(expiredRotation.json()).resolves.toMatchObject({
|
||||
error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] },
|
||||
})
|
||||
|
||||
const revoked = await createAgentKey(app, headers, orgId, ['objects:read'])
|
||||
await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
const revokedRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}/rotations`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(revokedRotation.status).toBe(409)
|
||||
await expect(revokedRotation.json()).resolves.toMatchObject({
|
||||
error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('rechecks team role before management and file operations [spec: agent-api-keys/role-reduction]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { userId } = await getUserAndPersonalOrg(db)
|
||||
await insertStorage(db)
|
||||
await insertTeamOrg(db, 'agent-role-team', userId, 'owner')
|
||||
await insertFile(db, 'agent-role-team', 'agent-role-share-file')
|
||||
await insertLandingShare(db, {
|
||||
token: 'agent-role-share',
|
||||
orgId: 'agent-role-team',
|
||||
matterId: 'agent-role-share-file',
|
||||
userId,
|
||||
})
|
||||
const created = await createAgentKey(app, headers, 'agent-role-team', [
|
||||
'objects:create',
|
||||
'shares:create',
|
||||
'shares:delete',
|
||||
])
|
||||
await db.run(
|
||||
sql`UPDATE member SET role = 'viewer' WHERE organization_id = 'agent-role-team' AND user_id = ${userId}`,
|
||||
)
|
||||
const auth = { Authorization: `Bearer ${created.key}`, 'Content-Type': 'application/json' }
|
||||
|
||||
const management = await app.request('/api/workspaces/agent-role-team/agent-api-keys', { headers })
|
||||
expect(management.status).toBe(403)
|
||||
|
||||
const create = await app.request('/api/objects', {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
body: JSON.stringify({ name: 'blocked', type: 'folder', dirtype: 1, parent: '' }),
|
||||
})
|
||||
expect(create.status).toBe(403)
|
||||
|
||||
const privacy = await app.request('/api/shares/agent-role-share/privacy', {
|
||||
method: 'PUT',
|
||||
headers: auth,
|
||||
body: JSON.stringify({ private: true }),
|
||||
})
|
||||
expect(privacy.status).toBe(403)
|
||||
|
||||
const revoke = await app.request('/api/shares/agent-role-share/status', {
|
||||
method: 'PUT',
|
||||
headers: auth,
|
||||
body: JSON.stringify({ status: 'revoked' }),
|
||||
})
|
||||
expect(revoke.status).toBe(403)
|
||||
})
|
||||
|
||||
it('denies an old team workspace key after the owner membership is removed [spec: agent-api-keys/denials]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { userId } = await getUserAndPersonalOrg(db)
|
||||
await insertStorage(db)
|
||||
await insertTeamOrg(db, 'agent-removed-team', userId, 'owner')
|
||||
await insertFile(db, 'agent-removed-team', 'agent-removed-readable')
|
||||
const created = await createAgentKey(app, headers, 'agent-removed-team', ['objects:read'])
|
||||
|
||||
await db.run(sql`DELETE FROM member WHERE organization_id = 'agent-removed-team' AND user_id = ${userId}`)
|
||||
|
||||
const denied = await app.request('/api/objects?orgId=agent-removed-team', {
|
||||
headers: { Authorization: `Bearer ${created.key}` },
|
||||
})
|
||||
expect(denied.status).toBe(403)
|
||||
})
|
||||
|
||||
it('denies a legacy Better Auth Agent key without scoped metadata [spec: agent-api-keys/denials]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
const { userId } = await getUserAndPersonalOrg(db)
|
||||
const key = await insertLegacyAgentKey(db, userId)
|
||||
|
||||
const denied = await app.request('/api/objects', { headers: { Authorization: `Bearer ${key}` } })
|
||||
expect(denied.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import {
|
||||
agentApiKeyCreatedSchema,
|
||||
agentApiKeyCreateSchema,
|
||||
agentApiKeyListSchema,
|
||||
agentApiKeyRotateSchema,
|
||||
} from '@shared/schemas'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '../usecases/agent-api-keys'
|
||||
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
const workspaceParamsSchema = z.object({ orgId: z.string().min(1) })
|
||||
const keyParamsSchema = workspaceParamsSchema.extend({ keyId: z.string().min(1) })
|
||||
const listQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
const listRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'listWorkspaceAgentApiKeys',
|
||||
summary: 'List Agent API keys for a workspace',
|
||||
tags: ['Agent Access'],
|
||||
method: 'get',
|
||||
path: '/{orgId}/agent-api-keys',
|
||||
middleware: [requireAuth] as const,
|
||||
request: { params: workspaceParamsSchema, query: listQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(agentApiKeyListSchema, 'Agent API keys'),
|
||||
403: errorResponse('Forbidden'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const createRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'createWorkspaceAgentApiKey',
|
||||
summary: 'Create an Agent API key for a workspace',
|
||||
tags: ['Agent Access'],
|
||||
method: 'post',
|
||||
path: '/{orgId}/agent-api-keys',
|
||||
middleware: [requireAuth] as const,
|
||||
request: { params: workspaceParamsSchema, ...jsonBody(agentApiKeyCreateSchema) },
|
||||
responses: {
|
||||
201: jsonContent(agentApiKeyCreatedSchema, 'Created Agent API key'),
|
||||
400: errorResponse('Bad request'),
|
||||
403: errorResponse('Forbidden'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const rotateRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'rotateWorkspaceAgentApiKey',
|
||||
summary: 'Rotate an Agent API key for a workspace',
|
||||
tags: ['Agent Access'],
|
||||
method: 'post',
|
||||
path: '/{orgId}/agent-api-keys/{keyId}/rotations',
|
||||
middleware: [requireAuth] as const,
|
||||
request: { params: keyParamsSchema, ...jsonBody(agentApiKeyRotateSchema) },
|
||||
responses: {
|
||||
201: jsonContent(agentApiKeyCreatedSchema, 'Rotated Agent API key'),
|
||||
400: errorResponse('Bad request'),
|
||||
409: errorResponse('Agent API key is not active'),
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Agent API key not found'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const revokeRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'revokeWorkspaceAgentApiKey',
|
||||
summary: 'Revoke an Agent API key for a workspace',
|
||||
tags: ['Agent Access'],
|
||||
method: 'delete',
|
||||
path: '/{orgId}/agent-api-keys/{keyId}',
|
||||
middleware: [requireAuth] as const,
|
||||
request: { params: keyParamsSchema },
|
||||
responses: {
|
||||
204: { description: 'Revoked' },
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Agent API key not found'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const agentApiKeys = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) => {
|
||||
const { orgId } = c.req.valid('param')
|
||||
const { page, pageSize } = c.req.valid('query')
|
||||
const result = await listAgentApiKeys(c.get('deps'), c.get('platform').db, {
|
||||
userId: c.get('userId')!,
|
||||
orgId,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
return c.json(result, 200)
|
||||
})
|
||||
.openapi(createRoute, async (c) => {
|
||||
const { orgId } = c.req.valid('param')
|
||||
const result = await createAgentApiKey(c.get('deps'), c.get('platform').db, {
|
||||
userId: c.get('userId')!,
|
||||
orgId,
|
||||
body: c.req.valid('json'),
|
||||
})
|
||||
return c.json(result, 201)
|
||||
})
|
||||
.openapi(rotateRoute, async (c) => {
|
||||
const { orgId, keyId } = c.req.valid('param')
|
||||
const result = await rotateAgentApiKey(c.get('deps'), c.get('platform').db, {
|
||||
userId: c.get('userId')!,
|
||||
orgId,
|
||||
keyId,
|
||||
body: c.req.valid('json'),
|
||||
})
|
||||
return c.json(result, 201)
|
||||
})
|
||||
.openapi(revokeRoute, async (c) => {
|
||||
const { orgId, keyId } = c.req.valid('param')
|
||||
await revokeAgentApiKey(c.get('deps'), c.get('platform').db, {
|
||||
userId: c.get('userId')!,
|
||||
orgId,
|
||||
keyId,
|
||||
})
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
export default agentApiKeys
|
||||
@@ -450,6 +450,7 @@ const revokeShareRoute = authRoute(
|
||||
{
|
||||
access: 'protected',
|
||||
scopes: [AuthorizationScope.SHARES_DELETE],
|
||||
minTeamRole: 'editor',
|
||||
},
|
||||
{
|
||||
operationId: 'revokeShare',
|
||||
@@ -475,6 +476,7 @@ const putSharePrivacyRoute = authRoute(
|
||||
{
|
||||
access: 'protected',
|
||||
scopes: [AuthorizationScope.SHARES_CREATE],
|
||||
minTeamRole: 'editor',
|
||||
},
|
||||
{
|
||||
operationId: 'putSharePrivacy',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { anonymousAuthzContext, type Env } from './platform'
|
||||
// existing org members get read access rather than being silently denied.
|
||||
const ROLE_LEVELS: Record<string, number> = {
|
||||
owner: 3,
|
||||
admin: 3,
|
||||
editor: 2,
|
||||
viewer: 1,
|
||||
member: 1,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { AuthzContext, Env } from './platform'
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = {
|
||||
owner: 3,
|
||||
admin: 3,
|
||||
editor: 2,
|
||||
viewer: 1,
|
||||
member: 1,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates'
|
||||
import type {
|
||||
AgentApiKeyCreated,
|
||||
AgentApiKeyCreateInput,
|
||||
AgentApiKeyList,
|
||||
AgentApiKeyRotateInput,
|
||||
AgentGrantableScope,
|
||||
} from '@shared/schemas'
|
||||
import type { Database } from '../platform/interface'
|
||||
import type { Deps } from './deps'
|
||||
import { badRequest, conflict, forbidden, notFound } from './ports'
|
||||
|
||||
const MAX_AGENT_API_KEY_AGE_MS = 365 * 24 * 60 * 60 * 1000
|
||||
const AGENT_GRANTABLE_SCOPE_SET = new Set<string>(AGENT_GRANTABLE_API_KEY_SCOPES)
|
||||
|
||||
export async function listAgentApiKeys(
|
||||
deps: Pick<Deps, 'apiKeys' | 'org'>,
|
||||
db: Database,
|
||||
input: { userId: string; orgId: string; page: number; pageSize: number; now?: Date },
|
||||
): Promise<AgentApiKeyList> {
|
||||
await requireWorkspaceManager(deps, input.userId, input.orgId)
|
||||
const items = await deps.apiKeys.listAgentApiKeys(db, input.userId, input.orgId, input.now ?? new Date())
|
||||
const offset = (input.page - 1) * input.pageSize
|
||||
return {
|
||||
items: items.slice(offset, offset + input.pageSize),
|
||||
total: items.length,
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgentApiKey(
|
||||
deps: Pick<Deps, 'apiKeys' | 'org'>,
|
||||
db: Database,
|
||||
input: { userId: string; orgId: string; body: AgentApiKeyCreateInput; now?: Date },
|
||||
): Promise<AgentApiKeyCreated> {
|
||||
const now = input.now ?? new Date()
|
||||
await requireWorkspaceManager(deps, input.userId, input.orgId)
|
||||
return deps.apiKeys.issueAgentApiKey(db, {
|
||||
name: input.body.name,
|
||||
orgId: input.orgId,
|
||||
userId: input.userId,
|
||||
scopes: normalizeScopes(input.body.scopes),
|
||||
expiresAt: parseExpiresAt(input.body.expiresAt, now),
|
||||
})
|
||||
}
|
||||
|
||||
export async function rotateAgentApiKey(
|
||||
deps: Pick<Deps, 'apiKeys' | 'org'>,
|
||||
db: Database,
|
||||
input: { userId: string; orgId: string; keyId: string; body: AgentApiKeyRotateInput; now?: Date },
|
||||
): Promise<AgentApiKeyCreated> {
|
||||
const now = input.now ?? new Date()
|
||||
await requireWorkspaceManager(deps, input.userId, input.orgId)
|
||||
const existing = await deps.apiKeys.getAgentApiKey(db, input.userId, input.orgId, input.keyId, now)
|
||||
if (!existing) throw notFound('Agent API key not found')
|
||||
if (existing.status !== 'active') {
|
||||
throw conflict('Only active Agent API keys can be rotated', 'AGENT_API_KEY_NOT_ACTIVE')
|
||||
}
|
||||
return deps.apiKeys.issueAgentApiKey(db, {
|
||||
name: input.body.name?.trim() || `${existing.name} rotation`,
|
||||
orgId: input.orgId,
|
||||
userId: input.userId,
|
||||
scopes: normalizeScopes(input.body.scopes ?? existing.scopes),
|
||||
expiresAt: parseExpiresAt(input.body.expiresAt ?? existing.expiresAt, now),
|
||||
revokeKeyId: existing.id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function revokeAgentApiKey(
|
||||
deps: Pick<Deps, 'apiKeys' | 'org'>,
|
||||
db: Database,
|
||||
input: { userId: string; orgId: string; keyId: string; now?: Date },
|
||||
): Promise<void> {
|
||||
await requireWorkspaceManager(deps, input.userId, input.orgId)
|
||||
const existing = await deps.apiKeys.getAgentApiKey(
|
||||
db,
|
||||
input.userId,
|
||||
input.orgId,
|
||||
input.keyId,
|
||||
input.now ?? new Date(),
|
||||
)
|
||||
if (!existing) throw notFound('Agent API key not found')
|
||||
await deps.apiKeys.revokeAgentApiKey(db, input.keyId)
|
||||
}
|
||||
|
||||
async function requireWorkspaceManager(deps: Pick<Deps, 'org'>, userId: string, orgId: string): Promise<void> {
|
||||
if (!(await deps.org.canManageAgentAccess(userId, orgId))) {
|
||||
throw forbidden('Owner or admin access to the workspace is required')
|
||||
}
|
||||
}
|
||||
|
||||
function parseExpiresAt(value: string, now: Date): Date {
|
||||
const expiresAt = new Date(value)
|
||||
if (Number.isNaN(expiresAt.getTime())) throw badRequest('Invalid expiry')
|
||||
if (expiresAt <= now) throw badRequest('Agent API key expiry must be in the future')
|
||||
if (expiresAt.getTime() - now.getTime() > MAX_AGENT_API_KEY_AGE_MS) {
|
||||
throw badRequest('Agent API key expiry cannot exceed one year')
|
||||
}
|
||||
return expiresAt
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: readonly string[]): AgentGrantableScope[] {
|
||||
const unique = new Set(scopes)
|
||||
if (unique.size !== scopes.length) throw badRequest('Duplicate Agent API key scopes are not allowed')
|
||||
const normalized = [...unique] as AgentGrantableScope[]
|
||||
if (normalized.some((scope) => !AGENT_GRANTABLE_SCOPE_SET.has(scope))) {
|
||||
throw badRequest('Agent API key scope is not grantable')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function actorLogId(actor: ObjectActor): string {
|
||||
return actor.kind === 'download-task-upload' ? `downloader:${actor.downloaderId}` : actor.userId
|
||||
}
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
|
||||
|
||||
// Whether the user may write (editor+) in the org. Personal orgs grant full
|
||||
// access to their owner even without a member row.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ApiKeyScope } from '@shared/api-key-templates'
|
||||
import type { ApiKeyPermissions, AuthorizationScope } from '@shared/authorization'
|
||||
import type { AgentApiKey, AgentApiKeyCreated, AgentGrantableScope } from '@shared/schemas'
|
||||
import type { Database } from '../../platform/interface'
|
||||
|
||||
export interface VerifiedApiKey {
|
||||
@@ -40,4 +41,18 @@ export interface ApiKeyGateway {
|
||||
): Promise<VerifiedApiKey | null>
|
||||
hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource: string, action: string): boolean
|
||||
hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope: AuthorizationScope): boolean
|
||||
listAgentApiKeys(db: Database, userId: string, orgId: string, now: Date): Promise<AgentApiKey[]>
|
||||
getAgentApiKey(db: Database, userId: string, orgId: string, keyId: string, now: Date): Promise<AgentApiKey | null>
|
||||
issueAgentApiKey(
|
||||
db: Database,
|
||||
input: {
|
||||
name: string
|
||||
userId: string
|
||||
orgId: string
|
||||
scopes: AgentGrantableScope[]
|
||||
expiresAt: Date
|
||||
revokeKeyId?: string
|
||||
},
|
||||
): Promise<AgentApiKeyCreated>
|
||||
revokeAgentApiKey(db: Database, keyId: string): Promise<void>
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@ export interface OrgRepo {
|
||||
getMemberRole(orgId: string, userId: string): Promise<string | null>
|
||||
canReadOrg(userId: string, orgId: string): Promise<boolean>
|
||||
canWriteToOrg(userId: string, orgId: string): Promise<boolean>
|
||||
canManageAgentAccess(userId: string, orgId: string): Promise<boolean>
|
||||
isPersonalOrg(orgId: string): Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ function makeDeps(
|
||||
getMemberRole: async () => null,
|
||||
canReadOrg: async () => false,
|
||||
canWriteToOrg: async () => false,
|
||||
canManageAgentAccess: async () => false,
|
||||
isPersonalOrg: async () => false,
|
||||
...overrides.org,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ApiKeyTemplate = {
|
||||
IHOST: 'ihost',
|
||||
WEBDAV: 'webdav',
|
||||
REMOTE_DOWNLOAD: 'remote-download',
|
||||
AGENT: 'agent',
|
||||
} as const
|
||||
|
||||
export type ApiKeyTemplate = (typeof ApiKeyTemplate)[keyof typeof ApiKeyTemplate]
|
||||
@@ -59,10 +60,59 @@ export const REMOTE_DOWNLOAD_API_KEY_PERMISSIONS = {
|
||||
]),
|
||||
} satisfies ApiKeyPermissions
|
||||
|
||||
export const AGENT_GRANTABLE_API_KEY_SCOPES = [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.OBJECTS_CREATE,
|
||||
AuthorizationScope.OBJECTS_UPDATE,
|
||||
AuthorizationScope.OBJECTS_DELETE,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.SHARES_CREATE,
|
||||
AuthorizationScope.SHARES_DELETE,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
] as const
|
||||
|
||||
export const AGENT_API_KEY_PERMISSIONS = scopePermissions(AGENT_GRANTABLE_API_KEY_SCOPES)
|
||||
|
||||
export const AgentApiKeyShortcut = {
|
||||
READER: 'reader',
|
||||
FILE_MANAGER: 'file-manager',
|
||||
PUBLISHER: 'publisher',
|
||||
} as const
|
||||
|
||||
export type AgentApiKeyShortcut = (typeof AgentApiKeyShortcut)[keyof typeof AgentApiKeyShortcut]
|
||||
|
||||
export const AGENT_API_KEY_SHORTCUT_SCOPES = {
|
||||
[AgentApiKeyShortcut.READER]: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
[AgentApiKeyShortcut.FILE_MANAGER]: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.OBJECTS_CREATE,
|
||||
AuthorizationScope.OBJECTS_UPDATE,
|
||||
AuthorizationScope.OBJECTS_DELETE,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
[AgentApiKeyShortcut.PUBLISHER]: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.SHARES_CREATE,
|
||||
AuthorizationScope.SHARES_DELETE,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
} as const satisfies Record<AgentApiKeyShortcut, readonly AuthorizationScope[]>
|
||||
|
||||
export const API_KEY_TEMPLATE_PERMISSIONS = {
|
||||
[ApiKeyTemplate.IHOST]: IHOST_API_KEY_PERMISSIONS,
|
||||
[ApiKeyTemplate.WEBDAV]: WEBDAV_API_KEY_PERMISSIONS,
|
||||
[ApiKeyTemplate.REMOTE_DOWNLOAD]: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
|
||||
[ApiKeyTemplate.AGENT]: AGENT_API_KEY_PERMISSIONS,
|
||||
} satisfies Record<ApiKeyTemplate, ApiKeyPermissions>
|
||||
|
||||
export const API_KEY_TEMPLATES = Object.values(ApiKeyTemplate)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AGENT_API_KEY_SHORTCUT_SCOPES,
|
||||
AGENT_GRANTABLE_API_KEY_SCOPES,
|
||||
AgentApiKeyShortcut,
|
||||
} from '../api-key-templates'
|
||||
import { AuthorizationScope } from '../authorization'
|
||||
|
||||
export const agentGrantableScopeSchema = z.enum(AGENT_GRANTABLE_API_KEY_SCOPES)
|
||||
export type AgentGrantableScope = z.infer<typeof agentGrantableScopeSchema>
|
||||
|
||||
export const agentApiKeyShortcutSchema = z.enum(Object.values(AgentApiKeyShortcut))
|
||||
export type AgentApiKeyShortcutInput = z.infer<typeof agentApiKeyShortcutSchema>
|
||||
|
||||
export const agentApiKeyCreateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
scopes: z.array(agentGrantableScopeSchema).min(1),
|
||||
expiresAt: z.string().datetime(),
|
||||
})
|
||||
export type AgentApiKeyCreateInput = z.infer<typeof agentApiKeyCreateSchema>
|
||||
|
||||
export const agentApiKeyRotateSchema = agentApiKeyCreateSchema.partial({ name: true, scopes: true, expiresAt: true })
|
||||
export type AgentApiKeyRotateInput = z.infer<typeof agentApiKeyRotateSchema>
|
||||
|
||||
export const agentApiKeyStatusSchema = z.enum(['active', 'expired', 'revoked', 'inaccessible'])
|
||||
export type AgentApiKeyStatus = z.infer<typeof agentApiKeyStatusSchema>
|
||||
|
||||
export const agentApiKeySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
orgId: z.string(),
|
||||
workspaceName: z.string().nullable(),
|
||||
scopes: z.array(agentGrantableScopeSchema),
|
||||
createdAt: z.string(),
|
||||
expiresAt: z.string(),
|
||||
lastUsedAt: z.string().nullable(),
|
||||
status: agentApiKeyStatusSchema,
|
||||
})
|
||||
export type AgentApiKey = z.infer<typeof agentApiKeySchema>
|
||||
|
||||
export const agentApiKeyListSchema = z.object({
|
||||
items: z.array(agentApiKeySchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
export type AgentApiKeyList = z.infer<typeof agentApiKeyListSchema>
|
||||
|
||||
export const agentApiKeyCreatedSchema = z.object({
|
||||
key: z.string(),
|
||||
item: agentApiKeySchema,
|
||||
})
|
||||
export type AgentApiKeyCreated = z.infer<typeof agentApiKeyCreatedSchema>
|
||||
|
||||
export const agentApiKeyShortcutOptions = Object.entries(AGENT_API_KEY_SHORTCUT_SCOPES).map(([id, scopes]) => ({
|
||||
id: id as AgentApiKeyShortcutInput,
|
||||
scopes: [...scopes],
|
||||
}))
|
||||
|
||||
export const agentScopeLabels = {
|
||||
[AuthorizationScope.OBJECTS_READ]: 'settings.agentAccess.scope.objectsRead',
|
||||
[AuthorizationScope.OBJECTS_CREATE]: 'settings.agentAccess.scope.objectsCreate',
|
||||
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.agentAccess.scope.objectsUpdate',
|
||||
[AuthorizationScope.OBJECTS_DELETE]: 'settings.agentAccess.scope.objectsDelete',
|
||||
[AuthorizationScope.SHARES_READ]: 'settings.agentAccess.scope.sharesRead',
|
||||
[AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate',
|
||||
[AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete',
|
||||
[AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead',
|
||||
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead',
|
||||
} as const satisfies Record<AgentGrantableScope, string>
|
||||
@@ -9,6 +9,28 @@ export {
|
||||
adminAnalyticsTrafficSchema,
|
||||
adminOverviewSchema,
|
||||
} from './admin-analytics'
|
||||
export type {
|
||||
AgentApiKey,
|
||||
AgentApiKeyCreated,
|
||||
AgentApiKeyCreateInput,
|
||||
AgentApiKeyList,
|
||||
AgentApiKeyRotateInput,
|
||||
AgentApiKeyShortcutInput,
|
||||
AgentApiKeyStatus,
|
||||
AgentGrantableScope,
|
||||
} from './agent-api-keys'
|
||||
export {
|
||||
agentApiKeyCreatedSchema,
|
||||
agentApiKeyCreateSchema,
|
||||
agentApiKeyListSchema,
|
||||
agentApiKeyRotateSchema,
|
||||
agentApiKeySchema,
|
||||
agentApiKeyShortcutOptions,
|
||||
agentApiKeyShortcutSchema,
|
||||
agentApiKeyStatusSchema,
|
||||
agentGrantableScopeSchema,
|
||||
agentScopeLabels,
|
||||
} from './agent-api-keys'
|
||||
|
||||
export type {
|
||||
AnnouncementInput,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
Feature: Agent API keys
|
||||
Workspace-scoped Agent API keys provide a CI and unattended-service credential
|
||||
path. Keys are owned by one authorizing user, bound to one workspace, grant only
|
||||
explicit Agent scopes, expire, and are revealed only once.
|
||||
|
||||
@agent-api-keys/lifecycle @api
|
||||
Scenario: A user manages a personal workspace Agent API key
|
||||
Given an authenticated personal workspace owner
|
||||
When they create, list, rotate, and revoke an Agent API key
|
||||
Then the plaintext key is returned only on create or rotation
|
||||
And revoked keys stop working immediately
|
||||
|
||||
@agent-api-keys/team-file-ops @api
|
||||
Scenario: A team Agent API key performs granted file operations
|
||||
Given a team workspace owner creates an Agent API key with file read and create scopes
|
||||
When the owner later becomes an editor
|
||||
Then the key can list files and create folders in that workspace
|
||||
|
||||
@agent-api-keys/management-role @api
|
||||
Scenario: Team credential management is restricted to owners and admins
|
||||
Given a team workspace member
|
||||
When an editor tries to list or create Agent API keys
|
||||
Then the API denies credential management
|
||||
And an owner or admin can manage Agent API keys
|
||||
|
||||
@agent-api-keys/scope-boundary @api
|
||||
Scenario: Agent API keys cannot request non-Agent scopes
|
||||
Given an authenticated workspace editor
|
||||
When they request image-hosting or raw Better Auth Agent permissions
|
||||
Then the API rejects the key creation request
|
||||
|
||||
@agent-api-keys/denials @api
|
||||
Scenario: Agent API keys fail closed
|
||||
Given a workspace Agent API key
|
||||
When the key is missing scope, crosses workspaces, is revoked, expires, or its owner is banned
|
||||
Then protected APIs reject the request
|
||||
|
||||
@agent-api-keys/role-reduction @api
|
||||
Scenario: Agent API keys recheck current workspace role
|
||||
Given a team Agent API key created by an owner
|
||||
When the owner is reduced to viewer
|
||||
Then management and editor-only file operations are denied
|
||||
|
||||
@agent-api-keys/terminal-rotation @api
|
||||
Scenario: Expired and revoked Agent API keys are terminal
|
||||
Given an expired or revoked Agent API key
|
||||
When an owner tries to rotate it
|
||||
Then the API rejects rotation and requires a new key
|
||||
@@ -1127,6 +1127,7 @@
|
||||
"settings.tabProfile": "Profile",
|
||||
"settings.tabPassword": "Password",
|
||||
"settings.tabApiKeys": "API Keys",
|
||||
"settings.tabAgentAccess": "Agent Access",
|
||||
"settings.tabWebDav": "WebDAV",
|
||||
"settings.tabImageHosting": "Image Hosting",
|
||||
"settings.profile.section": "Profile",
|
||||
@@ -1200,6 +1201,55 @@
|
||||
"settings.apiKeys.revokeSuccess": "API key revoked",
|
||||
"settings.apiKeys.orgRequired": "Select a workspace before creating this API key.",
|
||||
"settings.apiKeys.manage": "Manage API Keys",
|
||||
"settings.agentAccess.section": "Agent Access",
|
||||
"settings.agentAccess.description": "Manage workspace-scoped Agent API keys for CI and unattended services.",
|
||||
"settings.agentAccess.workspaceLabel": "Workspace",
|
||||
"settings.agentAccess.workspacePlaceholder": "Select a workspace",
|
||||
"settings.agentAccess.create": "Create Key",
|
||||
"settings.agentAccess.createTitle": "Create Agent API Key",
|
||||
"settings.agentAccess.createDescription": "Choose a workspace, expiry, and exact scopes.",
|
||||
"settings.agentAccess.nameLabel": "Name",
|
||||
"settings.agentAccess.namePlaceholder": "e.g. GitHub Actions deploy",
|
||||
"settings.agentAccess.expiryLabel": "Expiry",
|
||||
"settings.agentAccess.shortcutsLabel": "Shortcuts",
|
||||
"settings.agentAccess.shortcut.reader": "Reader",
|
||||
"settings.agentAccess.shortcut.file-manager": "File manager",
|
||||
"settings.agentAccess.shortcut.publisher": "Publisher",
|
||||
"settings.agentAccess.scope.objectsRead": "Files: read objects",
|
||||
"settings.agentAccess.scope.objectsCreate": "Files: create objects",
|
||||
"settings.agentAccess.scope.objectsUpdate": "Files: update objects",
|
||||
"settings.agentAccess.scope.objectsDelete": "Files: delete objects",
|
||||
"settings.agentAccess.scope.sharesRead": "Shares: read shares",
|
||||
"settings.agentAccess.scope.sharesCreate": "Shares: create shares",
|
||||
"settings.agentAccess.scope.sharesDelete": "Shares: revoke shares",
|
||||
"settings.agentAccess.scope.quotaRead": "Quota: read workspace quota",
|
||||
"settings.agentAccess.scope.storageUsageRead": "Storage usage: read workspace usage",
|
||||
"settings.agentAccess.colName": "Name",
|
||||
"settings.agentAccess.colWorkspace": "Workspace",
|
||||
"settings.agentAccess.colScopes": "Scopes",
|
||||
"settings.agentAccess.colCreated": "Created",
|
||||
"settings.agentAccess.colExpires": "Expires",
|
||||
"settings.agentAccess.colLastUsed": "Last Used",
|
||||
"settings.agentAccess.colStatus": "Status",
|
||||
"settings.agentAccess.colActions": "Actions",
|
||||
"settings.agentAccess.status.active": "Active",
|
||||
"settings.agentAccess.status.expired": "Expired",
|
||||
"settings.agentAccess.status.revoked": "Revoked",
|
||||
"settings.agentAccess.status.inaccessible": "Inaccessible",
|
||||
"settings.agentAccess.noKeys": "No Agent API keys yet",
|
||||
"settings.agentAccess.managementRequired": "Owner or admin access is required to manage Agent API keys for this workspace.",
|
||||
"settings.agentAccess.never": "Never",
|
||||
"settings.agentAccess.copy": "Copy",
|
||||
"settings.agentAccess.copied": "Copied",
|
||||
"settings.agentAccess.createSuccess": "Agent API key created",
|
||||
"settings.agentAccess.rotate": "Rotate",
|
||||
"settings.agentAccess.rotateSuccess": "Agent API key rotated",
|
||||
"settings.agentAccess.revoke": "Revoke",
|
||||
"settings.agentAccess.revokeTitle": "Revoke Agent API Key",
|
||||
"settings.agentAccess.revokeConfirm": "Revoke Agent API key \"{{name}}\"? Any services using it will stop immediately.",
|
||||
"settings.agentAccess.revokeSuccess": "Agent API key revoked",
|
||||
"settings.agentAccess.revealedTitle": "Save Your Agent API Key",
|
||||
"settings.agentAccess.revealedWarning": "This is the only time this key will be shown. Store it securely.",
|
||||
"settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.",
|
||||
"settings.appearance.language.description": "The display language for the app.",
|
||||
"settings.appearance.autoSaved": "Changes apply immediately.",
|
||||
|
||||
@@ -1127,6 +1127,7 @@
|
||||
"settings.tabProfile": "基本信息",
|
||||
"settings.tabPassword": "密码",
|
||||
"settings.tabApiKeys": "API Key",
|
||||
"settings.tabAgentAccess": "Agent Access",
|
||||
"settings.tabWebDav": "WebDAV",
|
||||
"settings.tabImageHosting": "图床",
|
||||
"settings.profile.section": "个人资料",
|
||||
@@ -1200,6 +1201,55 @@
|
||||
"settings.apiKeys.revokeSuccess": "API Key 已撤销",
|
||||
"settings.apiKeys.orgRequired": "创建该 API Key 前请先选择工作区。",
|
||||
"settings.apiKeys.manage": "管理 API Key",
|
||||
"settings.agentAccess.section": "Agent Access",
|
||||
"settings.agentAccess.description": "管理用于 CI 和无人值守服务的工作空间级 Agent API Key。",
|
||||
"settings.agentAccess.workspaceLabel": "工作空间",
|
||||
"settings.agentAccess.workspacePlaceholder": "选择工作空间",
|
||||
"settings.agentAccess.create": "创建 Key",
|
||||
"settings.agentAccess.createTitle": "创建 Agent API Key",
|
||||
"settings.agentAccess.createDescription": "选择工作空间、过期时间和明确权限。",
|
||||
"settings.agentAccess.nameLabel": "名称",
|
||||
"settings.agentAccess.namePlaceholder": "例如:GitHub Actions deploy",
|
||||
"settings.agentAccess.expiryLabel": "过期时间",
|
||||
"settings.agentAccess.shortcutsLabel": "快捷模板",
|
||||
"settings.agentAccess.shortcut.reader": "Reader",
|
||||
"settings.agentAccess.shortcut.file-manager": "File manager",
|
||||
"settings.agentAccess.shortcut.publisher": "Publisher",
|
||||
"settings.agentAccess.scope.objectsRead": "文件:读取对象",
|
||||
"settings.agentAccess.scope.objectsCreate": "文件:创建对象",
|
||||
"settings.agentAccess.scope.objectsUpdate": "文件:更新对象",
|
||||
"settings.agentAccess.scope.objectsDelete": "文件:删除对象",
|
||||
"settings.agentAccess.scope.sharesRead": "分享:读取分享",
|
||||
"settings.agentAccess.scope.sharesCreate": "分享:创建分享",
|
||||
"settings.agentAccess.scope.sharesDelete": "分享:撤销分享",
|
||||
"settings.agentAccess.scope.quotaRead": "配额:读取工作空间配额",
|
||||
"settings.agentAccess.scope.storageUsageRead": "存储用量:读取工作空间用量",
|
||||
"settings.agentAccess.colName": "名称",
|
||||
"settings.agentAccess.colWorkspace": "工作空间",
|
||||
"settings.agentAccess.colScopes": "权限",
|
||||
"settings.agentAccess.colCreated": "创建时间",
|
||||
"settings.agentAccess.colExpires": "过期时间",
|
||||
"settings.agentAccess.colLastUsed": "最近使用",
|
||||
"settings.agentAccess.colStatus": "状态",
|
||||
"settings.agentAccess.colActions": "操作",
|
||||
"settings.agentAccess.status.active": "有效",
|
||||
"settings.agentAccess.status.expired": "已过期",
|
||||
"settings.agentAccess.status.revoked": "已撤销",
|
||||
"settings.agentAccess.status.inaccessible": "不可访问",
|
||||
"settings.agentAccess.noKeys": "暂无 Agent API Key",
|
||||
"settings.agentAccess.managementRequired": "需要工作空间所有者或管理员权限才能管理 Agent API Key。",
|
||||
"settings.agentAccess.never": "从未",
|
||||
"settings.agentAccess.copy": "复制",
|
||||
"settings.agentAccess.copied": "已复制",
|
||||
"settings.agentAccess.createSuccess": "Agent API Key 已创建",
|
||||
"settings.agentAccess.rotate": "轮换",
|
||||
"settings.agentAccess.rotateSuccess": "Agent API Key 已轮换",
|
||||
"settings.agentAccess.revoke": "撤销",
|
||||
"settings.agentAccess.revokeTitle": "撤销 Agent API Key",
|
||||
"settings.agentAccess.revokeConfirm": "撤销 Agent API Key「{{name}}」?使用该 Key 的服务将立即停止工作。",
|
||||
"settings.agentAccess.revokeSuccess": "Agent API Key 已撤销",
|
||||
"settings.agentAccess.revealedTitle": "保存你的 Agent API Key",
|
||||
"settings.agentAccess.revealedWarning": "该 Key 只会显示一次,请妥善保存。",
|
||||
"settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。",
|
||||
"settings.appearance.language.description": "界面显示语言。",
|
||||
"settings.appearance.autoSaved": "修改即时生效。",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
connectCloud,
|
||||
continueCloudOrderPayment,
|
||||
copyObject,
|
||||
createAgentApiKey,
|
||||
createAnnouncement,
|
||||
createBackgroundJob,
|
||||
createCloudBillingPortalSession,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
listActiveAnnouncements,
|
||||
listAdminAnnouncements,
|
||||
listAdminAuditLogs,
|
||||
listAgentApiKeys,
|
||||
listAnnouncements,
|
||||
listApiKeys,
|
||||
listAuthProviders,
|
||||
@@ -120,6 +122,7 @@ import {
|
||||
resetBrandingField,
|
||||
restoreObject,
|
||||
retryBackgroundJob,
|
||||
revokeAgentApiKey,
|
||||
revokeIhostApiKey,
|
||||
revokeOrgEntitlement,
|
||||
revokeRemoteDownloadApiKey,
|
||||
@@ -127,6 +130,7 @@ import {
|
||||
revokeSiteInvitation,
|
||||
revokeUserEntitlement,
|
||||
revokeWebDavAppPassword,
|
||||
rotateAgentApiKey,
|
||||
runDownloadTaskAction,
|
||||
saveBranding,
|
||||
saveEmailConfig,
|
||||
@@ -3104,6 +3108,96 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent Access API keys', () => {
|
||||
const sampleList = {
|
||||
items: [
|
||||
{
|
||||
id: 'agent-key-1',
|
||||
name: 'CI',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-07-29T00:00:00.000Z',
|
||||
expiresAt: '2026-10-27T00:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}
|
||||
|
||||
it('lists workspace Agent API keys through the Hono RPC route', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleList))
|
||||
|
||||
const result = await listAgentApiKeys('org-1')
|
||||
|
||||
expect(result).toEqual(sampleList)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/workspaces/org-1/agent-api-keys')
|
||||
expect(url).toContain('page=1')
|
||||
expect(url).toContain('pageSize=50')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('creates a workspace Agent API key with explicit scopes and expiry', async () => {
|
||||
const payload = { key: 'zpan_agent_secret', item: sampleList.items[0] }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
|
||||
|
||||
const result = await createAgentApiKey('org-1', {
|
||||
name: 'CI',
|
||||
scopes: ['objects:read'],
|
||||
expiresAt: '2026-10-27T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/workspaces/org-1/agent-api-keys')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
name: 'CI',
|
||||
scopes: ['objects:read'],
|
||||
expiresAt: '2026-10-27T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('rotates a workspace Agent API key without sending the old secret', async () => {
|
||||
const payload = { key: 'zpan_agent_rotated', item: { ...sampleList.items[0], id: 'agent-key-2' } }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
|
||||
|
||||
const result = await rotateAgentApiKey('org-1', 'agent-key-1', { name: 'CI rotated' })
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1/rotations')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: 'CI rotated' })
|
||||
})
|
||||
|
||||
it('revokes a workspace Agent API key with DELETE', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
|
||||
|
||||
await revokeAgentApiKey('org-1', 'agent-key-1')
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws ApiError when Agent key creation fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
|
||||
await expect(
|
||||
createAgentApiKey('org-1', {
|
||||
name: 'CI',
|
||||
scopes: ['objects:read'],
|
||||
expiresAt: '2026-10-27T00:00:00.000Z',
|
||||
}),
|
||||
).rejects.toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listApiKeys', () => {
|
||||
const sampleKey = {
|
||||
id: 'key-1',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
|
||||
import type { OAuthProviderConfig } from '@shared/oauth-providers'
|
||||
import type {
|
||||
AgentApiKey,
|
||||
AgentApiKeyCreated,
|
||||
AgentApiKeyCreateInput,
|
||||
AgentApiKeyList,
|
||||
AgentApiKeyRotateInput,
|
||||
AllowedImageMime,
|
||||
AnnouncementInput,
|
||||
CloudCreditBalanceResponse,
|
||||
@@ -97,6 +102,7 @@ import {
|
||||
adminQuotas,
|
||||
adminSiteInvitations,
|
||||
adminTeams,
|
||||
agentApiKeysApi,
|
||||
announcementsApi,
|
||||
authedSharesApi,
|
||||
authProviders,
|
||||
@@ -1076,6 +1082,40 @@ export function deleteIhostConfig() {
|
||||
})
|
||||
}
|
||||
|
||||
// Agent Access API keys
|
||||
|
||||
export type { AgentApiKey, AgentApiKeyCreated, AgentApiKeyCreateInput, AgentApiKeyList, AgentApiKeyRotateInput }
|
||||
|
||||
export function listAgentApiKeys(orgId: string, page = 1, pageSize = 50) {
|
||||
return unwrap<AgentApiKeyList>(
|
||||
agentApiKeysApi[':orgId']['agent-api-keys'].$get({
|
||||
param: { orgId },
|
||||
query: { page: String(page), pageSize: String(pageSize) },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function createAgentApiKey(orgId: string, input: AgentApiKeyCreateInput) {
|
||||
return unwrap<AgentApiKeyCreated>(
|
||||
agentApiKeysApi[':orgId']['agent-api-keys'].$post({ param: { orgId }, json: input }),
|
||||
)
|
||||
}
|
||||
|
||||
export function rotateAgentApiKey(orgId: string, keyId: string, input: AgentApiKeyRotateInput = {}) {
|
||||
return unwrap<AgentApiKeyCreated>(
|
||||
agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].rotations.$post({
|
||||
param: { orgId, keyId },
|
||||
json: input,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function revokeAgentApiKey(orgId: string, keyId: string) {
|
||||
return agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].$delete({ param: { orgId, keyId } }).then((res) => {
|
||||
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
|
||||
})
|
||||
}
|
||||
|
||||
// Image Host API Keys (via better-auth apiKey plugin)
|
||||
|
||||
export interface IhostApiKey {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AdminSiteInvitationsRoute,
|
||||
AdminStatsRoute,
|
||||
AdminTeamsRoute,
|
||||
AgentApiKeysRoute,
|
||||
AnnouncementsRoute,
|
||||
AuthedSharesRoute,
|
||||
AuthProvidersRoute,
|
||||
@@ -49,6 +50,7 @@ export const objects = hc<ObjectsRoute>('/api/objects', opts)
|
||||
export const downloadTasksApi = hc<DownloadTasksRoute>('/api/downloads/tasks', opts)
|
||||
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloads/downloaders', opts)
|
||||
export const trash = hc<TrashRoute>('/api/trash', opts)
|
||||
export const agentApiKeysApi = hc<AgentApiKeysRoute>('/api/workspaces', opts)
|
||||
export const storages = hc<StoragesRoute>('/api/site/storages', opts)
|
||||
export const storageUsageApi = hc<StorageUsageRoute>('/api/storage', opts)
|
||||
export const adminDownloadersApi = hc<DownloadersRoute>('/api/downloads/downloaders', opts)
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Route as AuthenticatedSettingsWebdavRouteImport } from './routes/_authe
|
||||
import { Route as AuthenticatedSettingsProfileRouteImport } from './routes/_authenticated/settings/profile'
|
||||
import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_authenticated/settings/password'
|
||||
import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings/api-keys'
|
||||
import { Route as AuthenticatedSettingsAgentAccessRouteImport } from './routes/_authenticated/settings/agent-access'
|
||||
import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing'
|
||||
import { Route as AuthenticatedAdminDownloadersRouteImport } from './routes/_authenticated/admin/downloaders'
|
||||
import { Route as AuthenticatedAdminDashboardRouteImport } from './routes/_authenticated/admin/dashboard'
|
||||
@@ -222,6 +223,12 @@ const AuthenticatedSettingsApiKeysRoute =
|
||||
path: '/api-keys',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsAgentAccessRoute =
|
||||
AuthenticatedSettingsAgentAccessRouteImport.update({
|
||||
id: '/agent-access',
|
||||
path: '/agent-access',
|
||||
getParentRoute: () => AuthenticatedSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminLicensingRoute =
|
||||
AuthenticatedAdminLicensingRouteImport.update({
|
||||
id: '/licensing',
|
||||
@@ -376,6 +383,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
|
||||
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
|
||||
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
|
||||
'/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
|
||||
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
|
||||
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
|
||||
@@ -426,6 +434,7 @@ export interface FileRoutesByTo {
|
||||
'/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
|
||||
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
|
||||
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
|
||||
'/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
|
||||
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
|
||||
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
|
||||
@@ -481,6 +490,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
|
||||
'/_authenticated/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
|
||||
'/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute
|
||||
'/_authenticated/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
|
||||
'/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||
'/_authenticated/settings/password': typeof AuthenticatedSettingsPasswordRoute
|
||||
'/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileRoute
|
||||
@@ -536,6 +546,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/dashboard'
|
||||
| '/admin/downloaders'
|
||||
| '/admin/licensing'
|
||||
| '/settings/agent-access'
|
||||
| '/settings/api-keys'
|
||||
| '/settings/password'
|
||||
| '/settings/profile'
|
||||
@@ -586,6 +597,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/dashboard'
|
||||
| '/admin/downloaders'
|
||||
| '/admin/licensing'
|
||||
| '/settings/agent-access'
|
||||
| '/settings/api-keys'
|
||||
| '/settings/password'
|
||||
| '/settings/profile'
|
||||
@@ -640,6 +652,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/admin/dashboard'
|
||||
| '/_authenticated/admin/downloaders'
|
||||
| '/_authenticated/admin/licensing'
|
||||
| '/_authenticated/settings/agent-access'
|
||||
| '/_authenticated/settings/api-keys'
|
||||
| '/_authenticated/settings/password'
|
||||
| '/_authenticated/settings/profile'
|
||||
@@ -895,6 +908,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedSettingsApiKeysRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/settings/agent-access': {
|
||||
id: '/_authenticated/settings/agent-access'
|
||||
path: '/agent-access'
|
||||
fullPath: '/settings/agent-access'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsAgentAccessRouteImport
|
||||
parentRoute: typeof AuthenticatedSettingsRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/licensing': {
|
||||
id: '/_authenticated/admin/licensing'
|
||||
path: '/licensing'
|
||||
@@ -1097,6 +1117,7 @@ const AuthenticatedAdminRouteRouteWithChildren =
|
||||
)
|
||||
|
||||
interface AuthenticatedSettingsRouteRouteChildren {
|
||||
AuthenticatedSettingsAgentAccessRoute: typeof AuthenticatedSettingsAgentAccessRoute
|
||||
AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute
|
||||
AuthenticatedSettingsPasswordRoute: typeof AuthenticatedSettingsPasswordRoute
|
||||
AuthenticatedSettingsProfileRoute: typeof AuthenticatedSettingsProfileRoute
|
||||
@@ -1106,6 +1127,8 @@ interface AuthenticatedSettingsRouteRouteChildren {
|
||||
|
||||
const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren =
|
||||
{
|
||||
AuthenticatedSettingsAgentAccessRoute:
|
||||
AuthenticatedSettingsAgentAccessRoute,
|
||||
AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute,
|
||||
AuthenticatedSettingsPasswordRoute: AuthenticatedSettingsPasswordRoute,
|
||||
AuthenticatedSettingsProfileRoute: AuthenticatedSettingsProfileRoute,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { AgentApiKey } from '@shared/schemas'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
|
||||
import { useListOrganizations } from '@/lib/auth-client'
|
||||
import { AgentAccessSettingsPage } from './agent-access'
|
||||
import { SettingsLayout } from './route'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
orgs: [
|
||||
{ id: 'org-1', name: 'Personal' },
|
||||
{ id: 'org-2', name: 'Team Alpha' },
|
||||
],
|
||||
keys: [] as AgentApiKey[],
|
||||
webdavEnabled: true,
|
||||
}))
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'settings.agentAccess.scope.objectsRead': 'Files: read objects',
|
||||
'settings.agentAccess.scope.objectsCreate': 'Files: create objects',
|
||||
'settings.agentAccess.scope.objectsUpdate': 'Files: update objects',
|
||||
'settings.agentAccess.scope.objectsDelete': 'Files: delete objects',
|
||||
'settings.agentAccess.scope.sharesRead': 'Shares: read shares',
|
||||
'settings.agentAccess.scope.sharesCreate': 'Shares: create shares',
|
||||
'settings.agentAccess.scope.sharesDelete': 'Shares: revoke shares',
|
||||
'settings.agentAccess.scope.quotaRead': 'Quota: read workspace quota',
|
||||
'settings.agentAccess.scope.storageUsageRead': 'Storage usage: read workspace usage',
|
||||
'settings.agentAccess.managementRequired': 'Owner or admin access is required',
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => translations[key] ?? key }),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Outlet: () => <div>outlet</div>,
|
||||
createFileRoute: () => (options: unknown) => options,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/page-header', () => ({
|
||||
PageHeader: () => <div>page-header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/page-tabs', () => ({
|
||||
PageTabs: ({ items }: { items: Array<{ label: string }> }) => <div>{items.map((item) => item.label).join('|')}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-site-config', () => ({
|
||||
useSiteConfig: () => ({
|
||||
data: { services: { webdav: { enabled: state.webdavEnabled } } },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useListOrganizations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
createAgentApiKey: vi.fn(),
|
||||
listAgentApiKeys: vi.fn(),
|
||||
revokeAgentApiKey: vi.fn(),
|
||||
rotateAgentApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
const queryClients: QueryClient[] = []
|
||||
|
||||
function renderWithQuery(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
queryClient.setDefaultOptions({
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false, gcTime: 0 },
|
||||
})
|
||||
queryClients.push(queryClient)
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
vi.mocked(useListOrganizations).mockReturnValue({ data: state.orgs } as never)
|
||||
vi.mocked(listAgentApiKeys).mockImplementation(async (orgId: string) => ({
|
||||
items: state.keys.filter((item) => item.orgId === orgId),
|
||||
total: state.keys.filter((item) => item.orgId === orgId).length,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
for (const queryClient of queryClients.splice(0)) queryClient.clear()
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
state.keys = []
|
||||
state.webdavEnabled = true
|
||||
})
|
||||
|
||||
describe('Agent Access settings page', () => {
|
||||
it('loads the first workspace, fetches its keys, and keeps creation inside a dialog', async () => {
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
|
||||
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
|
||||
expect(await screen.findByText('settings.agentAccess.noKeys')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('settings.agentAccess.nameLabel')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
|
||||
|
||||
expect(screen.getByLabelText('settings.agentAccess.nameLabel')).toBeTruthy()
|
||||
expect(screen.getByLabelText('settings.agentAccess.expiryLabel')).toBeTruthy()
|
||||
for (const label of [
|
||||
'Files: read objects',
|
||||
'Files: create objects',
|
||||
'Files: update objects',
|
||||
'Files: delete objects',
|
||||
'Shares: read shares',
|
||||
'Shares: create shares',
|
||||
'Shares: revoke shares',
|
||||
'Quota: read workspace quota',
|
||||
'Storage usage: read workspace usage',
|
||||
]) {
|
||||
expect(screen.getByText(label)).toBeTruthy()
|
||||
}
|
||||
expect(screen.queryByText(/settings\.agentAccess\.scope\..*:/)).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a workspace Agent API key and reveals the secret once', async () => {
|
||||
vi.mocked(createAgentApiKey).mockResolvedValue({
|
||||
key: 'zpan_agent_secret',
|
||||
item: {
|
||||
id: 'agent-key-1',
|
||||
name: 'CI key',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
expiresAt: '2026-10-27T23:59:59.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
|
||||
await screen.findByText('settings.agentAccess.noKeys')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.createTitle' })
|
||||
fireEvent.change(within(dialog).getByLabelText('settings.agentAccess.nameLabel'), {
|
||||
target: { value: ' CI key ' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.create' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createAgentApiKey).toHaveBeenCalledWith(
|
||||
'org-1',
|
||||
expect.objectContaining({
|
||||
name: 'CI key',
|
||||
scopes: ['objects:read', 'shares:read', 'quota:read', 'storage-usage:read'],
|
||||
expiresAt: expect.stringMatching(/T23:59:59\.000Z$/),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(screen.getByText('zpan_agent_secret')).toBeTruthy()
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.createSuccess')
|
||||
})
|
||||
|
||||
it('rotates and revokes an existing workspace Agent API key', async () => {
|
||||
state.keys = [
|
||||
{
|
||||
id: 'agent-key-1',
|
||||
name: 'CI key',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
expiresAt: '2026-10-27T23:59:59.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
]
|
||||
vi.mocked(rotateAgentApiKey).mockResolvedValue({
|
||||
key: 'zpan_agent_rotated',
|
||||
item: {
|
||||
...state.keys[0],
|
||||
id: 'agent-key-2',
|
||||
},
|
||||
})
|
||||
vi.mocked(revokeAgentApiKey).mockResolvedValue(undefined)
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
await screen.findByText('CI key')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' }))
|
||||
|
||||
await waitFor(() => expect(rotateAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1'))
|
||||
const revealedDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })
|
||||
expect(within(revealedDialog).getByText('zpan_agent_rotated')).toBeTruthy()
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.rotateSuccess')
|
||||
fireEvent.click(within(revealedDialog).getAllByRole('button', { name: 'common.close' })[1]!)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })).toBeNull())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' }))
|
||||
const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })
|
||||
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
|
||||
|
||||
await waitFor(() => expect(revokeAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1'))
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.revokeSuccess')
|
||||
})
|
||||
|
||||
it('surfaces rotate and revoke errors and lets the revoke dialog close from its close control', async () => {
|
||||
state.keys = [
|
||||
{
|
||||
id: 'agent-key-1',
|
||||
name: 'CI key',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
expiresAt: '2026-10-27T23:59:59.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
]
|
||||
vi.mocked(rotateAgentApiKey).mockRejectedValue(new Error('rotate failed'))
|
||||
vi.mocked(revokeAgentApiKey).mockRejectedValue(new Error('revoke failed'))
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
await screen.findByText('CI key')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('rotate failed'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' }))
|
||||
const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })
|
||||
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('revoke failed'))
|
||||
|
||||
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'common.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })).toBeNull())
|
||||
})
|
||||
|
||||
it('does not offer rotation for expired or revoked keys', async () => {
|
||||
state.keys = [
|
||||
{
|
||||
id: 'expired-key',
|
||||
name: 'Expired key',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-02-01T00:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'expired',
|
||||
},
|
||||
{
|
||||
id: 'revoked-key',
|
||||
name: 'Revoked key',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-12-01T00:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'revoked',
|
||||
},
|
||||
]
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
await screen.findByText('Expired key')
|
||||
expect(screen.getByText('Revoked key')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'settings.agentAccess.rotate' })).toBeNull()
|
||||
})
|
||||
|
||||
it('disables credential creation when the workspace management check fails', async () => {
|
||||
vi.mocked(listAgentApiKeys).mockRejectedValue(new Error('Forbidden'))
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
|
||||
expect(await screen.findByText('Owner or admin access is required')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'settings.agentAccess.create' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings layout tabs', () => {
|
||||
it('includes the Agent Access tab alongside existing settings tabs', () => {
|
||||
renderWithQuery(<SettingsLayout />)
|
||||
|
||||
expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the Agent Access tab when WebDAV is disabled', () => {
|
||||
state.webdavEnabled = false
|
||||
|
||||
renderWithQuery(<SettingsLayout />)
|
||||
|
||||
expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy()
|
||||
expect(screen.queryByText(/settings\.tabWebDav/)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
import { type AgentGrantableScope, agentApiKeyShortcutOptions, agentScopeLabels } from '@shared/schemas'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Copy, KeyRound, Plus, RotateCw, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { type AgentApiKey, createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
|
||||
import { useListOrganizations } from '@/lib/auth-client'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/settings/agent-access')({
|
||||
component: AgentAccessSettingsPage,
|
||||
})
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RevealedKey {
|
||||
name: string
|
||||
key: string
|
||||
}
|
||||
|
||||
const allAgentScopes = Object.keys(agentScopeLabels) as AgentGrantableScope[]
|
||||
|
||||
function defaultExpiryDate(): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + 90)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function expiryDateToIso(value: string): string {
|
||||
return new Date(`${value}T23:59:59.000Z`).toISOString()
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : null
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t('settings.agentAccess.copy')}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(t('settings.agentAccess.copied'))
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
<span className="sr-only">{t('settings.agentAccess.copy')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAgentKeyDialog({
|
||||
open,
|
||||
orgId,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
orgId: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (key: RevealedKey) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [expiryDate, setExpiryDate] = useState(defaultExpiryDate)
|
||||
const [scopes, setScopes] = useState<AgentGrantableScope[]>(agentApiKeyShortcutOptions[0]?.scopes ?? [])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createAgentApiKey(orgId, {
|
||||
name: name.trim(),
|
||||
scopes,
|
||||
expiresAt: expiryDateToIso(expiryDate),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-api-keys', orgId] })
|
||||
onCreated({ name: result.item.name, key: result.key })
|
||||
setName('')
|
||||
setExpiryDate(defaultExpiryDate())
|
||||
setScopes(agentApiKeyShortcutOptions[0]?.scopes ?? [])
|
||||
onOpenChange(false)
|
||||
toast.success(t('settings.agentAccess.createSuccess'))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function toggleScope(scope: AgentGrantableScope, checked: boolean) {
|
||||
setScopes((current) => (checked ? [...current, scope] : current.filter((item) => item !== scope)))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.agentAccess.createTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.agentAccess.createDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-key-name">{t('settings.agentAccess.nameLabel')}</Label>
|
||||
<Input
|
||||
id="agent-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={t('settings.agentAccess.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-key-expiry">{t('settings.agentAccess.expiryLabel')}</Label>
|
||||
<Input
|
||||
id="agent-key-expiry"
|
||||
type="date"
|
||||
value={expiryDate}
|
||||
max={oneYearDate()}
|
||||
onChange={(event) => setExpiryDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('settings.agentAccess.shortcutsLabel')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agentApiKeyShortcutOptions.map((shortcut) => (
|
||||
<Button
|
||||
key={shortcut.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScopes([...shortcut.scopes])}
|
||||
>
|
||||
{t(`settings.agentAccess.shortcut.${shortcut.id}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{allAgentScopes.map((scope) => {
|
||||
const checkboxId = `agent-key-scope-${scope}`
|
||||
return (
|
||||
<div key={scope} className="flex min-h-10 items-center gap-3 rounded-md border px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={scopes.includes(scope)}
|
||||
onCheckedChange={(checked) => toggleScope(scope, !!checked)}
|
||||
/>
|
||||
<Label htmlFor={checkboxId} className="font-normal">
|
||||
{t(agentScopeLabels[scope])}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!orgId || !name.trim() || scopes.length === 0 || !expiryDate || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{createMutation.isPending ? t('common.loading') : t('settings.agentAccess.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function oneYearDate(): string {
|
||||
const date = new Date()
|
||||
date.setFullYear(date.getFullYear() + 1)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function RevealedKeyDialog({ revealedKey, onClose }: { revealedKey: RevealedKey | null; onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
if (!revealedKey) return null
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.agentAccess.revealedTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.agentAccess.revealedWarning')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{revealedKey.name}</Label>
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/40 p-2">
|
||||
<code className="min-w-0 flex-1 break-all text-sm">{revealedKey.key}</code>
|
||||
<CopyButton value={revealedKey.key} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RevokeAgentKeyDialog({ apiKey, onClose }: { apiKey: AgentApiKey | null; onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!apiKey) return
|
||||
await revokeAgentApiKey(apiKey.orgId, apiKey.id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (apiKey) queryClient.invalidateQueries({ queryKey: ['agent-api-keys', apiKey.orgId] })
|
||||
toast.success(t('settings.agentAccess.revokeSuccess'))
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
if (!apiKey) return null
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.agentAccess.revokeTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.agentAccess.revokeConfirm', { name: apiKey.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{revokeMutation.isPending ? t('common.loading') : t('settings.agentAccess.revoke')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentAccessSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationData } = useListOrganizations()
|
||||
const organizations = (organizationData ?? []) as Organization[]
|
||||
const [orgId, setOrgId] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [revealedKey, setRevealedKey] = useState<RevealedKey | null>(null)
|
||||
const [revoking, setRevoking] = useState<AgentApiKey | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId && organizations[0]) setOrgId(organizations[0].id)
|
||||
}, [orgId, organizations])
|
||||
|
||||
const keysQuery = useQuery({
|
||||
queryKey: ['agent-api-keys', orgId],
|
||||
queryFn: () => listAgentApiKeys(orgId),
|
||||
enabled: !!orgId,
|
||||
})
|
||||
|
||||
const rows = keysQuery.data?.items ?? []
|
||||
|
||||
async function rotate(apiKey: AgentApiKey) {
|
||||
try {
|
||||
const result = await rotateAgentApiKey(apiKey.orgId, apiKey.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-api-keys', apiKey.orgId] })
|
||||
setRevealedKey({ name: result.item.name, key: result.key })
|
||||
toast.success(t('settings.agentAccess.rotateSuccess'))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.agentAccess.section')}</CardTitle>
|
||||
<CardDescription>{t('settings.agentAccess.description')}</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!orgId || keysQuery.isLoading || keysQuery.isError}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('settings.agentAccess.create')}
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<Label htmlFor="agent-access-workspace">{t('settings.agentAccess.workspaceLabel')}</Label>
|
||||
<Select value={orgId} onValueChange={setOrgId}>
|
||||
<SelectTrigger id="agent-access-workspace">
|
||||
<SelectValue placeholder={t('settings.agentAccess.workspacePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{keysQuery.isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : keysQuery.isError ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">{t('settings.agentAccess.managementRequired')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('settings.agentAccess.noKeys')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('settings.agentAccess.colName')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colWorkspace')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colScopes')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colCreated')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colExpires')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colLastUsed')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colStatus')}</TableHead>
|
||||
<TableHead className="w-24 text-right">{t('settings.agentAccess.colActions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
{row.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{row.workspaceName ?? row.orgId}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex max-w-md flex-wrap gap-1">
|
||||
{row.scopes.map((scope) => (
|
||||
<Badge key={scope} variant="secondary">
|
||||
{t(agentScopeLabels[scope])}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(row.createdAt)}</TableCell>
|
||||
<TableCell>{formatDate(row.expiresAt)}</TableCell>
|
||||
<TableCell>{formatDate(row.lastUsedAt) ?? t('settings.agentAccess.never')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.status === 'active' ? 'default' : 'secondary'}>
|
||||
{t(`settings.agentAccess.status.${row.status}`)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.status === 'active' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t('settings.agentAccess.rotate')}
|
||||
onClick={() => rotate(row)}
|
||||
>
|
||||
<RotateCw className="size-4" />
|
||||
<span className="sr-only">{t('settings.agentAccess.rotate')}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t('settings.agentAccess.revoke')}
|
||||
onClick={() => setRevoking(row)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">{t('settings.agentAccess.revoke')}</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CreateAgentKeyDialog open={createOpen} orgId={orgId} onOpenChange={setCreateOpen} onCreated={setRevealedKey} />
|
||||
<RevealedKeyDialog revealedKey={revealedKey} onClose={() => setRevealedKey(null)} />
|
||||
<RevokeAgentKeyDialog apiKey={revoking} onClose={() => setRevoking(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const Route = createFileRoute('/_authenticated/settings')({
|
||||
component: SettingsLayout,
|
||||
})
|
||||
|
||||
function SettingsLayout() {
|
||||
export function SettingsLayout() {
|
||||
const { t } = useTranslation()
|
||||
const { data: siteConfig } = useSiteConfig()
|
||||
|
||||
@@ -20,6 +20,7 @@ function SettingsLayout() {
|
||||
? []
|
||||
: [{ to: '/settings/webdav', label: t('settings.tabWebDav') }]),
|
||||
{ to: '/settings/api-keys', label: t('settings.tabApiKeys') },
|
||||
{ to: '/settings/agent-access', label: t('settings.tabAgentAccess') },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user