fix(session): include external users in API session group

This commit is contained in:
wizardchen
2026-07-30 11:56:05 +08:00
committed by lyingbug
parent f3cd3d9724
commit 123f2d6349
9 changed files with 100 additions and 19 deletions
@@ -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', () => {
+8 -1
View File
@@ -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' }
+17 -10
View File
@@ -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),
)
}
+11 -2
View File
@@ -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) {
+13
View File
@@ -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.
+1 -1
View File
@@ -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) ||
+4
View File
@@ -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, "") {