feat(acl): redesign ACL with conversation scope selector (#297)

Backend
- New subject kinds: all / channel_identity / channel_type
- Source scope fields on bot_acl_rules: source_channel,
  source_conversation_type, source_conversation_id, source_thread_id
- Fix source_scope_check constraint: resolve source_channel server-side
  (channel_type → subject_channel_type; channel_identity → DB lookup)
- Add GET /bots/:id/acl/channel-types/:type/conversations to list
  observed conversations by platform type
- ListObservedConversations: include private/DM chats, normalise
  conversation_type; COALESCE(name, handle) for display name
- enrichConversationAvatar: persist entry.Name → conversation_name
  (keeps Telegram group titles current on every message)
- Unify Priority type to int32 across Go types to match DB INTEGER;
  remove all int/int32 casts in service layer
- Fix duplicate nil guard in Evaluate; drop dead SourceScope.Channel field
- Migration 0048_acl_redesign

Frontend
- Drag-and-drop rule priority reordering (SortableJS/useSortable);
  fix reorder: compute new order from oldIndex/newIndex directly,
  not from the array (which useSortable syncs after onEnd)
- Conversation scope selector: searchable popover backed by observed
  conversations (by identity or platform type); collapsible manual-ID fallback
- Display: name as primary label, stable channel·type·id always shown
  as subtitle for verification
- bot-terminal: accessibility fix on close-tab button (keyboard events)
- i18n: drag-to-reorder, conversation source, manual IDs (en/zh)

Tests: update fakeChatACL to Evaluate interface; fix SourceScope literals.
SDK/spec regenerated.
This commit is contained in:
BBQ
2026-03-28 01:06:13 +08:00
committed by GitHub
parent 64378d29ed
commit 7f9d6e4aba
30 changed files with 4599 additions and 3556 deletions
+57
View File
@@ -0,0 +1,57 @@
-- 0044_acl_redesign
-- Rollback: restore old bot_acl_rules schema and remove bots.acl_default_effect.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM bot_acl_rules
WHERE subject_kind IN ('all', 'channel_type')
) THEN
RAISE EXCEPTION 'cannot rollback 0044_acl_redesign while "all" or "channel_type" ACL rules exist';
END IF;
END $$;
-- Restore user_id column
ALTER TABLE bot_acl_rules
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_user_id ON bot_acl_rules(user_id);
-- Drop new columns
ALTER TABLE bot_acl_rules
DROP COLUMN IF EXISTS priority,
DROP COLUMN IF EXISTS enabled,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS subject_channel_type;
DROP INDEX IF EXISTS idx_bot_acl_rules_bot_priority;
-- Drop new constraints
ALTER TABLE bot_acl_rules
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_kind_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_value_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_unique_channel_identity;
-- Restore old constraints
ALTER TABLE bot_acl_rules
ADD CONSTRAINT bot_acl_rules_subject_kind_check CHECK (subject_kind IN ('guest_all', 'user', 'channel_identity')),
ADD CONSTRAINT bot_acl_rules_subject_value_check CHECK (
(subject_kind = 'guest_all' AND user_id IS NULL AND channel_identity_id IS NULL) OR
(subject_kind = 'user' AND user_id IS NOT NULL AND channel_identity_id IS NULL) OR
(subject_kind = 'channel_identity' AND user_id IS NULL AND channel_identity_id IS NOT NULL)
),
ADD CONSTRAINT bot_acl_rules_unique_user UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, user_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id
),
ADD CONSTRAINT bot_acl_rules_unique_channel_identity UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, channel_identity_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id
);
-- Remove acl_default_effect from bots
ALTER TABLE bots
DROP CONSTRAINT IF EXISTS bots_acl_default_effect_check;
ALTER TABLE bots
DROP COLUMN IF EXISTS acl_default_effect;
+80
View File
@@ -0,0 +1,80 @@
-- 0044_acl_redesign
-- Redesign bot ACL rules to priority-based first-match-wins with new subject kinds.
-- Removes user_id subject support and guest_all fallback row in favor of bots.acl_default_effect.
-- 1. Add acl_default_effect to bots (default deny = closed-by-default, same as current behavior)
ALTER TABLE bots
ADD COLUMN IF NOT EXISTS acl_default_effect TEXT NOT NULL DEFAULT 'deny';
ALTER TABLE bots
DROP CONSTRAINT IF EXISTS bots_acl_default_effect_check;
ALTER TABLE bots
ADD CONSTRAINT bots_acl_default_effect_check CHECK (acl_default_effect IN ('allow', 'deny'));
-- 2. Migrate existing guest_all allow rules -> set acl_default_effect = 'allow' on the bot
UPDATE bots
SET acl_default_effect = 'allow'
WHERE id IN (
SELECT bot_id
FROM bot_acl_rules
WHERE action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all'
);
-- 3. Add new columns to bot_acl_rules
ALTER TABLE bot_acl_rules
ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS description TEXT,
ADD COLUMN IF NOT EXISTS subject_channel_type TEXT;
-- 4. Assign priorities to existing channel_identity rules:
-- deny rules get priority 100, allow rules get priority 200
-- (preserving deny-before-allow behavior from the old evaluation pipeline)
UPDATE bot_acl_rules
SET priority = 100
WHERE subject_kind = 'channel_identity'
AND effect = 'deny';
UPDATE bot_acl_rules
SET priority = 200
WHERE subject_kind = 'channel_identity'
AND effect = 'allow';
-- 5. Delete all user-subject rules (no longer supported)
DELETE FROM bot_acl_rules WHERE subject_kind = 'user';
-- 6. Delete all guest_all rules (now represented by bots.acl_default_effect)
DELETE FROM bot_acl_rules WHERE subject_kind = 'guest_all';
-- 7. Drop old constraints before altering subject_kind values and columns
ALTER TABLE bot_acl_rules
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_kind_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_subject_value_check,
DROP CONSTRAINT IF EXISTS bot_acl_rules_unique_user,
DROP CONSTRAINT IF EXISTS bot_acl_rules_unique_channel_identity;
-- 8. Drop user_id column (no remaining user-subject rows)
ALTER TABLE bot_acl_rules
DROP COLUMN IF EXISTS user_id;
DROP INDEX IF EXISTS idx_bot_acl_rules_user_id;
-- 9. Add updated constraints
ALTER TABLE bot_acl_rules
ADD CONSTRAINT bot_acl_rules_subject_kind_check CHECK (subject_kind IN ('all', 'channel_identity', 'channel_type')),
ADD CONSTRAINT bot_acl_rules_subject_value_check CHECK (
(subject_kind = 'all' AND channel_identity_id IS NULL AND subject_channel_type IS NULL) OR
(subject_kind = 'channel_identity' AND channel_identity_id IS NOT NULL AND subject_channel_type IS NULL) OR
(subject_kind = 'channel_type' AND channel_identity_id IS NULL AND subject_channel_type IS NOT NULL)
),
ADD CONSTRAINT bot_acl_rules_unique_channel_identity UNIQUE NULLS NOT DISTINCT (
bot_id, action, effect, subject_kind, channel_identity_id,
source_conversation_type, source_conversation_id, source_thread_id
);
-- 10. Add indexes for new query patterns
CREATE INDEX IF NOT EXISTS idx_bot_acl_rules_bot_priority ON bot_acl_rules(bot_id, priority ASC, created_at ASC)
WHERE enabled = true;
+86 -106
View File
@@ -1,122 +1,46 @@
-- name: UpsertBotACLGuestAllAllowRule :one
INSERT INTO bot_acl_rules (bot_id, action, effect, subject_kind, created_by_user_id)
VALUES ($1, 'chat.trigger', 'allow', 'guest_all', $2)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_user
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpsertBotACLUserRule :one
INSERT INTO bot_acl_rules (
bot_id, action, effect, subject_kind, user_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id,
created_by_user_id
)
VALUES (
$1, 'chat.trigger', $2, 'user', $3,
sqlc.narg(source_channel)::text,
sqlc.narg(source_conversation_type)::text,
sqlc.narg(source_conversation_id)::text,
sqlc.narg(source_thread_id)::text,
$4
)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_user
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpsertBotACLChannelIdentityRule :one
INSERT INTO bot_acl_rules (
bot_id, action, effect, subject_kind, channel_identity_id,
source_channel, source_conversation_type, source_conversation_id, source_thread_id,
created_by_user_id
)
VALUES (
$1, 'chat.trigger', $2, 'channel_identity', $3,
sqlc.narg(source_channel)::text,
sqlc.narg(source_conversation_type)::text,
sqlc.narg(source_conversation_id)::text,
sqlc.narg(source_thread_id)::text,
$4
)
ON CONFLICT ON CONSTRAINT bot_acl_rules_unique_channel_identity
DO UPDATE SET
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, bot_acl_rules.created_by_user_id),
updated_at = now()
RETURNING id, bot_id, action, effect, subject_kind, user_id, channel_identity_id, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: DeleteBotACLGuestAllAllowRule :exec
DELETE FROM bot_acl_rules
-- name: EvaluateBotACLRule :one
-- First-match-wins: returns the effect of the highest-priority matching enabled rule.
-- If no row is returned, the caller falls back to bots.acl_default_effect.
SELECT effect
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all';
AND enabled = true
AND action = $2
AND (
subject_kind = 'all'
OR (subject_kind = 'channel_identity' AND channel_identity_id = sqlc.narg(channel_identity_id)::uuid)
OR (subject_kind = 'channel_type' AND subject_channel_type = sqlc.narg(subject_channel_type)::text)
)
AND (source_conversation_type IS NULL OR source_conversation_type = sqlc.narg(source_conversation_type)::text)
AND (source_conversation_id IS NULL OR source_conversation_id = sqlc.narg(source_conversation_id)::text)
AND (source_thread_id IS NULL OR source_thread_id = sqlc.narg(source_thread_id)::text)
ORDER BY priority ASC, created_at ASC
LIMIT 1;
-- name: DeleteBotACLRuleByID :exec
DELETE FROM bot_acl_rules
WHERE id = $1;
-- name: GetBotACLDefaultEffect :one
SELECT acl_default_effect FROM bots WHERE id = $1;
-- name: HasBotACLGuestAllAllowRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = 'allow'
AND subject_kind = 'guest_all'
) AS allowed;
-- name: SetBotACLDefaultEffect :exec
UPDATE bots SET acl_default_effect = $2, updated_at = now() WHERE id = $1;
-- name: HasBotACLUserRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = $2
AND subject_kind = 'user'
AND user_id = $3
AND (source_channel IS NULL OR source_channel = sqlc.narg(source_channel)::text)
AND (source_conversation_type IS NULL OR source_conversation_type = sqlc.narg(source_conversation_type)::text)
AND (source_conversation_id IS NULL OR source_conversation_id = sqlc.narg(source_conversation_id)::text)
AND (source_thread_id IS NULL OR source_thread_id = sqlc.narg(source_thread_id)::text)
) AS matched;
-- name: HasBotACLChannelIdentityRule :one
SELECT EXISTS (
SELECT 1
FROM bot_acl_rules
WHERE bot_id = $1
AND action = 'chat.trigger'
AND effect = $2
AND subject_kind = 'channel_identity'
AND channel_identity_id = $3
AND (source_channel IS NULL OR source_channel = sqlc.narg(source_channel)::text)
AND (source_conversation_type IS NULL OR source_conversation_type = sqlc.narg(source_conversation_type)::text)
AND (source_conversation_id IS NULL OR source_conversation_id = sqlc.narg(source_conversation_id)::text)
AND (source_thread_id IS NULL OR source_thread_id = sqlc.narg(source_thread_id)::text)
) AS matched;
-- name: ListBotACLSubjectRulesByEffect :many
-- name: ListBotACLRules :many
SELECT
r.id,
r.bot_id,
r.priority,
r.enabled,
r.description,
r.action,
r.effect,
r.subject_kind,
r.user_id,
r.channel_identity_id,
r.source_channel,
r.subject_channel_type,
r.source_conversation_type,
r.source_conversation_id,
r.source_thread_id,
r.created_by_user_id,
r.created_at,
r.updated_at,
u.username AS user_username,
u.display_name AS user_display_name,
u.avatar_url AS user_avatar_url,
ci.channel_type,
ci.channel_subject_id,
ci.display_name AS channel_identity_display_name,
@@ -126,11 +50,67 @@ SELECT
linked.display_name AS linked_user_display_name,
linked.avatar_url AS linked_user_avatar_url
FROM bot_acl_rules r
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN channel_identities ci ON ci.id = r.channel_identity_id
LEFT JOIN users linked ON linked.id = ci.user_id
WHERE r.bot_id = $1
AND r.action = 'chat.trigger'
AND r.effect = $2
AND r.subject_kind IN ('user', 'channel_identity')
ORDER BY r.created_at DESC;
ORDER BY r.priority ASC, r.created_at ASC;
-- name: CreateBotACLRule :one
INSERT INTO bot_acl_rules (
bot_id,
priority,
enabled,
description,
action,
effect,
subject_kind,
channel_identity_id,
subject_channel_type,
source_channel,
source_conversation_type,
source_conversation_id,
source_thread_id,
created_by_user_id
)
VALUES (
$1,
$2,
$3,
sqlc.narg(description)::text,
'chat.trigger',
$4,
$5,
sqlc.narg(channel_identity_id)::uuid,
sqlc.narg(subject_channel_type)::text,
sqlc.narg(source_channel)::text,
sqlc.narg(source_conversation_type)::text,
sqlc.narg(source_conversation_id)::text,
sqlc.narg(source_thread_id)::text,
$6
)
RETURNING id, bot_id, priority, enabled, description, action, effect, subject_kind, channel_identity_id, subject_channel_type, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpdateBotACLRule :one
UPDATE bot_acl_rules
SET
priority = $2,
enabled = $3,
description = sqlc.narg(description)::text,
effect = $4,
subject_kind = $5,
channel_identity_id = sqlc.narg(channel_identity_id)::uuid,
subject_channel_type = sqlc.narg(subject_channel_type)::text,
source_channel = sqlc.narg(source_channel)::text,
source_conversation_type = sqlc.narg(source_conversation_type)::text,
source_conversation_id = sqlc.narg(source_conversation_id)::text,
source_thread_id = sqlc.narg(source_thread_id)::text,
updated_at = now()
WHERE id = $1
RETURNING id, bot_id, priority, enabled, description, action, effect, subject_kind, channel_identity_id, subject_channel_type, source_channel, source_conversation_type, source_conversation_id, source_thread_id, created_by_user_id, created_at, updated_at;
-- name: UpdateBotACLRulePriority :exec
UPDATE bot_acl_rules SET priority = $2, updated_at = now() WHERE id = $1;
-- name: DeleteBotACLRuleByID :exec
DELETE FROM bot_acl_rules WHERE id = $1;
+48 -2
View File
@@ -310,15 +310,61 @@ SELECT
r.channel_type AS channel,
CASE
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread'
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('p2p', 'private', 'direct', 'dm') THEN 'private'
ELSE 'group'
END AS conversation_type,
r.external_conversation_id AS conversation_id,
COALESCE(r.external_thread_id, '') AS thread_id,
COALESCE(r.metadata->>'conversation_name', '')::text AS conversation_name,
COALESCE(
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_name', '')), ''),
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_handle', '')), ''),
''
)::text AS conversation_name,
rr.last_observed_at
FROM observed_routes rr
JOIN bot_channel_routes r ON r.id = rr.route_id
GROUP BY
r.id,
r.channel_type,
r.conversation_type,
r.external_conversation_id,
r.external_thread_id,
r.metadata,
rr.last_observed_at
ORDER BY rr.last_observed_at DESC;
-- name: ListObservedConversationsByChannelType :many
-- Routes on this platform type where the bot has seen at least one message (any sender).
WITH observed_routes AS (
SELECT
s.route_id,
MAX(m.created_at)::timestamptz AS last_observed_at
FROM bot_history_messages m
JOIN bot_sessions s ON s.id = m.session_id
JOIN bot_channel_routes r ON r.id = s.route_id
WHERE m.bot_id = sqlc.arg(bot_id)
AND LOWER(TRIM(r.channel_type)) = LOWER(TRIM(sqlc.arg(channel_type)))
AND s.route_id IS NOT NULL
GROUP BY s.route_id
)
SELECT
r.id AS route_id,
r.channel_type AS channel,
CASE
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('thread', 'topic') THEN 'thread'
WHEN LOWER(COALESCE(r.conversation_type, '')) IN ('p2p', 'private', 'direct', 'dm') THEN 'private'
ELSE 'group'
END AS conversation_type,
r.external_conversation_id AS conversation_id,
COALESCE(r.external_thread_id, '') AS thread_id,
COALESCE(
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_name', '')), ''),
NULLIF(TRIM(COALESCE(r.metadata->>'conversation_handle', '')), ''),
''
)::text AS conversation_name,
rr.last_observed_at
FROM observed_routes rr
JOIN bot_channel_routes r ON r.id = rr.route_id
WHERE LOWER(COALESCE(r.conversation_type, '')) NOT IN ('', 'p2p', 'private', 'direct', 'dm')
GROUP BY
r.id,
r.channel_type,