diff --git a/frontend/src/components/sessionGrouping.test.ts b/frontend/src/components/sessionGrouping.test.ts index c00b67ea7..eb1f4e0aa 100644 --- a/frontend/src/components/sessionGrouping.test.ts +++ b/frontend/src/components/sessionGrouping.test.ts @@ -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', () => { diff --git a/frontend/src/components/sessionGrouping.ts b/frontend/src/components/sessionGrouping.ts index 3d60b5ae8..0053b78c2 100644 --- a/frontend/src/components/sessionGrouping.ts +++ b/frontend/src/components/sessionGrouping.ts @@ -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' } diff --git a/internal/application/repository/session.go b/internal/application/repository/session.go index 778222fb6..82ad99d90 100644 --- a/internal/application/repository/session.go +++ b/internal/application/repository/session.go @@ -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::", 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+"%") diff --git a/internal/application/repository/session_test.go b/internal/application/repository/session_test.go index a80a543d2..5e9a7e4f2 100644 --- a/internal/application/repository/session_test.go +++ b/internal/application/repository/session_test.go @@ -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), + ) } diff --git a/internal/application/service/session.go b/internal/application/service/session.go index c38c7b01c..935c465e7 100644 --- a/internal/application/service/session.go +++ b/internal/application/service/session.go @@ -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 != "" { diff --git a/internal/application/service/session_user_scope_test.go b/internal/application/service/session_user_scope_test.go index 0f93203d9..7045f6076 100644 --- a/internal/application/service/session_user_scope_test.go +++ b/internal/application/service/session_user_scope_test.go @@ -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) { diff --git a/internal/types/principal.go b/internal/types/principal.go index e3cb95861..cf2087c9d 100644 --- a/internal/types/principal.go +++ b/internal/types/principal.go @@ -26,6 +26,19 @@ const EmbedVisitorHeader = "X-Embed-Visitor" // LIKE '%' 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 ":". +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. diff --git a/internal/types/session.go b/internal/types/session.go index 18da9094f..e6f3b7a74 100644 --- a/internal/types/session.go +++ b/internal/types/session.go @@ -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) || diff --git a/internal/types/session_access_test.go b/internal/types/session_access_test.go index 2bb832fd5..4ca4c4623 100644 --- a/internal/types/session_access_test.go +++ b/internal/types/session_access_test.go @@ -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, "") {