From 478bc70b10057691071940c32a643f21ffbb8e55 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Tue, 20 Oct 2026 11:49:19 +0530 Subject: [PATCH 1/2] Disable AI memory for public URLs and embeds without external_user_id AI memory (#9992) is keyed on the runtime JWT subject and only treated an empty subject as anonymous. But the subject is shared by several viewers in two cases: an embed issued without external_user_id puts the issuer's identity (a service or user ID) on every viewer's token, and a public URL puts the magic token ID on everyone with the link. Viewers of the same embed or public URL therefore shared one memory store, so one viewer's "remember that..." silently shaped the answers of the others, and Manage memory let any viewer list, edit or delete the rest. Memory now needs a subject that identifies one viewer: - The admin server marks magic auth token JWTs with the attribute public_url, since they carry the creator's attributes and were otherwise indistinguishable from the creator's own token, and marks tokens issued with an external_user_id with external_user. Embed tokens already carry embed: true. - ResolveFeatureFlags resolves chat_memory to false for public URLs and for embeds without external_user, whatever the project configures. MemoryAvailable (the memory RPCs and the update_memory tool) enforces that flag, and GetInstance returns it to the UI, which already hides the memory button and Settings > AI memory, and now the "Memory updated" notice, when it is off. - Docs state that memory is per user, available in embeds only with external_user_id, and never through public URLs. Tests cover service-token, external-user and public URL claims in the runtime, and the JWT attributes issued for service-token embeds with and without an external user ID and for a public URL in the admin server. --- admin/server/deployment_test.go | 68 +++++++++++++++++++ admin/server/runtime_jwt.go | 12 +++- .../docs/developers/build/ai-configuration.md | 2 +- docs/docs/developers/embed/iframe.md | 2 + .../access-tokens/service-tokens.md | 2 + docs/docs/guide/ai/ai-chat.md | 2 +- runtime/ai/memory.go | 7 +- runtime/ai/memory_test.go | 5 ++ runtime/feature_flags.go | 15 +++- runtime/feature_flags_test.go | 35 ++++++++++ runtime/server/ai_memory.go | 2 +- runtime/server/ai_memory_test.go | 20 ++++++ .../chat/memory/MemoryUpdateChip.svelte | 32 +++++---- 13 files changed, 184 insertions(+), 20 deletions(-) diff --git a/admin/server/deployment_test.go b/admin/server/deployment_test.go index 6b4053fc7809..e86ad399dbb2 100644 --- a/admin/server/deployment_test.go +++ b/admin/server/deployment_test.go @@ -120,6 +120,7 @@ func TestDeploymentJWTs(t *testing.T) { require.NotEqual(t, u1.ID, c.Subject) require.Equal(t, "embed@example.com", c.Attrs["email"]) require.Equal(t, "foo", c.Attrs["tenant_id"]) + require.Equal(t, true, c.Attrs["external_user"]) require.NotContains(t, c.Attrs, "admin") // Subject hash must be deterministic across calls with the same external id and project. @@ -131,6 +132,73 @@ func TestDeploymentJWTs(t *testing.T) { require.NoError(t, err) require.Equal(t, c.Subject, decodeJWT(t, resp2.AccessToken).Subject) }) + + t.Run("GetIFrame for service token without external user ID", func(t *testing.T) { + svc, err := u1Client.CreateService(t.Context(), &adminv1.CreateServiceRequest{ + Org: "jwt-test", + Name: "embed-service", + Project: "jwt-project", + ProjectRoleName: "admin", + }) + require.NoError(t, err) + tok, err := u1Client.IssueServiceAuthToken(t.Context(), &adminv1.IssueServiceAuthTokenRequest{ + Org: "jwt-test", + ServiceName: "embed-service", + }) + require.NoError(t, err) + + resp, err := adm.NewClient(t, tok.Token).GetIFrame(t.Context(), &adminv1.GetIFrameRequest{ + Org: "jwt-test", + Project: "jwt-project", + Resource: "foo", + }) + require.NoError(t, err) + + // Every viewer of the embed shares the service ID as subject, and nothing marks the token as identifying one viewer. + c := decodeJWT(t, resp.AccessToken) + require.Equal(t, svc.Service.Id, c.Subject) + require.Equal(t, true, c.Attrs["embed"]) + require.NotContains(t, c.Attrs, "external_user") + require.NotContains(t, c.Attrs, "public_url") + requireHasPerms(t, c.Instances[depl.RuntimeInstanceID], runtime.UseAI) + + // With an external user ID, each viewer gets their own subject and the token says so. + resp, err = adm.NewClient(t, tok.Token).GetIFrame(t.Context(), &adminv1.GetIFrameRequest{ + Org: "jwt-test", + Project: "jwt-project", + Resource: "foo", + ExternalUserId: "external-customer-42", + }) + require.NoError(t, err) + c = decodeJWT(t, resp.AccessToken) + require.True(t, strings.HasPrefix(c.Subject, "ext_")) + require.Equal(t, true, c.Attrs["embed"]) + require.Equal(t, true, c.Attrs["external_user"]) + }) + + t.Run("GetProject for public URL", func(t *testing.T) { + tkn, err := u1Client.IssueMagicAuthToken(t.Context(), &adminv1.IssueMagicAuthTokenRequest{ + Org: "jwt-test", + Project: "jwt-project", + Resources: []*adminv1.ResourceName{{Type: runtime.ResourceKindExplore, Name: "foo"}}, + }) + require.NoError(t, err) + + resp, err := adm.NewClient(t, tkn.Token).GetProject(t.Context(), &adminv1.GetProjectRequest{ + Org: "jwt-test", + Project: "jwt-project", + }) + require.NoError(t, err) + + // Everyone with the link shares the magic token ID as subject and the creator's attributes, so the token is marked as a public URL. + c := decodeJWT(t, resp.Jwt) + require.NotEmpty(t, c.Subject) + require.NotEqual(t, u1.ID, c.Subject) + require.Equal(t, u1.Email, c.Attrs["email"]) + require.Equal(t, true, c.Attrs["public_url"]) + require.NotContains(t, c.Attrs, "embed") + requireHasPerms(t, c.Instances[depl.RuntimeInstanceID], runtime.UseAI) + }) } // jwtPayload mirrors the subset of the runtime JWT payload that the assertions touch. diff --git a/admin/server/runtime_jwt.go b/admin/server/runtime_jwt.go index 0c827262adab..da38a20e9da0 100644 --- a/admin/server/runtime_jwt.go +++ b/admin/server/runtime_jwt.go @@ -2,6 +2,7 @@ package server import ( "context" + "maps" "time" "github.com/rilldata/rill/admin/database" @@ -116,7 +117,11 @@ func (s *Server) issueRuntimeToken(ctx context.Context, opts *issueRuntimeTokenO if !ok { return "", status.Errorf(codes.Internal, "unexpected type %T for magic auth token model", claims.AuthTokenModel()) } - attr = mdl.Attributes + // A public URL token carries the creator's attributes, so the runtime cannot tell it from the creator's own token by subject or attributes alone. + // Mark it with public_url: everyone with the link shares this subject, so per-user features such as AI memory must stay off (see runtime.ResolveFeatureFlags). + attr = make(map[string]any, len(mdl.Attributes)+1) + maps.Copy(attr, mdl.Attributes) + attr["public_url"] = true magicRules, err := securityRulesFromMagicAuthToken(mdl) if err != nil { return "", err @@ -152,8 +157,13 @@ func (s *Server) issueRuntimeToken(ctx context.Context, opts *issueRuntimeTokenO } // Apply externalUserID after principal resolution so it overrides whatever subject was set. + // The external_user attribute tells the runtime that the subject identifies one viewer, so per-user features such as AI memory stay on in an embed (see runtime.ResolveFeatureFlags). if opts.externalUserID != "" { subject = subjectForExternalUser(opts.externalUserID, opts.project.ID) + if attr == nil { + attr = make(map[string]any, 1) + } + attr["external_user"] = true } // overrideResources replaces any principal-derived resource rules with a fresh allow-list. diff --git a/docs/docs/developers/build/ai-configuration.md b/docs/docs/developers/build/ai-configuration.md index a609e1a3b306..d47e6ff33c04 100644 --- a/docs/docs/developers/build/ai-configuration.md +++ b/docs/docs/developers/build/ai-configuration.md @@ -194,7 +194,7 @@ In addition to the project-wide `ai_instructions` you write, [AI Chat](/guide/ai If several users keep teaching the AI the same thing, add it to `ai_instructions` so everyone benefits. Memory never stores data values, secrets, or anything `ai_instructions` already say. -Memory is on by default for logged-in users. To turn it off for a project, disable the `chat_memory` feature flag in `rill.yaml`: +Memory is on by default for logged-in users and for embed viewers identified with `external_user_id`. It is never on for public URL viewers or for embeds without `external_user_id`, whatever the flag says, because every viewer would share one memory. To turn it off for a project, disable the `chat_memory` feature flag in `rill.yaml`: ```yaml features: diff --git a/docs/docs/developers/embed/iframe.md b/docs/docs/developers/embed/iframe.md index d6bfaf0de794..c92f632c29c2 100644 --- a/docs/docs/developers/embed/iframe.md +++ b/docs/docs/developers/embed/iframe.md @@ -328,6 +328,8 @@ Only one of `user_email` or `attributes` can be provided for a given iframe. The - `attributes`: Passes the provided attributes through directly. Make sure to include all attributes referenced in your security policies (e.g. `email`, `domain`, `admin`, or custom attributes like `tenant_id`). Does not enable per-user state on its own; combine with `external_user_id` to enable per-user state. - `external_user_id`: Any stable identifier for the end user. This is usually the user's ID in your own database. Setting it enables per-user state such as AI chat history. +[AI memory](/guide/ai/ai-chat#memory) is per-user state too: it is only available in an embed with `external_user_id`. Without it, every viewer of the embed would share one memory, so memory is off. + The same attributes decide what an embedded canvas shows. Canvas rows, tabs and components can have [`if` conditions](/developers/build/dashboards/canvas#show-content-to-some-viewers-only) on them, such as `'{{ eq .user.plan "premium" }}'` to show premium content only to tenants on that plan. Every embed also carries the attribute `embed: true`, so a condition like `'{{ not .user.embed }}'` keeps content out of embedded dashboards. ## Embedding the project vs embedding an individual dashboard diff --git a/docs/docs/guide/administration/access-tokens/service-tokens.md b/docs/docs/guide/administration/access-tokens/service-tokens.md index 854d492e3ef5..74baa3f275e5 100644 --- a/docs/docs/guide/administration/access-tokens/service-tokens.md +++ b/docs/docs/guide/administration/access-tokens/service-tokens.md @@ -203,3 +203,5 @@ Only one of `user_email` or `attributes` can be provided for a given token. The - `user_email`: Looks up the user in Rill Cloud by email and populates their standard attributes. If no matching user is found, it generates limited attributes with only the fields `email`, `domain` and `admin` (set to `false`). Does not enable per-user state on its own; combine with `external_user_id` to enable per-user state. - `attributes`: Passes the provided attributes through directly. Make sure to include all attributes referenced in your security policies (e.g. `email`, `domain`, `admin`, or custom attributes like `tenant_id`). Does not enable per-user state on its own; combine with `external_user_id` to enable per-user state. - `external_user_id`: Any stable identifier for an external end user. This is usually the user's ID in your own database. Setting it enables per-user state such as AI chat history. Can be combined with `user_email` or `attributes`. + +[AI memory](/guide/ai/ai-chat#memory) is per-user state too: it is only available with `external_user_id`. Without it, everyone using the token would share one memory, so memory is off. diff --git a/docs/docs/guide/ai/ai-chat.md b/docs/docs/guide/ai/ai-chat.md index c9730cf41294..588178e805f6 100644 --- a/docs/docs/guide/ai/ai-chat.md +++ b/docs/docs/guide/ai/ai-chat.md @@ -172,7 +172,7 @@ Open **Manage memory** from the chat sidebar (or **Settings > AI memory** in Ril Turn **Use memory** off to keep your memories but stop the AI from using or updating them until you turn it back on. -Memory is available in Rill Cloud for logged-in users and in Rill Developer, where it is stored locally and never committed to git. It is not used by AI reports, shared conversations viewed by other users, or external AI clients connected through MCP. Project administrators can turn memory off for a project with the `chat_memory` feature flag; see [AI Configuration](/developers/build/ai-configuration#user-memory). +Memory is per user. It is available in Rill Cloud for logged-in users and in Rill Developer, where it is stored locally and never committed to git. In [embedded dashboards](/developers/embed/iframe) it is only available when the embed identifies the viewer with `external_user_id`; otherwise every viewer would share one memory, so it is off. It is never available through [public URLs](/guide/dashboards/public-urls), and it is not used by AI reports, shared conversations viewed by other users, or external AI clients connected through MCP. Project administrators can turn memory off for a project with the `chat_memory` feature flag; see [AI Configuration](/developers/build/ai-configuration#user-memory). ## Use in Your Favorite AI Client diff --git a/runtime/ai/memory.go b/runtime/ai/memory.go index c5546433bd1d..579e65c90125 100644 --- a/runtime/ai/memory.go +++ b/runtime/ai/memory.go @@ -58,12 +58,15 @@ var ( ) // MemoryAvailable reports whether memory is available to the given claims in an instance. -// It is false for users without the AI permission, for anonymous users, and when the chat_memory feature flag is off. +// Memory is keyed on the user ID, so it is only available when that ID identifies one viewer: +// it is false for users without the AI permission, for anonymous users, and when the chat_memory feature flag is off. +// The flag is also resolved to false for tokens whose subject is shared by several viewers (see runtime.ResolveFeatureFlags): +// public URLs, and embeds without an external_user_id. func MemoryAvailable(ctx context.Context, rt *runtime.Runtime, instanceID string, claims *runtime.SecurityClaims) (bool, error) { if claims == nil || !claims.Can(runtime.UseAI) { return false, nil } - // Anonymous users on Rill Cloud (public projects, magic tokens) have no stable identity to key memory on. + // Anonymous users on Rill Cloud (public projects) have no identity to key memory on. // In Rill Developer, auth is disabled so SkipChecks is true and the empty user ID acts as the single local user. if claims.UserID == "" && !claims.SkipChecks { return false, nil diff --git a/runtime/ai/memory_test.go b/runtime/ai/memory_test.go index f76bcf6aaf8b..eccb19d82e8e 100644 --- a/runtime/ai/memory_test.go +++ b/runtime/ai/memory_test.go @@ -213,6 +213,11 @@ func TestMemoryGates(t *testing.T) { {"no ai permission", "rill/0.1", &runtime.SecurityClaims{UserID: uuid.NewString()}, false}, {"report", "rill/report", &runtime.SecurityClaims{UserID: uuid.NewString(), SkipChecks: true}, false}, {"mcp client", "claude-desktop/1.0", &runtime.SecurityClaims{UserID: uuid.NewString(), SkipChecks: true}, false}, + // An embed without an external_user_id puts the issuer's identity (here a service ID) on every viewer's token; with one, each viewer has their own subject. + {"service token embed", "rill/0.1", &runtime.SecurityClaims{UserID: uuid.NewString(), UserAttributes: map[string]any{"name": "my-service", "admin": true, "embed": true}, Permissions: []runtime.Permission{runtime.UseAI}}, false}, + {"external user embed", "rill/0.1", &runtime.SecurityClaims{UserID: "ext_" + uuid.NewString(), UserAttributes: map[string]any{"embed": true, "external_user": true}, Permissions: []runtime.Permission{runtime.UseAI}}, true}, + // A public URL token carries the magic auth token ID as subject and the creator's attributes, shared by everyone with the link. + {"public URL", "rill/0.1", &runtime.SecurityClaims{UserID: uuid.NewString(), UserAttributes: map[string]any{"email": "creator@example.com", "domain": "example.com", "admin": true, "public_url": true}, Permissions: []runtime.Permission{runtime.UseAI}}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/runtime/feature_flags.go b/runtime/feature_flags.go index bf3ddcf99ce3..0061884610c5 100644 --- a/runtime/feature_flags.go +++ b/runtime/feature_flags.go @@ -59,7 +59,8 @@ var defaultFeatureFlags = map[string]string{ "developer_chat": "true", // Controls whether charts are rendered in AI chats "chat_charts": "true", - // Controls whether the AI chat learns and uses per-user memories (preferences and clarifications remembered across conversations) + // Controls whether the AI chat learns and uses per-user memories (preferences and clarifications remembered across conversations). + // Always resolved to false for public URLs and for embeds without an external_user_id, whose subject is shared by every viewer. "chat_memory": "true", // Controls whether to show/hide deploy related actions. "deploy": "true", @@ -150,5 +151,17 @@ func ResolveFeatureFlags(inst *drivers.Instance, userAttributes map[string]any, featureFlags[chatMemoryKey] = false } + // Memory is keyed on the token's subject, so it is off whenever the subject is shared by several viewers, whatever the project configures: + // an embed without an external_user_id puts the issuer's identity (a service or user ID) on every viewer's token, + // and a public URL puts the magic token ID on everyone with the link. An embed with an external_user_id has a per-viewer subject and keeps memory. + // The admin server sets these attributes when it issues the token. + // The runtime enforces this flag (see ai.MemoryAvailable), and the UI hides the memory controls when it is off. + embed, _ := userAttributes["embed"].(bool) + externalUser, _ := userAttributes["external_user"].(bool) + publicURL, _ := userAttributes["public_url"].(bool) + if (embed && !externalUser) || publicURL { + featureFlags[chatMemoryKey] = false + } + return featureFlags, nil } diff --git a/runtime/feature_flags_test.go b/runtime/feature_flags_test.go index 7766ace9c3c3..d7becfdcb4a2 100644 --- a/runtime/feature_flags_test.go +++ b/runtime/feature_flags_test.go @@ -194,3 +194,38 @@ func Test_ResolveFeatureFlags_CloudEditingDisabledAnnotation(t *testing.T) { }) } } + +// Test_ResolveFeatureFlags_ChatMemoryPrincipals checks that chat_memory is off for tokens whose subject is shared by several viewers, whatever the project configures: +// public URLs and embeds without an external_user_id. +func Test_ResolveFeatureFlags_ChatMemoryPrincipals(t *testing.T) { + inst := &drivers.Instance{ + FeatureFlags: map[string]string{ + "chat": "true", + "dashboard_chat": "true", + "chat_memory": "true", + }, + } + + tests := []struct { + name string + userAttrs map[string]any + chatMemory bool + }{ + {"rill user", map[string]any{"email": "user@example.com"}, true}, + {"anonymous", nil, true}, + {"embed without external user", map[string]any{"embed": true, "tenant_id": "foo"}, false}, + {"embed with external user", map[string]any{"embed": true, "external_user": true, "tenant_id": "foo"}, true}, + {"ephemeral token with external user", map[string]any{"external_user": true}, true}, + {"public URL", map[string]any{"email": "creator@example.com", "public_url": true}, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + featureFlags, err := ResolveFeatureFlags(inst, test.userAttrs, true) + require.NoError(t, err) + require.Equal(t, test.chatMemory, featureFlags["chatMemory"]) + // Only memory is affected: chat itself stays as configured. + require.True(t, featureFlags["chat"]) + require.True(t, featureFlags["dashboardChat"]) + }) + } +} diff --git a/runtime/server/ai_memory.go b/runtime/server/ai_memory.go index 8e034e6cce0d..c9498296358b 100644 --- a/runtime/server/ai_memory.go +++ b/runtime/server/ai_memory.go @@ -188,7 +188,7 @@ func (s *Server) UpdateAIMemorySettings(ctx context.Context, req *runtimev1.Upda } // checkAIMemoryAccess checks that the caller may use AI and that memory is available to them. -// It returns a FailedPrecondition error when memory is not available: for anonymous users on Rill Cloud and when the chat_memory feature flag is off. +// It returns a FailedPrecondition error when memory is not available: for anonymous users on Rill Cloud, for public URL viewers and embeds without an external_user_id, and when the chat_memory feature flag is off. func (s *Server) checkAIMemoryAccess(ctx context.Context, instanceID string) (*runtime.SecurityClaims, error) { claims := auth.GetClaims(ctx, instanceID) if !claims.Can(runtime.UseAI) { diff --git a/runtime/server/ai_memory_test.go b/runtime/server/ai_memory_test.go index f5fb4f1e310d..2c47fba0d20f 100644 --- a/runtime/server/ai_memory_test.go +++ b/runtime/server/ai_memory_test.go @@ -26,6 +26,11 @@ func TestAIMemories(t *testing.T) { barCtx := auth.WithClaims(t.Context(), &runtime.SecurityClaims{UserID: "bar", Permissions: []runtime.Permission{runtime.UseAI}}) anonCtx := auth.WithClaims(t.Context(), &runtime.SecurityClaims{Permissions: []runtime.Permission{runtime.UseAI}}) noAICtx := auth.WithClaims(t.Context(), &runtime.SecurityClaims{UserID: "foo"}) + // An embed token without an external user ID has the service ID as subject (shared by every viewer); a public URL token has the magic token ID (shared by everyone with the link). + embedCtx := auth.WithClaims(t.Context(), &runtime.SecurityClaims{UserID: "service", UserAttributes: map[string]any{"embed": true}, Permissions: []runtime.Permission{runtime.UseAI}}) + publicURLCtx := auth.WithClaims(t.Context(), &runtime.SecurityClaims{UserID: "magic", UserAttributes: map[string]any{"email": "creator@example.com", "public_url": true}, Permissions: []runtime.Permission{runtime.UseAI}}) + // An embed token with an external user ID has a per-viewer subject. + externalUserCtx := auth.WithClaims(t.Context(), &runtime.SecurityClaims{UserID: "ext_abc", UserAttributes: map[string]any{"embed": true, "external_user": true}, Permissions: []runtime.Permission{runtime.UseAI}}) // Anonymous users on Rill Cloud get no memory _, err = srv.ListAIMemories(anonCtx, &runtimev1.ListAIMemoriesRequest{InstanceId: instanceID}) @@ -33,6 +38,21 @@ func TestAIMemories(t *testing.T) { _, err = srv.CreateAIMemory(anonCtx, &runtimev1.CreateAIMemoryRequest{InstanceId: instanceID, Content: "x"}) require.Equal(t, codes.FailedPrecondition, status.Code(err)) + // Embed and public URL viewers get no memory either: their subject is shared, so a memory store would be shared too + for _, ctx := range []context.Context{embedCtx, publicURLCtx} { + _, err = srv.ListAIMemories(ctx, &runtimev1.ListAIMemoriesRequest{InstanceId: instanceID}) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + _, err = srv.CreateAIMemory(ctx, &runtimev1.CreateAIMemoryRequest{InstanceId: instanceID, Content: "x"}) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + _, err = srv.GetAIMemorySettings(ctx, &runtimev1.GetAIMemorySettingsRequest{InstanceId: instanceID}) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + } + + // An embed viewer identified by an external user ID keeps their own memory + extList, err := srv.ListAIMemories(externalUserCtx, &runtimev1.ListAIMemoriesRequest{InstanceId: instanceID}) + require.NoError(t, err) + require.Empty(t, extList.Memories) + // Users without the AI permission are forbidden _, err = srv.ListAIMemories(noAICtx, &runtimev1.ListAIMemoriesRequest{InstanceId: instanceID}) require.Error(t, err) diff --git a/web-common/src/features/chat/memory/MemoryUpdateChip.svelte b/web-common/src/features/chat/memory/MemoryUpdateChip.svelte index fd100842d1fe..4fbaaaf4cfb2 100644 --- a/web-common/src/features/chat/memory/MemoryUpdateChip.svelte +++ b/web-common/src/features/chat/memory/MemoryUpdateChip.svelte @@ -1,9 +1,11 @@ -
- - {m.chat_memory_updated()} - {opLabel} - - {block.result.content} - -
- +{#if $chatMemory} +
+ + {m.chat_memory_updated()} + {opLabel} + + {block.result.content} + +
+ +
-
- + +{/if}