mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-24 16:29:01 +08:00
fix(session): include external users in API session group
This commit is contained in:
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
API_EXTERNAL_USER_SESSION_OWNER_PREFIX,
|
||||
EMBED_SESSION_MARKER_PREFIX,
|
||||
classifyDateBucket,
|
||||
configuredPlatforms,
|
||||
@@ -36,6 +37,10 @@ test('resolveSessionOrigin distinguishes web, IM, and embed sessions', () => {
|
||||
resolveSessionOrigin({ id: '4', user_id: 'api_tenant_key:1:10' }),
|
||||
{ kind: 'api' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
resolveSessionOrigin({ id: '5', user_id: `${API_EXTERNAL_USER_SESSION_OWNER_PREFIX}1:alice` }),
|
||||
{ kind: 'api' },
|
||||
)
|
||||
})
|
||||
|
||||
test('configuredPlatforms returns distinct platform keys in first-seen order', () => {
|
||||
|
||||
@@ -11,6 +11,9 @@ export const EMBED_SESSION_MARKER_PREFIX = 'embed_channel:'
|
||||
/** Mirrors backend types.SessionOwnerAPITenantKeyPrefix (sessions.user_id owner). */
|
||||
export const API_SESSION_OWNER_PREFIX = 'api_tenant_key:'
|
||||
|
||||
/** Mirrors backend types.SessionOwnerAPIExternalUserPrefix. */
|
||||
export const API_EXTERNAL_USER_SESSION_OWNER_PREFIX = 'api_external_user:'
|
||||
|
||||
export interface SessionForGrouping {
|
||||
id: string
|
||||
title?: string
|
||||
@@ -92,7 +95,11 @@ export function resolveSessionOrigin(session: SessionForGrouping): SessionOrigin
|
||||
const channelId = desc.slice(EMBED_SESSION_MARKER_PREFIX.length).trim()
|
||||
if (channelId) return { kind: 'embed', channelId }
|
||||
}
|
||||
if ((session.user_id || '').startsWith(API_SESSION_OWNER_PREFIX)) {
|
||||
const ownerId = session.user_id || ''
|
||||
if (
|
||||
ownerId.startsWith(API_SESSION_OWNER_PREFIX) ||
|
||||
ownerId.startsWith(API_EXTERNAL_USER_SESSION_OWNER_PREFIX)
|
||||
) {
|
||||
return { kind: 'api' }
|
||||
}
|
||||
return { kind: 'web' }
|
||||
|
||||
@@ -189,20 +189,27 @@ func (r *sessionRepository) QueryPaged(
|
||||
case "":
|
||||
return db
|
||||
case types.SessionSourceAPI:
|
||||
// Tenant-wide view of API-key sessions. Their owner id is
|
||||
// "api_tenant_key:<tenantID>:<keyID>", so a prefix match selects
|
||||
// every key's sessions. The service layer already enforced Admin+
|
||||
// and cleared the per-user scope for this source.
|
||||
return db.Where("s.user_id LIKE ?", types.SessionOwnerAPITenantKeyPrefix+"%")
|
||||
// Tenant-wide view of API-key sessions. Requests without an
|
||||
// external identity use an api_tenant_key owner; requests with a
|
||||
// direct-header or signed-token identity use api_external_user.
|
||||
// The service layer already enforced Admin+ and cleared the
|
||||
// per-user scope for this source.
|
||||
return db.Where(
|
||||
"(s.user_id LIKE ? OR s.user_id LIKE ?)",
|
||||
types.SessionOwnerAPITenantKeyPrefix+"%",
|
||||
types.SessionOwnerAPIExternalUserPrefix+"%",
|
||||
)
|
||||
case "web":
|
||||
// User web chats only — exclude embed-widget sessions (same IM-null
|
||||
// row) and tenant API-key sessions (surfaced only in the admin-only
|
||||
// "api" bucket). The user_id NULL check keeps legacy tenant-level web
|
||||
// rows visible, since "col NOT LIKE ?" is unknown (not true) for NULL.
|
||||
// row) and API-key sessions (surfaced only in the admin-only "api"
|
||||
// bucket). The user_id NULL check keeps legacy tenant-level web rows
|
||||
// visible, since "col NOT LIKE ?" is unknown (not true) for NULL.
|
||||
return db.Where(
|
||||
"ics.id IS NULL AND (s.description = '' OR s.description NOT LIKE ?) "+
|
||||
"AND (s.user_id IS NULL OR s.user_id NOT LIKE ?)",
|
||||
embedPrefix+"%", types.SessionOwnerAPITenantKeyPrefix+"%",
|
||||
"AND (s.user_id IS NULL OR (s.user_id NOT LIKE ? AND s.user_id NOT LIKE ?))",
|
||||
embedPrefix+"%",
|
||||
types.SessionOwnerAPITenantKeyPrefix+"%",
|
||||
types.SessionOwnerAPIExternalUserPrefix+"%",
|
||||
)
|
||||
case "embed":
|
||||
return db.Where("ics.id IS NULL AND s.description LIKE ?", embedPrefix+"%")
|
||||
|
||||
@@ -246,7 +246,7 @@ func TestSessionRepositoryQueryPagedSplitsWebAndEmbedSessions(t *testing.T) {
|
||||
require.Equal(t, []string{embed.ID}, listItemIDsForTest(embedItems))
|
||||
}
|
||||
|
||||
// The "web" source is user chats only; tenant API-key sessions live in the
|
||||
// The "web" source is user chats only; API-key sessions live in the
|
||||
// admin-only "api" bucket and must never leak into a tenant-wide web listing.
|
||||
// Legacy tenant-level rows (user_id "") must still show up in web.
|
||||
func TestSessionRepositoryQueryPagedWebExcludesAPIKeySessions(t *testing.T) {
|
||||
@@ -256,6 +256,7 @@ func TestSessionRepositoryQueryPagedWebExcludesAPIKeySessions(t *testing.T) {
|
||||
|
||||
legacy := createSessionForTest(t, db, 1, "") // legacy tenant web row
|
||||
_ = createSessionForTest(t, db, 1, types.SessionOwnerAPITenantKeyPrefix+"1:10")
|
||||
_ = createSessionForTest(t, db, 1, types.SessionOwnerAPIExternalUserPrefix+"1:alice")
|
||||
|
||||
items, _, err := repo.QueryPaged(ctx, &types.SessionListQuery{
|
||||
TenantID: 1, UserID: "", Source: "web", Page: 1, PageSize: 50,
|
||||
@@ -295,11 +296,15 @@ func TestSessionRepositoryQueryPagedAPISourceReturnsAllTenantAPIKeySessions(t *t
|
||||
require.NoError(t, db.AutoMigrate(&testIMChannelSession{}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Two different API keys plus a web user and a cross-tenant API session.
|
||||
// API requests without and with external-user identity, plus a web user and
|
||||
// cross-tenant API sessions.
|
||||
key1 := createSessionForTest(t, db, 1, types.SessionOwnerAPITenantKeyPrefix+"1:10")
|
||||
key2 := createSessionForTest(t, db, 1, types.SessionOwnerAPITenantKeyPrefix+"1:20")
|
||||
directHeader := createSessionForTest(t, db, 1, types.SessionOwnerAPIExternalUserPrefix+"1:alice")
|
||||
signedToken := createSessionForTest(t, db, 1, types.SessionOwnerAPIExternalUserPrefix+"1:bob")
|
||||
_ = createSessionForTest(t, db, 1, "alice")
|
||||
_ = createSessionForTest(t, db, 2, types.SessionOwnerAPITenantKeyPrefix+"2:30")
|
||||
_ = createSessionForTest(t, db, 2, types.SessionOwnerAPIExternalUserPrefix+"2:mallory")
|
||||
|
||||
// The admin view clears UserID, so every API-key session in the tenant is
|
||||
// returned regardless of which key created it.
|
||||
@@ -307,6 +312,10 @@ func TestSessionRepositoryQueryPagedAPISourceReturnsAllTenantAPIKeySessions(t *t
|
||||
TenantID: 1, UserID: "", Source: types.SessionSourceAPI, Page: 1, PageSize: 50,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, total)
|
||||
require.ElementsMatch(t, []string{key1.ID, key2.ID}, listItemIDsForTest(items))
|
||||
require.EqualValues(t, 4, total)
|
||||
require.ElementsMatch(
|
||||
t,
|
||||
[]string{key1.ID, key2.ID, directHeader.ID, signedToken.ID},
|
||||
listItemIDsForTest(items),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,12 +35,21 @@ func loadSessionForRead(
|
||||
) (*types.Session, error) {
|
||||
isAdmin := types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin)
|
||||
principal, hasPrincipal := types.PrincipalFromContext(ctx)
|
||||
isIMRuntime := hasPrincipal && principal.Type == types.PrincipalIMUser
|
||||
isChannelRuntime := false
|
||||
if hasPrincipal {
|
||||
switch principal.Type {
|
||||
case types.PrincipalAPITenant,
|
||||
types.PrincipalAPIPlatform,
|
||||
types.PrincipalAPIExternalUser,
|
||||
types.PrincipalIMUser:
|
||||
isChannelRuntime = true
|
||||
}
|
||||
}
|
||||
|
||||
session, err := repo.Get(ctx, tenantID, ownerID, sessionID)
|
||||
if err == nil {
|
||||
imPlatform, _ := repo.GetIMPlatform(ctx, tenantID, sessionID)
|
||||
if types.SessionRequiresAdminConsoleRead(session, imPlatform) && !isAdmin && !isIMRuntime {
|
||||
if types.SessionRequiresAdminConsoleRead(session, imPlatform) && !isAdmin && !isChannelRuntime {
|
||||
return nil, apperrors.ErrSessionNotFound
|
||||
}
|
||||
if imPlatform != "" {
|
||||
|
||||
@@ -203,9 +203,15 @@ func TestListSessionsAPISourceRequiresAdminAndReturnsAllKeys(t *testing.T) {
|
||||
|
||||
key1 := &types.Session{TenantID: 1, UserID: types.SessionOwnerAPITenantKeyPrefix + "1:10", Title: "key1"}
|
||||
key2 := &types.Session{TenantID: 1, UserID: types.SessionOwnerAPITenantKeyPrefix + "1:20", Title: "key2"}
|
||||
externalUser := &types.Session{
|
||||
TenantID: 1,
|
||||
UserID: types.SessionOwnerAPIExternalUserPrefix + "1:external-u1",
|
||||
Title: "external user",
|
||||
}
|
||||
web := &types.Session{TenantID: 1, UserID: "alice", Title: "alice web"}
|
||||
require.NoError(t, db.Create(key1).Error)
|
||||
require.NoError(t, db.Create(key2).Error)
|
||||
require.NoError(t, db.Create(externalUser).Error)
|
||||
require.NoError(t, db.Create(web).Error)
|
||||
|
||||
// A non-admin (viewer) web user is rejected.
|
||||
@@ -220,7 +226,28 @@ func TestListSessionsAPISourceRequiresAdminAndReturnsAllKeys(t *testing.T) {
|
||||
adminCtx := context.WithValue(testSessionScopeContext(1, "alice"), types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||||
result, err := svc.ListSessions(adminCtx, &types.SessionListQuery{Source: types.SessionSourceAPI})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, result.Total)
|
||||
require.EqualValues(t, 3, result.Total)
|
||||
}
|
||||
|
||||
func TestGetSessionAllowsAdminToReadAPIExternalUserSession(t *testing.T) {
|
||||
svc, db := newTestSessionService(t)
|
||||
require.NoError(t, db.AutoMigrate(&testListSessionsIMChannelSession{}))
|
||||
|
||||
apiSession := &types.Session{
|
||||
TenantID: 1,
|
||||
UserID: types.SessionOwnerAPIExternalUserPrefix + "1:external-u1",
|
||||
Title: "external user",
|
||||
}
|
||||
require.NoError(t, db.Create(apiSession).Error)
|
||||
|
||||
viewerCtx := testSessionScopeContext(1, "alice")
|
||||
_, err := svc.GetSession(viewerCtx, apiSession.ID)
|
||||
require.ErrorIs(t, err, apperrors.ErrSessionNotFound)
|
||||
|
||||
adminCtx := context.WithValue(viewerCtx, types.TenantRoleContextKey, types.TenantRoleAdmin)
|
||||
got, err := svc.GetSession(adminCtx, apiSession.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, apiSession.ID, got.ID)
|
||||
}
|
||||
|
||||
func TestListSessionsIMSourceRequiresAdmin(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,19 @@ const EmbedVisitorHeader = "X-Embed-Visitor"
|
||||
// LIKE '<prefix>%' selects every API-key session in the tenant.
|
||||
const SessionOwnerAPITenantKeyPrefix = "api_tenant_key:"
|
||||
|
||||
// SessionOwnerAPIExternalUserPrefix prefixes sessions.user_id for rows created
|
||||
// by a tenant API key whose request resolved an external-user identity. The
|
||||
// remainder is "<tenantID>:<externalUserID>".
|
||||
const SessionOwnerAPIExternalUserPrefix = PrincipalAPIExternalUser + ":"
|
||||
|
||||
// IsAPISessionOwnerID reports whether a stored session owner was produced by
|
||||
// a tenant API-key request, with or without an external-user identity.
|
||||
func IsAPISessionOwnerID(ownerID string) bool {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
return strings.HasPrefix(ownerID, SessionOwnerAPITenantKeyPrefix) ||
|
||||
strings.HasPrefix(ownerID, SessionOwnerAPIExternalUserPrefix)
|
||||
}
|
||||
|
||||
// Principal represents the terminal caller for per-subject isolation features.
|
||||
// It is intentionally separate from UserID: many principals, such as IM users
|
||||
// or embed visitors, are not WeKnora accounts and must not imply RBAC rights.
|
||||
|
||||
@@ -159,7 +159,7 @@ func SessionRequiresAdminConsoleRead(s *Session, imPlatform string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s.UserID, SessionOwnerAPITenantKeyPrefix) {
|
||||
if IsAPISessionOwnerID(s.UserID) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(s.Description, EmbedSessionMarkerPrefix) ||
|
||||
|
||||
@@ -28,6 +28,10 @@ func TestSessionRequiresAdminConsoleRead(t *testing.T) {
|
||||
if !SessionRequiresAdminConsoleRead(api, "") {
|
||||
t.Fatal("API-key session should require admin")
|
||||
}
|
||||
apiExternalUser := &Session{UserID: SessionOwnerAPIExternalUserPrefix + "1:alice"}
|
||||
if !SessionRequiresAdminConsoleRead(apiExternalUser, "") {
|
||||
t.Fatal("external-user API session should require admin")
|
||||
}
|
||||
|
||||
embed := &Session{Description: EmbedSessionMarkerPrefix + "ch-1"}
|
||||
if !SessionRequiresAdminConsoleRead(embed, "") {
|
||||
|
||||
Reference in New Issue
Block a user