feat: support api key ip restriction rules

This commit is contained in:
fawney19
2026-05-20 16:11:49 +08:00
parent b6bdc08267
commit f76bbaab52
55 changed files with 698 additions and 551 deletions

View File

@@ -148,6 +148,7 @@ export interface UserApiKeyExport {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
ip_rules?: string[] | null
rate_limit?: number | null // legacy/null 兼容1.3+ standalone null = 跟随系统默认
concurrent_limit?: number | null
force_capabilities?: Record<string, boolean>
@@ -497,6 +498,7 @@ export interface AdminApiKey {
allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表
ip_rules?: string[] | null // IP 限制规则
feature_settings?: Record<string, unknown> | null
auto_delete_on_expiry?: boolean // 过期后是否自动删除
last_used_at?: string
@@ -511,6 +513,7 @@ export interface CreateStandaloneApiKeyRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
ip_rules?: string[] | null
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
concurrent_limit?: number | null // null = 跟随系统默认0 = 不限制
expires_at?: string | null // RFC3339 时间null = 永不过期

View File

@@ -175,7 +175,7 @@ export interface ApiKey {
total_cost_usd?: number
rate_limit?: number | null
concurrent_limit?: number | null
allowed_ips?: string[] | null
ip_rules?: string[] | null
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置
feature_settings?: FeatureSettingsMap | null
@@ -255,7 +255,7 @@ export const meApi = {
return response.data
},
async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null; allowed_ips?: string[] | null; feature_settings?: FeatureSettingsMap | null }): Promise<ApiKey> {
async createApiKey(data: { name: string; rate_limit?: number | null; concurrent_limit?: number | null; ip_rules?: string[] | null; feature_settings?: FeatureSettingsMap | null }): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
return response.data
},
@@ -288,7 +288,7 @@ export const meApi = {
async updateApiKey(
keyId: string,
data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null; allowed_ips?: string[] | null; feature_settings?: FeatureSettingsMap | null | undefined }
data: { name?: string; rate_limit?: number | null; concurrent_limit?: number | null; ip_rules?: string[] | null; feature_settings?: FeatureSettingsMap | null | undefined }
): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>(
`/api/users/me/api-keys/${keyId}`,

View File

@@ -221,7 +221,7 @@ export interface ApiKey {
feature_settings?: FeatureSettings | null
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
concurrent_limit?: number | null // 普通Key: 0 = 不限制并发,历史 null 兼容
allowed_ips?: string[] | null
ip_rules?: string[] | null
total_requests?: number // 总请求数
total_cost_usd?: number // 总费用
}
@@ -230,7 +230,7 @@ export interface UpsertUserApiKeyRequest {
name?: string
rate_limit?: number | null
concurrent_limit?: number | null
allowed_ips?: string[] | null
ip_rules?: string[] | null
feature_settings?: FeatureSettings | null
}

View File

@@ -236,6 +236,22 @@
</p>
</div>
<div class="space-y-2">
<Label
for="form-ip-rules"
class="text-sm font-medium"
>IP 限制</Label>
<Input
id="form-ip-rules"
v-model="form.ip_rules_text"
class="h-10"
placeholder="例如203.0.113.10, 10.0.0.0/24, !10.0.0.13"
/>
<p class="text-xs text-muted-foreground">
留空表示不限制;支持 IP、CIDR、IPv4 通配符、*,用 ! 前缀拒绝,多个规则用英文逗号分隔
</p>
</div>
<div class="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">敏感信息保护</Label>
@@ -344,6 +360,7 @@ export interface StandaloneKeyFormData {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
ip_rules?: string[] | null
feature_settings?: Record<string, unknown> | null
}
@@ -365,6 +382,7 @@ interface StandaloneKeyFormState {
allowed_providers: string[]
allowed_api_formats: string[]
allowed_models: string[]
ip_rules_text: string
chat_pii_redaction_enabled: boolean
chat_pii_redaction_placeholder_notice: boolean
}
@@ -424,6 +442,7 @@ const form = ref<StandaloneKeyFormState>({
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
ip_rules_text: '',
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
})
@@ -472,6 +491,7 @@ function resetForm() {
allowed_providers: [],
allowed_api_formats: [],
allowed_models: [],
ip_rules_text: '',
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
} as typeof form.value
@@ -498,6 +518,7 @@ function loadKeyData() {
allowed_providers: props.apiKey.allowed_providers ? [...props.apiKey.allowed_providers] : [],
allowed_api_formats: props.apiKey.allowed_api_formats ? [...props.apiKey.allowed_api_formats] : [],
allowed_models: props.apiKey.allowed_models ? [...props.apiKey.allowed_models] : [],
ip_rules_text: props.apiKey.ip_rules?.join(', ') ?? '',
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
} as typeof form.value
@@ -548,6 +569,7 @@ function handleSubmit() {
allowed_providers: form.value.provider_unrestricted ? null : [...form.value.allowed_providers],
allowed_api_formats: form.value.api_format_unrestricted ? null : [...form.value.allowed_api_formats],
allowed_models: form.value.model_unrestricted ? null : [...form.value.allowed_models],
ip_rules: parseIpRulesInput(form.value.ip_rules_text),
feature_settings: mergeChatPiiRedactionFeatureSettings(props.apiKey?.feature_settings, {
enabled: form.value.chat_pii_redaction_enabled,
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
@@ -555,6 +577,14 @@ function handleSubmit() {
})
}
function parseIpRulesInput(value: string): string[] | null {
const items = value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
return items.length > 0 ? items : null
}
// 设置保存状态
function setSaving(value: boolean) {
saving.value = value

View File

@@ -1420,6 +1420,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models,
ip_rules: data.ip_rules,
feature_settings: data.feature_settings ?? null
}
const { message: _, ...updated } = await adminApi.updateApiKey(data.id, updateData)
@@ -1451,6 +1452,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models,
ip_rules: data.ip_rules,
feature_settings: data.feature_settings ?? null
}
const response = await adminApi.createStandaloneApiKey(createData)

View File

@@ -1090,7 +1090,7 @@
{{ apiKey.key_display || '****' }}
</code>
<span class="text-xs text-muted-foreground">
IP 白名单{{ formatAllowedIps(apiKey.allowed_ips) }}
IP 限制{{ formatIpRules(apiKey.ip_rules) }}
</span>
<button
class="p-0.5 hover:bg-muted rounded transition-colors"
@@ -1263,17 +1263,17 @@
</div>
<div class="space-y-2">
<Label
for="admin-user-key-allowed-ips"
for="admin-user-key-ip-rules"
class="text-sm font-medium"
>IP 白名单</Label>
>IP 限制</Label>
<Input
id="admin-user-key-allowed-ips"
v-model="userApiKeyForm.allowed_ips_text"
id="admin-user-key-ip-rules"
v-model="userApiKeyForm.ip_rules_text"
class="h-10"
placeholder="例如203.0.113.10, 10.0.0.0/24"
placeholder="例如203.0.113.10, 10.0.0.0/24, !10.0.0.13"
/>
<p class="text-xs text-muted-foreground">
留空表示不限制来源 IP;支持 IPCIDR多个用英文逗号分隔
留空表示不限制;支持 IPCIDR、IPv4 通配符、*,用 ! 前缀拒绝,多个规则用英文逗号分隔
</p>
</div>
@@ -1580,7 +1580,7 @@ const userApiKeyForm = ref({
name: '',
rate_limit: undefined as number | undefined,
concurrent_limit: undefined as number | undefined,
allowed_ips_text: '',
ip_rules_text: '',
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
})
@@ -1888,11 +1888,11 @@ function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
return `${concurrentLimit} 并发`
}
function formatAllowedIps(allowedIps?: string[] | null): string {
return allowedIps && allowedIps.length > 0 ? allowedIps.join(', ') : '不限制'
function formatIpRules(ipRules?: string[] | null): string {
return ipRules && ipRules.length > 0 ? ipRules.join(', ') : '不限制'
}
function parseAllowedIpsInput(value: string): string[] | null {
function parseIpRulesInput(value: string): string[] | null {
const items = value
.split(',')
.map((item) => item.trim())
@@ -2110,7 +2110,7 @@ function openCreateUserApiKeyDialog() {
name: `Key-${new Date().toISOString().split('T')[0]}`,
rate_limit: undefined,
concurrent_limit: undefined,
allowed_ips_text: '',
ip_rules_text: '',
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
}
@@ -2125,7 +2125,7 @@ function openEditUserApiKeyDialog(apiKey: ApiKey) {
name: apiKey.name || '',
rate_limit: apiKey.rate_limit ?? undefined,
concurrent_limit: apiKey.concurrent_limit ?? undefined,
allowed_ips_text: apiKey.allowed_ips?.join(', ') ?? '',
ip_rules_text: apiKey.ip_rules?.join(', ') ?? '',
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
}
@@ -2139,7 +2139,7 @@ function closeUserApiKeyFormDialog() {
name: '',
rate_limit: undefined,
concurrent_limit: undefined,
allowed_ips_text: '',
ip_rules_text: '',
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
}
@@ -2154,13 +2154,13 @@ async function submitUserApiKeyForm() {
creatingApiKey.value = true
try {
const allowedIps = parseAllowedIpsInput(userApiKeyForm.value.allowed_ips_text)
const ipRules = parseIpRulesInput(userApiKeyForm.value.ip_rules_text)
if (editingUserApiKey.value) {
await usersStore.updateApiKey(selectedUser.value.id, editingUserApiKey.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
allowed_ips: allowedIps,
ip_rules: ipRules,
feature_settings: mergeChatPiiRedactionFeatureSettings(editingUserApiKey.value.feature_settings, {
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
@@ -2172,7 +2172,7 @@ async function submitUserApiKeyForm() {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
allowed_ips: allowedIps,
ip_rules: ipRules,
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,

View File

@@ -426,21 +426,21 @@
</div>
<!-- IP 白名单 -->
<!-- IP 限制 -->
<div class="space-y-2">
<Label
for="token-ips"
class="text-sm font-semibold"
>IP 白名单</Label>
>IP 限制</Label>
<Input
id="token-ips"
v-model="formData.allowedIpsText"
placeholder="例如192.168.1.0/24, 10.0.0.1(逗号分隔,留空不限制)"
placeholder="例如192.168.*.*, 10.0.0.0/24, !10.0.0.13"
class="h-11 border-border/60"
autocomplete="off"
/>
<p class="text-xs text-muted-foreground">
限制只能从指定 IP 地址使用此令牌支持 CIDR 格式
留空表示不限制支持 IPCIDRIPv4 通配符* ! 前缀拒绝多个规则用英文逗号分隔
</p>
</div>

View File

@@ -115,7 +115,7 @@
创建于 {{ formatDate(apiKey.created_at) }}
</div>
<div class="text-xs text-muted-foreground mt-0.5 truncate">
IP 白名单{{ formatAllowedIps(apiKey.allowed_ips) }}
IP 限制{{ formatIpRules(apiKey.ip_rules) }}
</div>
</div>
</TableCell>
@@ -359,7 +359,7 @@
</span>
</div>
<div class="text-xs text-muted-foreground truncate">
IP 白名单{{ formatAllowedIps(apiKey.allowed_ips) }}
IP 限制{{ formatIpRules(apiKey.ip_rules) }}
</div>
</div>
</div>
@@ -459,18 +459,18 @@
<div class="space-y-2">
<Label
for="key-allowed-ips"
for="key-ip-rules"
class="text-sm font-semibold"
>IP 白名单</Label>
>IP 限制</Label>
<Input
id="key-allowed-ips"
v-model="newKeyAllowedIpsText"
placeholder="例如203.0.113.10, 10.0.0.0/24"
id="key-ip-rules"
v-model="newKeyIpRulesText"
placeholder="例如203.0.113.10, 10.0.0.0/24, !10.0.0.13"
class="h-11 border-border/60"
autocomplete="off"
/>
<p class="text-xs text-muted-foreground">
留空表示不限制来源 IP;支持单个 IPCIDR多个用英文逗号分隔
留空表示不限制;支持 IPCIDR、IPv4 通配符、*,用 ! 前缀拒绝,多个规则用英文逗号分隔
</p>
</div>
@@ -802,7 +802,7 @@ const showInstallDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
const newKeyConcurrentLimit = ref<number | undefined>(undefined)
const newKeyAllowedIpsText = ref('')
const newKeyIpRulesText = ref('')
const keyRedactionMode = ref<'inherit' | 'custom'>('inherit')
const newKeyRedactionEnabled = ref(false)
const newKeyRedactionInjectNotice = ref(true)
@@ -891,7 +891,7 @@ function openEditApiKeyDialog(apiKey: ApiKey) {
newKeyName.value = apiKey.name || ''
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
newKeyConcurrentLimit.value = apiKey.concurrent_limit ?? undefined
newKeyAllowedIpsText.value = apiKey.allowed_ips?.join(', ') ?? ''
newKeyIpRulesText.value = apiKey.ip_rules?.join(', ') ?? ''
keyRedactionMode.value = hasRedactionFeature ? 'custom' : 'inherit'
newKeyRedactionEnabled.value = redactionFeature.enabled
newKeyRedactionInjectNotice.value = redactionFeature.inject_model_instruction
@@ -903,7 +903,7 @@ function openCreateApiKeyDialog() {
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
newKeyAllowedIpsText.value = ''
newKeyIpRulesText.value = ''
keyRedactionMode.value = 'inherit'
newKeyRedactionEnabled.value = false
newKeyRedactionInjectNotice.value = true
@@ -983,7 +983,7 @@ function closeApiKeyDialog() {
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
newKeyAllowedIpsText.value = ''
newKeyIpRulesText.value = ''
keyRedactionMode.value = 'inherit'
newKeyRedactionEnabled.value = false
newKeyRedactionInjectNotice.value = true
@@ -997,14 +997,14 @@ async function saveApiKey() {
creating.value = true
try {
const allowedIps = parseAllowedIpsInput(newKeyAllowedIpsText.value)
const ipRules = parseIpRulesInput(newKeyIpRulesText.value)
const isCreatingFirstApiKey = !editingApiKey.value && apiKeys.value.length === 0
if (editingApiKey.value) {
await meApi.updateApiKey(editingApiKey.value.id, {
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
allowed_ips: allowedIps,
ip_rules: ipRules,
feature_settings: keyRedactionMode.value === 'custom'
? mergeChatPiiRedactionFeatureSettings(editingApiKey.value.feature_settings, {
enabled: newKeyRedactionEnabled.value,
@@ -1018,7 +1018,7 @@ async function saveApiKey() {
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
allowed_ips: allowedIps,
ip_rules: ipRules,
...(keyRedactionMode.value === 'custom'
? {
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
@@ -1148,11 +1148,11 @@ function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
return `${concurrentLimit} 并发`
}
function formatAllowedIps(allowedIps?: string[] | null): string {
return allowedIps && allowedIps.length > 0 ? allowedIps.join(', ') : '不限制'
function formatIpRules(ipRules?: string[] | null): string {
return ipRules && ipRules.length > 0 ? ipRules.join(', ') : '不限制'
}
function parseAllowedIpsInput(value: string): string[] | null {
function parseIpRulesInput(value: string): string[] | null {
const items = value
.split(',')
.map((item) => item.trim())