mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(acl): add bot security policy presets
Initialize new bots with preset ACL templates and an allow-by-default fallback so common access setups can be selected during bot creation instead of being configured manually afterward.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
export type AclPresetKey =
|
||||
| 'allow_all'
|
||||
| 'private_only'
|
||||
| 'group_only'
|
||||
| 'group_and_thread_only'
|
||||
| 'deny_all'
|
||||
|
||||
export interface AclPresetOption {
|
||||
value: AclPresetKey
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
export const defaultAclPreset: AclPresetKey = 'allow_all'
|
||||
|
||||
export const aclPresetOptions: AclPresetOption[] = [
|
||||
{
|
||||
value: 'allow_all',
|
||||
titleKey: 'bots.aclPresets.allowAll.title',
|
||||
descriptionKey: 'bots.aclPresets.allowAll.description',
|
||||
},
|
||||
{
|
||||
value: 'private_only',
|
||||
titleKey: 'bots.aclPresets.privateOnly.title',
|
||||
descriptionKey: 'bots.aclPresets.privateOnly.description',
|
||||
},
|
||||
{
|
||||
value: 'group_only',
|
||||
titleKey: 'bots.aclPresets.groupOnly.title',
|
||||
descriptionKey: 'bots.aclPresets.groupOnly.description',
|
||||
},
|
||||
{
|
||||
value: 'group_and_thread_only',
|
||||
titleKey: 'bots.aclPresets.groupAndThreadOnly.title',
|
||||
descriptionKey: 'bots.aclPresets.groupAndThreadOnly.description',
|
||||
},
|
||||
{
|
||||
value: 'deny_all',
|
||||
titleKey: 'bots.aclPresets.denyAll.title',
|
||||
descriptionKey: 'bots.aclPresets.denyAll.description',
|
||||
},
|
||||
]
|
||||
@@ -614,6 +614,32 @@
|
||||
"timezonePlaceholder": "e.g. America/New_York",
|
||||
"timezoneInherited": "Inherited from user/system",
|
||||
"timezoneInheritedHint": "Leave empty to inherit the user's timezone, or fall back to the system timezone.",
|
||||
"aclPreset": "Security Policy",
|
||||
"aclPresetDescription": "Choose a common ACL starting point during creation. You can fine-tune it later in Access Control.",
|
||||
"aclPresetHint": "Recommendation: use Private Only for personal assistants, Group Only or Group and Thread for community bots, and Allow All when you want the broadest default.",
|
||||
"aclPresetHelp": "This initializes the bot ACL default effect and preset rules at creation time. You can still edit them later in Access Control.",
|
||||
"aclPresets": {
|
||||
"allowAll": {
|
||||
"title": "Allow All",
|
||||
"description": "Adds no extra restriction and allows all conversation types by default."
|
||||
},
|
||||
"privateOnly": {
|
||||
"title": "Private Only",
|
||||
"description": "Defaults to deny and only allows private conversations."
|
||||
},
|
||||
"groupOnly": {
|
||||
"title": "Group Only",
|
||||
"description": "Defaults to deny and only allows group conversations."
|
||||
},
|
||||
"groupAndThreadOnly": {
|
||||
"title": "Group and Thread",
|
||||
"description": "Defaults to deny and allows group conversations plus threads."
|
||||
},
|
||||
"denyAll": {
|
||||
"title": "Deny All",
|
||||
"description": "Defaults to deny and adds no rules, so you can configure access manually later."
|
||||
}
|
||||
},
|
||||
"editAvatar": "Edit Avatar",
|
||||
"editAvatarDescription": "Set the image URL for the bot avatar.",
|
||||
"editTimezone": "Edit Timezone",
|
||||
|
||||
@@ -610,6 +610,32 @@
|
||||
"timezonePlaceholder": "例如 Asia/Shanghai",
|
||||
"timezoneInherited": "继承用户或系统时区",
|
||||
"timezoneInheritedHint": "留空则继承用户时区,若用户未设置则回退到系统时区。",
|
||||
"aclPreset": "安全策略",
|
||||
"aclPresetDescription": "创建时选择一个常见 ACL 场景,后续仍可在访问控制中继续微调。",
|
||||
"aclPresetHint": "建议:个人助手用“仅私聊”,群机器人用“仅群聊”或“群聊和话题”,不确定时保留“允许全部”。",
|
||||
"aclPresetHelp": "创建时会按所选策略初始化 ACL 默认行为和预设规则,后续仍可在访问控制页面修改。",
|
||||
"aclPresets": {
|
||||
"allowAll": {
|
||||
"title": "允许全部",
|
||||
"description": "不额外添加限制,默认允许所有会话类型。"
|
||||
},
|
||||
"privateOnly": {
|
||||
"title": "仅私聊",
|
||||
"description": "默认拒绝,仅放行私聊。"
|
||||
},
|
||||
"groupOnly": {
|
||||
"title": "仅群聊",
|
||||
"description": "默认拒绝,仅放行群聊。"
|
||||
},
|
||||
"groupAndThreadOnly": {
|
||||
"title": "群聊和话题",
|
||||
"description": "默认拒绝,放行群聊和话题。"
|
||||
},
|
||||
"denyAll": {
|
||||
"title": "全部拒绝",
|
||||
"description": "默认拒绝且不添加规则,需要后续手动配置。"
|
||||
}
|
||||
},
|
||||
"editAvatar": "编辑头像",
|
||||
"editAvatarDescription": "设置 Bot 头像图片的链接地址",
|
||||
"editTimezone": "编辑时区",
|
||||
@@ -962,28 +988,28 @@
|
||||
"deleteSuccess": "规则已删除",
|
||||
"deleteFailed": "删除规则失败",
|
||||
"sourceScopeTitle": "来源范围",
|
||||
"sourceScopeDescription": "可选地将规则限制到某个平台、会话类型或指定会话/线程。",
|
||||
"sourceScopeDescription": "可选地将规则限制到某个平台、会话类型或指定会话/话题。",
|
||||
"sourceChannel": "来源平台",
|
||||
"anyChannel": "任意平台",
|
||||
"conversationType": "会话类型",
|
||||
"anyConversationType": "任意会话类型",
|
||||
"privateConversationType": "私聊",
|
||||
"groupConversationType": "群聊",
|
||||
"threadConversationType": "线程",
|
||||
"threadConversationType": "话题",
|
||||
"conversationSource": "会话",
|
||||
"conversationSourceDescription": "按名称、平台或会话 ID 搜索。列表为本 Bot 曾出现过的群聊/线程路由:选「平台身份」时按该身份筛选;选「平台类型」时按该平台上的全部会话。",
|
||||
"conversationSourceDescription": "按名称、平台或会话 ID 搜索。列表为本 Bot 曾出现过的群聊/话题路由:选「平台身份」时按该身份筛选;选「平台类型」时按该平台上的全部会话。",
|
||||
"selectConversationSource": "搜索或选择会话",
|
||||
"searchConversationSource": "搜索会话",
|
||||
"noObservedConversations": "暂无符合的历史会话。可手动填写 ID,或确认该 Bot 在对应会话中已有消息(含私聊)。",
|
||||
"manualConversationIds": "手动填写会话 ID",
|
||||
"manualConversationIdsHint": "若列表中没有目标会话,请从平台复制原始会话 ID、线程 ID。",
|
||||
"manualConversationIdsHint": "若列表中没有目标会话,请从平台复制原始会话 ID、话题 ID。",
|
||||
"pickIdentityForConversationSearch": "请先在上方选择平台身份,才能按历史记录搜索会话。",
|
||||
"pickChannelTypeForConversationSearch": "请先在上方填写或选择平台类型,才能按该平台上的历史会话搜索。",
|
||||
"conversationIdManualHint": "当前仅能手动填写。搜索会话需先选择平台身份。",
|
||||
"conversationId": "会话 ID",
|
||||
"conversationIdPlaceholder": "输入会话 ID",
|
||||
"threadId": "线程 ID",
|
||||
"threadIdPlaceholder": "输入线程 ID",
|
||||
"threadId": "话题 ID",
|
||||
"threadIdPlaceholder": "输入话题 ID",
|
||||
"clearScope": "清空范围",
|
||||
"lastObserved": "最近出现"
|
||||
},
|
||||
|
||||
@@ -833,7 +833,7 @@ function onConversationSourceChange(routeId: string) {
|
||||
|
||||
// ---- default effect ----
|
||||
|
||||
const defaultEffectDraft = ref('deny')
|
||||
const defaultEffectDraft = ref('allow')
|
||||
const isSavingDefaultEffect = ref(false)
|
||||
|
||||
watch(defaultEffectData, (data) => {
|
||||
@@ -843,7 +843,7 @@ watch(defaultEffectData, (data) => {
|
||||
}, { immediate: true })
|
||||
|
||||
const hasDefaultEffectChanges = computed(
|
||||
() => defaultEffectDraft.value !== (defaultEffectData.value?.default_effect ?? 'deny'),
|
||||
() => defaultEffectDraft.value !== (defaultEffectData.value?.default_effect ?? 'allow'),
|
||||
)
|
||||
|
||||
async function handleSaveDefaultEffect() {
|
||||
|
||||
@@ -77,6 +77,57 @@
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-slot="{ value, handleChange }"
|
||||
name="acl_preset"
|
||||
>
|
||||
<FormItem>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<Label>{{ $t('bots.aclPreset') }}</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-5 text-muted-foreground hover:text-foreground"
|
||||
:aria-label="$t('bots.aclPresetHelp')"
|
||||
>
|
||||
<CircleHelp class="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-64 text-left">
|
||||
{{ $t('bots.aclPresetHelp') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Select
|
||||
:model-value="value || defaultAclPreset"
|
||||
@update:model-value="(nextValue) => handleChange(nextValue)"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="$t('bots.aclPreset')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="preset in aclPresetOptions"
|
||||
:key="preset.value"
|
||||
:value="preset.value"
|
||||
>
|
||||
{{ $t(preset.titleKey) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<p
|
||||
v-if="getAclPresetDescription(value || defaultAclPreset)"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ getAclPresetDescription(value || defaultAclPreset) }}
|
||||
</p>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div class="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ $t('bots.createBotWaitHint') }}
|
||||
</div>
|
||||
@@ -117,10 +168,18 @@ import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
Separator,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Label,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@memohai/ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { CircleHelp, Plus } from 'lucide-vue-next'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
@@ -129,6 +188,7 @@ import { useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { postBotsMutation, getBotsQueryKey } from '@memohai/sdk/colada'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDialogMutation } from '@/composables/useDialogMutation'
|
||||
import { aclPresetOptions, defaultAclPreset } from '@/constants/acl-presets'
|
||||
import { emptyTimezoneValue } from '@/utils/timezones'
|
||||
import TimezoneSelect from '@/components/timezone-select/index.vue'
|
||||
|
||||
@@ -140,6 +200,7 @@ const formSchema = toTypedSchema(z.object({
|
||||
display_name: z.string().min(1),
|
||||
avatar_url: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
acl_preset: z.string().min(1),
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
@@ -148,6 +209,7 @@ const form = useForm({
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
timezone: '',
|
||||
acl_preset: defaultAclPreset,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -157,6 +219,20 @@ const { mutateAsync: createBot, isLoading: submitLoading } = useMutation({
|
||||
onSettled: () => queryCache.invalidateQueries({ key: getBotsQueryKey() }),
|
||||
})
|
||||
|
||||
function getAclPresetOption(value?: string) {
|
||||
const presetValue = value || defaultAclPreset
|
||||
return aclPresetOptions.find(option => option.value === presetValue)
|
||||
}
|
||||
|
||||
function getAclPresetDescriptionKey(value?: string) {
|
||||
return getAclPresetOption(value)?.descriptionKey
|
||||
}
|
||||
|
||||
function getAclPresetDescription(value?: string) {
|
||||
const descriptionKey = getAclPresetDescriptionKey(value)
|
||||
return descriptionKey ? t(descriptionKey) : ''
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) {
|
||||
form.resetForm({
|
||||
@@ -164,6 +240,7 @@ watch(open, (val) => {
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
timezone: '',
|
||||
acl_preset: defaultAclPreset,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -179,6 +256,7 @@ const handleSubmit = form.handleSubmit(async (values) => {
|
||||
avatar_url: values.avatar_url || undefined,
|
||||
timezone: values.timezone || undefined,
|
||||
is_active: true,
|
||||
acl_preset: values.acl_preset,
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0066_acl_default_allow
|
||||
-- Restore the bot ACL default effect to deny for newly created bots.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'bots' AND column_name = 'acl_default_effect'
|
||||
) THEN
|
||||
ALTER TABLE bots
|
||||
ALTER COLUMN acl_default_effect SET DEFAULT 'deny';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0066_acl_default_allow
|
||||
-- Change the bot ACL default effect to allow for newly created bots.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'bots' AND column_name = 'acl_default_effect'
|
||||
) THEN
|
||||
ALTER TABLE bots
|
||||
ALTER COLUMN acl_default_effect SET DEFAULT 'allow';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,194 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
|
||||
const (
|
||||
PresetAllowAll = "allow_all"
|
||||
PresetPrivateOnly = "private_only"
|
||||
PresetGroupOnly = "group_only"
|
||||
PresetGroupAndThreadOnly = "group_and_thread_only"
|
||||
PresetDenyAll = "deny_all"
|
||||
)
|
||||
|
||||
var ErrUnknownPreset = errors.New("unknown acl preset")
|
||||
|
||||
type Preset struct {
|
||||
Key string
|
||||
DefaultEffect string
|
||||
Rules []CreateRuleRequest
|
||||
}
|
||||
|
||||
func DefaultPresetKey() string {
|
||||
return PresetAllowAll
|
||||
}
|
||||
|
||||
func NormalizePresetKey(raw string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
if value == "" {
|
||||
return DefaultPresetKey()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func ResolvePreset(raw string) (Preset, error) {
|
||||
switch NormalizePresetKey(raw) {
|
||||
case PresetAllowAll:
|
||||
return Preset{
|
||||
Key: PresetAllowAll,
|
||||
DefaultEffect: EffectAllow,
|
||||
}, nil
|
||||
case PresetPrivateOnly:
|
||||
return Preset{
|
||||
Key: PresetPrivateOnly,
|
||||
DefaultEffect: EffectDeny,
|
||||
Rules: []CreateRuleRequest{
|
||||
{
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
Effect: EffectAllow,
|
||||
SubjectKind: SubjectKindAll,
|
||||
SourceScope: &SourceScope{ConversationType: "private"},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
case PresetGroupOnly:
|
||||
return Preset{
|
||||
Key: PresetGroupOnly,
|
||||
DefaultEffect: EffectDeny,
|
||||
Rules: []CreateRuleRequest{
|
||||
{
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
Effect: EffectAllow,
|
||||
SubjectKind: SubjectKindAll,
|
||||
SourceScope: &SourceScope{ConversationType: "group"},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
case PresetGroupAndThreadOnly:
|
||||
return Preset{
|
||||
Key: PresetGroupAndThreadOnly,
|
||||
DefaultEffect: EffectDeny,
|
||||
Rules: []CreateRuleRequest{
|
||||
{
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
Effect: EffectAllow,
|
||||
SubjectKind: SubjectKindAll,
|
||||
SourceScope: &SourceScope{ConversationType: "group"},
|
||||
},
|
||||
{
|
||||
Priority: 110,
|
||||
Enabled: true,
|
||||
Effect: EffectAllow,
|
||||
SubjectKind: SubjectKindAll,
|
||||
SourceScope: &SourceScope{ConversationType: "thread"},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
case PresetDenyAll:
|
||||
return Preset{
|
||||
Key: PresetDenyAll,
|
||||
DefaultEffect: EffectDeny,
|
||||
}, nil
|
||||
default:
|
||||
return Preset{}, ErrUnknownPreset
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyPreset(ctx context.Context, queries *sqlc.Queries, botID, createdByUserID, rawPreset string) error {
|
||||
if queries == nil {
|
||||
return errors.New("acl queries not configured")
|
||||
}
|
||||
|
||||
preset, err := ResolvePreset(rawPreset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pgBotID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := queries.SetBotACLDefaultEffect(ctx, sqlc.SetBotACLDefaultEffectParams{
|
||||
ID: pgBotID,
|
||||
AclDefaultEffect: preset.DefaultEffect,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, rule := range preset.Rules {
|
||||
if err := applyPresetRule(ctx, queries, pgBotID, createdByUserID, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyPresetRule(ctx context.Context, queries *sqlc.Queries, botID pgtype.UUID, createdByUserID string, rule CreateRuleRequest) error {
|
||||
if err := validateEffect(rule.Effect); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSubject(rule.SubjectKind, rule.ChannelIdentityID, rule.SubjectChannelType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceScope, err := normalizeOptionalSourceScope(rule.SourceScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceChannel, err := presetSourceChannel(rule.SubjectKind, rule.SubjectChannelType, sourceScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = queries.CreateBotACLRule(ctx, sqlc.CreateBotACLRuleParams{
|
||||
BotID: botID,
|
||||
Priority: rule.Priority,
|
||||
Enabled: rule.Enabled,
|
||||
Description: optionalText(rule.Description),
|
||||
Effect: rule.Effect,
|
||||
SubjectKind: rule.SubjectKind,
|
||||
ChannelIdentityID: optionalUUID(rule.ChannelIdentityID),
|
||||
SubjectChannelType: optionalText(rule.SubjectChannelType),
|
||||
SourceChannel: optionalText(sourceChannel),
|
||||
SourceConversationType: optionalText(sourceScope.ConversationType),
|
||||
SourceConversationID: optionalText(sourceScope.ConversationID),
|
||||
SourceThreadID: optionalText(sourceScope.ThreadID),
|
||||
CreatedByUserID: optionalUUID(createdByUserID),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func presetSourceChannel(subjectKind, subjectChannelType string, sourceScope SourceScope) (string, error) {
|
||||
if sourceScope.IsZero() {
|
||||
return "", nil
|
||||
}
|
||||
if sourceScope.ConversationID == "" && sourceScope.ThreadID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(subjectKind) {
|
||||
case SubjectKindChannelType:
|
||||
return strings.TrimSpace(subjectChannelType), nil
|
||||
case SubjectKindAll:
|
||||
return "", fmt.Errorf("acl preset rule cannot scope subject_kind=%q to a concrete conversation without source channel", SubjectKindAll)
|
||||
case SubjectKindChannelIdentity:
|
||||
return "", fmt.Errorf("acl preset rule cannot scope subject_kind=%q to a concrete conversation without source channel", SubjectKindChannelIdentity)
|
||||
default:
|
||||
return "", ErrInvalidRuleSubject
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
|
||||
func TestResolvePreset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
wantKey string
|
||||
wantEffect string
|
||||
wantRuleCount int
|
||||
wantFirstType string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "empty falls back to allow all",
|
||||
key: "",
|
||||
wantKey: PresetAllowAll,
|
||||
wantEffect: EffectAllow,
|
||||
wantRuleCount: 0,
|
||||
},
|
||||
{
|
||||
name: "private only",
|
||||
key: PresetPrivateOnly,
|
||||
wantKey: PresetPrivateOnly,
|
||||
wantEffect: EffectDeny,
|
||||
wantRuleCount: 1,
|
||||
wantFirstType: "private",
|
||||
},
|
||||
{
|
||||
name: "group and thread only",
|
||||
key: PresetGroupAndThreadOnly,
|
||||
wantKey: PresetGroupAndThreadOnly,
|
||||
wantEffect: EffectDeny,
|
||||
wantRuleCount: 2,
|
||||
wantFirstType: "group",
|
||||
},
|
||||
{
|
||||
name: "invalid preset",
|
||||
key: "nope",
|
||||
wantErr: ErrUnknownPreset,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
preset, err := ResolvePreset(tt.key)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preset.Key != tt.wantKey {
|
||||
t.Fatalf("expected key %q, got %q", tt.wantKey, preset.Key)
|
||||
}
|
||||
if preset.DefaultEffect != tt.wantEffect {
|
||||
t.Fatalf("expected default effect %q, got %q", tt.wantEffect, preset.DefaultEffect)
|
||||
}
|
||||
if len(preset.Rules) != tt.wantRuleCount {
|
||||
t.Fatalf("expected %d rules, got %d", tt.wantRuleCount, len(preset.Rules))
|
||||
}
|
||||
if tt.wantFirstType != "" {
|
||||
got := preset.Rules[0].SourceScope.ConversationType
|
||||
if got != tt.wantFirstType {
|
||||
t.Fatalf("expected first conversation type %q, got %q", tt.wantFirstType, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPreset(t *testing.T) {
|
||||
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
|
||||
|
||||
type createdRule struct {
|
||||
priority int32
|
||||
effect string
|
||||
subjectKind string
|
||||
conversationType string
|
||||
}
|
||||
|
||||
var defaultEffect string
|
||||
var createdRules []createdRule
|
||||
|
||||
db := &fakeDBTX{
|
||||
execFunc: func(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
if strings.Contains(sql, "acl_default_effect") {
|
||||
defaultEffect = args[1].(string)
|
||||
}
|
||||
return pgconn.CommandTag{}, nil
|
||||
},
|
||||
queryRowFunc: func(_ context.Context, sql string, args ...any) pgx.Row {
|
||||
if strings.Contains(sql, "INSERT INTO bot_acl_rules") {
|
||||
createdRules = append(createdRules, createdRule{
|
||||
priority: args[1].(int32),
|
||||
effect: args[3].(string),
|
||||
subjectKind: args[4].(string),
|
||||
conversationType: textFromArg(args[10]),
|
||||
})
|
||||
return &fakeRow{scanFunc: func(_ ...any) error { return nil }}
|
||||
}
|
||||
return noRule()
|
||||
},
|
||||
}
|
||||
|
||||
err := ApplyPreset(context.Background(), sqlc.New(db), botUUID.String(), "", PresetGroupAndThreadOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if defaultEffect != EffectDeny {
|
||||
t.Fatalf("expected default effect %q, got %q", EffectDeny, defaultEffect)
|
||||
}
|
||||
if len(createdRules) != 2 {
|
||||
t.Fatalf("expected 2 created rules, got %d", len(createdRules))
|
||||
}
|
||||
if createdRules[0].priority != 100 || createdRules[0].conversationType != "group" {
|
||||
t.Fatalf("unexpected first rule: %+v", createdRules[0])
|
||||
}
|
||||
if createdRules[1].priority != 110 || createdRules[1].conversationType != "thread" {
|
||||
t.Fatalf("unexpected second rule: %+v", createdRules[1])
|
||||
}
|
||||
for _, rule := range createdRules {
|
||||
if rule.effect != EffectAllow || rule.subjectKind != SubjectKindAll {
|
||||
t.Fatalf("unexpected rule contents: %+v", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-18
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
@@ -25,17 +24,15 @@ var (
|
||||
|
||||
type Service struct {
|
||||
queries *sqlc.Queries
|
||||
bots *bots.Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(log *slog.Logger, queries *sqlc.Queries, botService *bots.Service) *Service {
|
||||
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
queries: queries,
|
||||
bots: botService,
|
||||
logger: log.With(slog.String("service", "acl")),
|
||||
}
|
||||
}
|
||||
@@ -43,7 +40,6 @@ func NewService(log *slog.Logger, queries *sqlc.Queries, botService *bots.Servic
|
||||
// Evaluate checks whether the given request is allowed to perform chat.trigger.
|
||||
// It uses a single first-match-wins query over priority-ordered enabled rules,
|
||||
// falling back to the bot's acl_default_effect if no rule matches.
|
||||
// The bot owner is always allowed without consulting the rule table.
|
||||
func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, error) {
|
||||
// Validate scope before any service nil checks so callers get meaningful errors.
|
||||
sourceScope, err := normalizeSourceScope(req.SourceScope)
|
||||
@@ -51,7 +47,7 @@ func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, erro
|
||||
return false, err
|
||||
}
|
||||
|
||||
if s == nil || s.queries == nil || s.bots == nil {
|
||||
if s == nil || s.queries == nil {
|
||||
return false, errors.New("acl service not configured")
|
||||
}
|
||||
|
||||
@@ -59,18 +55,6 @@ func (s *Service) Evaluate(ctx context.Context, req EvaluateRequest) (bool, erro
|
||||
channelIdentityID := strings.TrimSpace(req.ChannelIdentityID)
|
||||
channelType := strings.TrimSpace(req.ChannelType)
|
||||
|
||||
bot, err := s.bots.Get(ctx, botID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Owner always bypasses ACL.
|
||||
// Note: ChannelIdentityID here is the resolved Memoh user ID (set only when logged in).
|
||||
// The owner bypass was historically keyed on UserID; callers that pass the
|
||||
// ownerUserID via ChannelIdentityID will naturally not get bypassed here.
|
||||
// The inbound processor passes the resolved UserID separately — see the
|
||||
// comments in internal/channel/inbound/channel.go.
|
||||
_ = bot // currently unused after the user_id removal; keep the Get() for owner check wiring if re-added
|
||||
|
||||
pgBotID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
|
||||
@@ -90,40 +89,6 @@ func (*fakeRows) Conn() *pgx.Conn { return nil }
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
|
||||
return &fakeRow{
|
||||
scanFunc: func(dest ...any) error {
|
||||
if len(dest) < 22 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
*dest[0].(*pgtype.UUID) = botID
|
||||
*dest[1].(*pgtype.UUID) = ownerUserID
|
||||
*dest[2].(*pgtype.Text) = pgtype.Text{String: "bot", Valid: true}
|
||||
*dest[3].(*pgtype.Text) = pgtype.Text{}
|
||||
*dest[4].(*pgtype.Text) = pgtype.Text{}
|
||||
*dest[5].(*bool) = true
|
||||
*dest[6].(*string) = bots.BotStatusReady
|
||||
*dest[7].(*string) = "" // Language
|
||||
*dest[8].(*bool) = false // ReasoningEnabled
|
||||
*dest[9].(*string) = "medium" // ReasoningEffort
|
||||
*dest[10].(*pgtype.UUID) = pgtype.UUID{} // ChatModelID
|
||||
*dest[11].(*pgtype.UUID) = pgtype.UUID{} // SearchProviderID
|
||||
*dest[12].(*pgtype.UUID) = pgtype.UUID{} // MemoryProviderID
|
||||
*dest[13].(*bool) = false // HeartbeatEnabled
|
||||
*dest[14].(*int32) = 30 // HeartbeatInterval
|
||||
*dest[15].(*string) = "" // HeartbeatPrompt
|
||||
*dest[16].(*bool) = false // CompactionEnabled
|
||||
*dest[17].(*int32) = 100000 // CompactionThreshold
|
||||
*dest[18].(*int32) = 80 // CompactionRatio
|
||||
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
|
||||
*dest[20].(*[]byte) = []byte(`{}`)
|
||||
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makeStringRow(value string) *fakeRow {
|
||||
return &fakeRow{
|
||||
scanFunc: func(dest ...any) error {
|
||||
@@ -163,7 +128,6 @@ func noRule() *fakeRow {
|
||||
|
||||
func TestEvaluate(t *testing.T) {
|
||||
botUUID := pgtype.UUID{Bytes: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
|
||||
ownerUUID := pgtype.UUID{Bytes: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Valid: true}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -202,8 +166,6 @@ func TestEvaluate(t *testing.T) {
|
||||
db := &fakeDBTX{
|
||||
queryRowFunc: func(_ context.Context, sql string, _ ...any) pgx.Row {
|
||||
switch {
|
||||
case strings.Contains(sql, "FROM bots") && strings.Contains(sql, "owner_user_id"):
|
||||
return makeBotRow(botUUID, ownerUUID)
|
||||
case strings.Contains(sql, "FROM bot_acl_rules") && strings.Contains(sql, "LIMIT 1"):
|
||||
// Evaluate query
|
||||
if tt.matchedEffect == "" {
|
||||
@@ -218,8 +180,7 @@ func TestEvaluate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
queries := sqlc.New(db)
|
||||
botService := bots.NewService(nil, queries)
|
||||
service := NewService(nil, queries, botService)
|
||||
service := NewService(nil, queries)
|
||||
|
||||
allowed, err := service.Evaluate(context.Background(), EvaluateRequest{
|
||||
BotID: botUUID.String(),
|
||||
@@ -241,7 +202,7 @@ func TestEvaluate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvaluateRejectsInvalidScope(t *testing.T) {
|
||||
service := NewService(nil, nil, nil)
|
||||
service := NewService(nil, nil)
|
||||
_, err := service.Evaluate(context.Background(), EvaluateRequest{
|
||||
BotID: "11111111-1111-1111-1111-111111111111",
|
||||
SourceScope: SourceScope{
|
||||
@@ -306,7 +267,7 @@ func TestSetDefaultEffect(t *testing.T) {
|
||||
return pgconn.CommandTag{}, nil
|
||||
},
|
||||
}
|
||||
service := NewService(nil, sqlc.New(db), nil)
|
||||
service := NewService(nil, sqlc.New(db))
|
||||
if err := service.SetDefaultEffect(context.Background(), botUUID.String(), EffectAllow); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -346,7 +307,7 @@ func TestListObservedConversationsByChannelIdentity(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
service := NewService(nil, sqlc.New(db), nil)
|
||||
service := NewService(nil, sqlc.New(db))
|
||||
items, err := service.ListObservedConversationsByChannelIdentity(context.Background(), botUUID.String(), channelIdentityUUID.String())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -373,7 +334,7 @@ func TestReorderRules(t *testing.T) {
|
||||
return pgconn.CommandTag{}, nil
|
||||
},
|
||||
}
|
||||
service := NewService(nil, sqlc.New(db), nil)
|
||||
service := NewService(nil, sqlc.New(db))
|
||||
err := service.ReorderRules(context.Background(), []ReorderItem{
|
||||
{ID: ruleUUID.String(), Priority: 42},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/memohai/memoh/internal/acl"
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
tzutil "github.com/memohai/memoh/internal/timezone"
|
||||
@@ -100,6 +101,10 @@ func (s *Service) Create(ctx context.Context, ownerUserID string, req CreateBotR
|
||||
if err := s.ensureUserExists(ctx, ownerUUID); err != nil {
|
||||
return Bot{}, err
|
||||
}
|
||||
aclPresetKey := acl.NormalizePresetKey(req.AclPreset)
|
||||
if _, err := acl.ResolvePreset(aclPresetKey); err != nil {
|
||||
return Bot{}, err
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = "bot-" + uuid.NewString()
|
||||
@@ -137,6 +142,15 @@ func (s *Service) Create(ctx context.Context, ownerUserID string, req CreateBotR
|
||||
if err != nil {
|
||||
return Bot{}, err
|
||||
}
|
||||
if err := acl.ApplyPreset(ctx, s.queries, bot.ID, ownerID, aclPresetKey); err != nil {
|
||||
if cleanupErr := s.queries.DeleteBotByID(ctx, row.ID); cleanupErr != nil {
|
||||
return Bot{}, errors.Join(
|
||||
fmt.Errorf("apply acl preset: %w", err),
|
||||
fmt.Errorf("cleanup bot after acl preset failure: %w", cleanupErr),
|
||||
)
|
||||
}
|
||||
return Bot{}, fmt.Errorf("apply acl preset: %w", err)
|
||||
}
|
||||
if err := s.attachCheckSummary(ctx, &bot, asSQLCBot(row)); err != nil {
|
||||
return Bot{}, err
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/memohai/memoh/internal/acl"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
|
||||
@@ -145,3 +148,34 @@ func TestAuthorizeAccess(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsUnknownACLPreset(t *testing.T) {
|
||||
ownerUUID := mustParseUUID("00000000-0000-0000-0000-000000000001")
|
||||
createCalled := false
|
||||
|
||||
db := &fakeDBTX{
|
||||
queryRowFunc: func(_ context.Context, sql string, _ ...any) pgx.Row {
|
||||
switch {
|
||||
case strings.Contains(sql, "FROM users") && strings.Contains(sql, "WHERE id = $1"):
|
||||
return &fakeRow{scanFunc: func(_ ...any) error { return nil }}
|
||||
case strings.Contains(sql, "INSERT INTO bots"):
|
||||
createCalled = true
|
||||
return &fakeRow{scanFunc: func(_ ...any) error { return nil }}
|
||||
default:
|
||||
return &fakeRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewService(nil, sqlc.New(db))
|
||||
_, err := svc.Create(context.Background(), ownerUUID.String(), CreateBotRequest{
|
||||
DisplayName: "test-bot",
|
||||
AclPreset: "not_a_real_preset",
|
||||
})
|
||||
if !errors.Is(err, acl.ErrUnknownPreset) {
|
||||
t.Fatalf("expected ErrUnknownPreset, got %v", err)
|
||||
}
|
||||
if createCalled {
|
||||
t.Fatal("bot row should not be created when acl preset is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type CreateBotRequest struct {
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
AclPreset string `json:"acl_preset,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/memohai/memoh/internal/accounts"
|
||||
"github.com/memohai/memoh/internal/acl"
|
||||
"github.com/memohai/memoh/internal/auth"
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
@@ -442,6 +443,9 @@ func (h *UsersHandler) CreateBot(c echo.Context) error {
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "owner user not found")
|
||||
}
|
||||
if errors.Is(err, acl.ErrUnknownPreset) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusCreated, resp)
|
||||
|
||||
@@ -263,7 +263,7 @@ func normalizeBotSetting(language string, aclDefaultEffect string, reasoningEnab
|
||||
settings.Language = DefaultLanguage
|
||||
}
|
||||
if settings.AclDefaultEffect == "" {
|
||||
settings.AclDefaultEffect = "deny"
|
||||
settings.AclDefaultEffect = "allow"
|
||||
}
|
||||
if !isValidReasoningEffort(settings.ReasoningEffort) {
|
||||
settings.ReasoningEffort = DefaultReasoningEffort
|
||||
@@ -401,7 +401,7 @@ func normalizeBotSettingsFields(
|
||||
|
||||
func (s *Service) getDefaultEffect(ctx context.Context, botID string) (string, error) {
|
||||
if s.acl == nil {
|
||||
return "deny", nil
|
||||
return "allow", nil
|
||||
}
|
||||
return s.acl.GetDefaultEffect(ctx, botID)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -341,6 +341,7 @@ export type BotsBotCheck = {
|
||||
};
|
||||
|
||||
export type BotsCreateBotRequest = {
|
||||
acl_preset?: string;
|
||||
avatar_url?: string;
|
||||
display_name?: string;
|
||||
is_active?: boolean;
|
||||
@@ -1393,12 +1394,38 @@ export type ProvidersImportModelsResponse = {
|
||||
skipped?: number;
|
||||
};
|
||||
|
||||
export type ProvidersOAuthAccount = {
|
||||
avatar_url?: string;
|
||||
email?: string;
|
||||
label?: string;
|
||||
login?: string;
|
||||
name?: string;
|
||||
profile_url?: string;
|
||||
};
|
||||
|
||||
export type ProvidersOAuthAuthorizeResponse = {
|
||||
auth_url?: string;
|
||||
device?: ProvidersOAuthDeviceStatus;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
export type ProvidersOAuthDeviceStatus = {
|
||||
expires_at?: string;
|
||||
interval_seconds?: number;
|
||||
pending?: boolean;
|
||||
user_code?: string;
|
||||
verification_uri?: string;
|
||||
};
|
||||
|
||||
export type ProvidersOAuthStatus = {
|
||||
account?: ProvidersOAuthAccount;
|
||||
callback_url?: string;
|
||||
configured?: boolean;
|
||||
device?: ProvidersOAuthDeviceStatus;
|
||||
expired?: boolean;
|
||||
expires_at?: string;
|
||||
has_token?: boolean;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
export type ProvidersTestResponse = {
|
||||
@@ -1558,6 +1585,7 @@ export type SettingsSettings = {
|
||||
compaction_model_id?: string;
|
||||
compaction_ratio?: number;
|
||||
compaction_threshold?: number;
|
||||
context_token_budget?: number;
|
||||
discuss_probe_model_id?: string;
|
||||
heartbeat_enabled?: boolean;
|
||||
heartbeat_interval?: number;
|
||||
@@ -1565,9 +1593,11 @@ export type SettingsSettings = {
|
||||
image_model_id?: string;
|
||||
language?: string;
|
||||
memory_provider_id?: string;
|
||||
persist_full_tool_results?: boolean;
|
||||
reasoning_effort?: string;
|
||||
reasoning_enabled?: boolean;
|
||||
search_provider_id?: string;
|
||||
timezone?: string;
|
||||
title_model_id?: string;
|
||||
tts_model_id?: string;
|
||||
};
|
||||
@@ -1580,6 +1610,7 @@ export type SettingsUpsertRequest = {
|
||||
compaction_model_id?: string;
|
||||
compaction_ratio?: number;
|
||||
compaction_threshold?: number;
|
||||
context_token_budget?: number;
|
||||
discuss_probe_model_id?: string;
|
||||
heartbeat_enabled?: boolean;
|
||||
heartbeat_interval?: number;
|
||||
@@ -1587,6 +1618,7 @@ export type SettingsUpsertRequest = {
|
||||
image_model_id?: string;
|
||||
language?: string;
|
||||
memory_provider_id?: string;
|
||||
persist_full_tool_results?: boolean;
|
||||
reasoning_effort?: string;
|
||||
reasoning_enabled?: boolean;
|
||||
search_provider_id?: string;
|
||||
@@ -7720,13 +7752,45 @@ export type GetProvidersByIdOauthAuthorizeResponses = {
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
200: {
|
||||
[key: string]: string;
|
||||
};
|
||||
200: ProvidersOAuthAuthorizeResponse;
|
||||
};
|
||||
|
||||
export type GetProvidersByIdOauthAuthorizeResponse = GetProvidersByIdOauthAuthorizeResponses[keyof GetProvidersByIdOauthAuthorizeResponses];
|
||||
|
||||
export type PostProvidersByIdOauthPollData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Provider ID (UUID)
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/providers/{id}/oauth/poll';
|
||||
};
|
||||
|
||||
export type PostProvidersByIdOauthPollErrors = {
|
||||
/**
|
||||
* Bad Request
|
||||
*/
|
||||
400: HandlersErrorResponse;
|
||||
/**
|
||||
* Not Found
|
||||
*/
|
||||
404: HandlersErrorResponse;
|
||||
};
|
||||
|
||||
export type PostProvidersByIdOauthPollError = PostProvidersByIdOauthPollErrors[keyof PostProvidersByIdOauthPollErrors];
|
||||
|
||||
export type PostProvidersByIdOauthPollResponses = {
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
200: ProvidersOAuthStatus;
|
||||
};
|
||||
|
||||
export type PostProvidersByIdOauthPollResponse = PostProvidersByIdOauthPollResponses[keyof PostProvidersByIdOauthPollResponses];
|
||||
|
||||
export type GetProvidersByIdOauthStatusData = {
|
||||
body?: never;
|
||||
path: {
|
||||
|
||||
@@ -25,7 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
data-slot="tooltip-content"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn(
|
||||
'z-50 w-fit rounded-lg px-3 py-1.5 text-xs text-balance',
|
||||
'z-50 w-fit rounded-lg px-3 py-1.5 text-xs whitespace-normal break-words',
|
||||
'bg-primary text-primary-foreground shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
|
||||
+125
-4
@@ -7387,10 +7387,44 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/providers.OAuthAuthorizeResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/providers/{id}/oauth/poll": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"providers-oauth"
|
||||
],
|
||||
"summary": "Poll OAuth device authorization for an LLM provider",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Provider ID (UUID)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/providers.OAuthStatus"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -9458,6 +9492,9 @@ const docTemplate = `{
|
||||
"bots.CreateBotRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acl_preset": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12156,15 +12193,78 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthAccount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"login": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"profile_url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthAuthorizeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"device": {
|
||||
"$ref": "#/definitions/providers.OAuthDeviceStatus"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthDeviceStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pending": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"verification_uri": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"$ref": "#/definitions/providers.OAuthAccount"
|
||||
},
|
||||
"callback_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"configured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"device": {
|
||||
"$ref": "#/definitions/providers.OAuthDeviceStatus"
|
||||
},
|
||||
"expired": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -12173,6 +12273,9 @@ const docTemplate = `{
|
||||
},
|
||||
"has_token": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12575,6 +12678,9 @@ const docTemplate = `{
|
||||
"compaction_threshold": {
|
||||
"type": "integer"
|
||||
},
|
||||
"context_token_budget": {
|
||||
"type": "integer"
|
||||
},
|
||||
"discuss_probe_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12596,6 +12702,9 @@ const docTemplate = `{
|
||||
"memory_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"persist_full_tool_results": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12605,6 +12714,9 @@ const docTemplate = `{
|
||||
"search_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"title_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12637,6 +12749,9 @@ const docTemplate = `{
|
||||
"compaction_threshold": {
|
||||
"type": "integer"
|
||||
},
|
||||
"context_token_budget": {
|
||||
"type": "integer"
|
||||
},
|
||||
"discuss_probe_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12658,6 +12773,9 @@ const docTemplate = `{
|
||||
"memory_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"persist_full_tool_results": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12667,6 +12785,9 @@ const docTemplate = `{
|
||||
"search_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"title_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+125
-4
@@ -7378,10 +7378,44 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/providers.OAuthAuthorizeResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/providers/{id}/oauth/poll": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"providers-oauth"
|
||||
],
|
||||
"summary": "Poll OAuth device authorization for an LLM provider",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Provider ID (UUID)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/providers.OAuthStatus"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -9449,6 +9483,9 @@
|
||||
"bots.CreateBotRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acl_preset": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12147,15 +12184,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthAccount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"login": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"profile_url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthAuthorizeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"device": {
|
||||
"$ref": "#/definitions/providers.OAuthDeviceStatus"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthDeviceStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pending": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"verification_uri": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.OAuthStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"$ref": "#/definitions/providers.OAuthAccount"
|
||||
},
|
||||
"callback_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"configured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"device": {
|
||||
"$ref": "#/definitions/providers.OAuthDeviceStatus"
|
||||
},
|
||||
"expired": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -12164,6 +12264,9 @@
|
||||
},
|
||||
"has_token": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12566,6 +12669,9 @@
|
||||
"compaction_threshold": {
|
||||
"type": "integer"
|
||||
},
|
||||
"context_token_budget": {
|
||||
"type": "integer"
|
||||
},
|
||||
"discuss_probe_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12587,6 +12693,9 @@
|
||||
"memory_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"persist_full_tool_results": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12596,6 +12705,9 @@
|
||||
"search_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"title_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12628,6 +12740,9 @@
|
||||
"compaction_threshold": {
|
||||
"type": "integer"
|
||||
},
|
||||
"context_token_budget": {
|
||||
"type": "integer"
|
||||
},
|
||||
"discuss_probe_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12649,6 +12764,9 @@
|
||||
"memory_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"persist_full_tool_results": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12658,6 +12776,9 @@
|
||||
"search_provider_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"title_model_id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+82
-3
@@ -539,6 +539,8 @@ definitions:
|
||||
type: object
|
||||
bots.CreateBotRequest:
|
||||
properties:
|
||||
acl_preset:
|
||||
type: string
|
||||
avatar_url:
|
||||
type: string
|
||||
display_name:
|
||||
@@ -2330,18 +2332,61 @@ definitions:
|
||||
skipped:
|
||||
type: integer
|
||||
type: object
|
||||
providers.OAuthAccount:
|
||||
properties:
|
||||
avatar_url:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
login:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
profile_url:
|
||||
type: string
|
||||
type: object
|
||||
providers.OAuthAuthorizeResponse:
|
||||
properties:
|
||||
auth_url:
|
||||
type: string
|
||||
device:
|
||||
$ref: '#/definitions/providers.OAuthDeviceStatus'
|
||||
mode:
|
||||
type: string
|
||||
type: object
|
||||
providers.OAuthDeviceStatus:
|
||||
properties:
|
||||
expires_at:
|
||||
type: string
|
||||
interval_seconds:
|
||||
type: integer
|
||||
pending:
|
||||
type: boolean
|
||||
user_code:
|
||||
type: string
|
||||
verification_uri:
|
||||
type: string
|
||||
type: object
|
||||
providers.OAuthStatus:
|
||||
properties:
|
||||
account:
|
||||
$ref: '#/definitions/providers.OAuthAccount'
|
||||
callback_url:
|
||||
type: string
|
||||
configured:
|
||||
type: boolean
|
||||
device:
|
||||
$ref: '#/definitions/providers.OAuthDeviceStatus'
|
||||
expired:
|
||||
type: boolean
|
||||
expires_at:
|
||||
type: string
|
||||
has_token:
|
||||
type: boolean
|
||||
mode:
|
||||
type: string
|
||||
type: object
|
||||
providers.TestResponse:
|
||||
properties:
|
||||
@@ -2614,6 +2659,8 @@ definitions:
|
||||
type: integer
|
||||
compaction_threshold:
|
||||
type: integer
|
||||
context_token_budget:
|
||||
type: integer
|
||||
discuss_probe_model_id:
|
||||
type: string
|
||||
heartbeat_enabled:
|
||||
@@ -2628,12 +2675,16 @@ definitions:
|
||||
type: string
|
||||
memory_provider_id:
|
||||
type: string
|
||||
persist_full_tool_results:
|
||||
type: boolean
|
||||
reasoning_effort:
|
||||
type: string
|
||||
reasoning_enabled:
|
||||
type: boolean
|
||||
search_provider_id:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
title_model_id:
|
||||
type: string
|
||||
tts_model_id:
|
||||
@@ -2655,6 +2706,8 @@ definitions:
|
||||
type: integer
|
||||
compaction_threshold:
|
||||
type: integer
|
||||
context_token_budget:
|
||||
type: integer
|
||||
discuss_probe_model_id:
|
||||
type: string
|
||||
heartbeat_enabled:
|
||||
@@ -2669,12 +2722,16 @@ definitions:
|
||||
type: string
|
||||
memory_provider_id:
|
||||
type: string
|
||||
persist_full_tool_results:
|
||||
type: boolean
|
||||
reasoning_effort:
|
||||
type: string
|
||||
reasoning_enabled:
|
||||
type: boolean
|
||||
search_provider_id:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
title_model_id:
|
||||
type: string
|
||||
tts_model_id:
|
||||
@@ -7615,9 +7672,7 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/providers.OAuthAuthorizeResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
@@ -7629,6 +7684,30 @@ paths:
|
||||
summary: Start OAuth2 authorization for an LLM provider
|
||||
tags:
|
||||
- providers-oauth
|
||||
/providers/{id}/oauth/poll:
|
||||
post:
|
||||
parameters:
|
||||
- description: Provider ID (UUID)
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/providers.OAuthStatus'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Poll OAuth device authorization for an LLM provider
|
||||
tags:
|
||||
- providers-oauth
|
||||
/providers/{id}/oauth/status:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
Reference in New Issue
Block a user