From a39e388b3089929d9c81cc89bc216cbadd9bf5d1 Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Tue, 24 Mar 2026 12:02:41 +0000 Subject: [PATCH 1/2] feat: allow anonymous user with role defined --- managed/cmd/pmm-managed/main.go | 13 +- managed/services/grafana/auth_server.go | 9 +- managed/services/grafana/client.go | 195 ++++++++++++++++++ managed/services/user/current_http.go | 68 ++++++ ui/apps/pmm/src/App.tsx | 1 + ui/apps/pmm/src/api/user.ts | 4 +- .../navigation/navigation.provider.tsx | 14 +- .../contexts/settings/settings.provider.tsx | 5 +- .../pmm/src/contexts/user/user.provider.tsx | 41 ++-- ui/apps/pmm/src/contexts/user/user.utils.ts | 1 + ui/apps/pmm/src/hooks/api/useHA.ts | 4 +- ui/apps/pmm/src/types/user.types.ts | 2 + ui/apps/pmm/src/utils/testStubs.ts | 1 + 13 files changed, 324 insertions(+), 34 deletions(-) create mode 100644 managed/services/user/current_http.go diff --git a/managed/cmd/pmm-managed/main.go b/managed/cmd/pmm-managed/main.go index 789875f20bb..83173958497 100644 --- a/managed/cmd/pmm-managed/main.go +++ b/managed/cmd/pmm-managed/main.go @@ -52,6 +52,7 @@ import ( "google.golang.org/grpc/backoff" channelz "google.golang.org/grpc/channelz/service" "google.golang.org/grpc/credentials/insecure" + // Installing the gzip encoding registers it as an available compressor. // GRPC will automatically negotiate and use gzip if the client supports it. _ "google.golang.org/grpc/encoding/gzip" @@ -343,8 +344,9 @@ func runGRPCServer(ctx context.Context, deps *gRPCServerDeps) { } type http1ServerDeps struct { - logs *server.Logs - authServer *grafana.AuthServer + logs *server.Logs + authServer *grafana.AuthServer + currentUserHandler http.Handler } // runHTTP1Server runs grpc-gateway and other HTTP 1.1 APIs (like auth_request and logs.zip) @@ -425,6 +427,8 @@ func runHTTP1Server(ctx context.Context, deps *http1ServerDeps) { mux := http.NewServeMux() addLogsHandler(mux, deps.logs) mux.Handle("/auth_request", deps.authServer) + mux.Handle("/v1/users/current/orgs", deps.currentUserHandler) + mux.Handle("/v1/users/current", deps.currentUserHandler) mux.Handle("/", proxyMux) server := &http.Server{ //nolint:gosec @@ -1192,8 +1196,9 @@ func main() { //nolint:maintidx,cyclop wg.Go(func() { runHTTP1Server(ctx, &http1ServerDeps{ - logs: logs, - authServer: authServer, + logs: logs, + authServer: authServer, + currentUserHandler: user.NewCurrentHTTPHandler(grafanaClient), }) }) diff --git a/managed/services/grafana/auth_server.go b/managed/services/grafana/auth_server.go index 44642dd9ad7..7b843eef1f6 100644 --- a/managed/services/grafana/auth_server.go +++ b/managed/services/grafana/auth_server.go @@ -86,6 +86,8 @@ var rules = map[string]role{ "/v1/platform:": admin, "/v1/platform/": viewer, "/v1/users": viewer, + "/v1/users/current": none, + "/v1/users/current/orgs": none, // special case - used on Grafana login page before user can be authenticated. // Used for PMM Demo user only. "/v1/users/demo/credentials": none, @@ -291,7 +293,9 @@ func (s *AuthServer) returnError(rw http.ResponseWriter, msg map[string]any, l * // maybeAddLBACFilters adds extra filters to requests proxied through VMProxy. // In case the request is not proxied through VMProxy, this is a no-op. func (s *AuthServer) maybeAddLBACFilters(ctx context.Context, rw http.ResponseWriter, req *http.Request, userID int, l *logrus.Entry) error { + l.Debugf("maybeAddLBACFilters: userID=%d", userID) if !s.shallAddLBACFilters(req) { + l.Debugf("Skipping LBAC filters for non-proxied request.") return nil } @@ -310,7 +314,10 @@ func (s *AuthServer) maybeAddLBACFilters(ctx context.Context, rw http.ResponseWr } if userID <= 0 { - return ErrInvalidUserID + // Anonymous users don't have a numeric user ID and cannot have LBAC roles. + // Skip adding filters and allow the request to proceed. + l.Debugf("Skipping LBAC filters for anonymous user.") + return nil } filters, err := s.getLBACFilters(ctx, userID) diff --git a/managed/services/grafana/client.go b/managed/services/grafana/client.go index d0c50504aa8..21af0c95b84 100644 --- a/managed/services/grafana/client.go +++ b/managed/services/grafana/client.go @@ -168,6 +168,30 @@ type authUser struct { userID int } +// CurrentUser represents Grafana user payload. +type CurrentUser struct { + ID int `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Login string `json:"login"` + CreatedAt string `json:"createdAt"` + OrgID int `json:"orgId"` + IsAnonymous bool `json:"isAnonymous"` + IsDisabled bool `json:"isDisabled"` + IsExternal bool `json:"isExternal"` + IsExtarnallySynced bool `json:"isExtarnallySynced"` + IsGrafanaAdmin bool `json:"isGrafanaAdmin"` + IsGrafanaAdminExternallySynced bool `json:"isGrafanaAdminExternallySynced"` + Theme string `json:"theme"` +} + +// CurrentUserOrg represents Grafana org payload. +type CurrentUserOrg struct { + OrgID int `json:"orgId"` + Name string `json:"name"` + Role string `json:"role"` +} + // role defines Grafana user role within the organization // (except grafanaAdmin that is a global flag that is more important than any other role). // Role with more permissions has larger numerical value: viewer < editor, admin < grafanaAdmin, etc. @@ -242,10 +266,26 @@ func (c *Client) getAuthUser(ctx context.Context, authHeaders http.Header, l *lo }, nil } + var ( + anonymousEnabled bool + anonymousRole role + ) + if authHeaders.Get("Authorization") == "" && authHeaders.Get("Cookie") == "" { + anonymousEnabled, anonymousRole = c.getAnonymousRoleFromSettings(ctx, l) + } + // https://grafana.com/docs/http_api/user/#actual-user - works only with Basic Auth var m map[string]interface{} err := c.do(ctx, http.MethodGet, "/api/user", "", authHeaders, nil, &m) if err != nil { + var cErr *clientError + if anonymousEnabled && errors.As(errors.Cause(err), &cErr) && cErr.Code == http.StatusUnauthorized { + l.Debugf("Grafana returned 401 for /api/user with no credentials; using anonymous role %q.", anonymousRole.String()) + return authUser{ + role: anonymousRole, + userID: 0, + }, nil + } return emptyUser, err } @@ -302,6 +342,161 @@ func (c *Client) convertRole(role string) role { } } +type frontendUserSettings struct { + OrgRole string `json:"orgRole"` +} + +type frontendSettings struct { + AnonymousEnabled bool `json:"anonymousEnabled"` + AnonymousOrgRole string `json:"anonymousOrgRole"` + User frontendUserSettings `json:"user"` +} + +type frontendUserSettingsFull struct { + OrgRole string `json:"orgRole"` + OrgID int `json:"orgId"` + OrgName string `json:"orgName"` +} + +type frontendSettingsFull struct { + AnonymousEnabled bool `json:"anonymousEnabled"` + AnonymousOrgRole string `json:"anonymousOrgRole"` + User frontendUserSettingsFull `json:"user"` +} + +func (c *Client) getAnonymousRoleFromSettings(ctx context.Context, l *logrus.Entry) (bool, role) { + var settings frontendSettings + if err := c.do(ctx, http.MethodGet, "/api/frontend/settings", "", nil, nil, &settings); err != nil { + return false, none + } + + if !settings.AnonymousEnabled { + return false, none + } + + // orgRole from frontend user state is the effective role for anonymous access. + parsedRole := c.convertRole(settings.User.OrgRole) + if parsedRole == none { + parsedRole = c.convertRole(settings.AnonymousOrgRole) + } + l.Debugf("Grafana anonymous mode is enabled with role %q.", parsedRole.String()) + return true, parsedRole +} + +func (c *Client) getFrontendSettings(ctx context.Context) (frontendSettingsFull, error) { + var settings frontendSettingsFull + if err := c.do(ctx, http.MethodGet, "/api/frontend/settings", "", nil, nil, &settings); err != nil { + return frontendSettingsFull{}, err + } + + return settings, nil +} + +func hasAuthHeaders(authHeaders http.Header) bool { + return authHeaders.Get("Authorization") != "" || authHeaders.Get("Cookie") != "" +} + +func (c *Client) resolveAnonymousRole(settings frontendSettingsFull) string { + if settings.User.OrgRole != "" { + return settings.User.OrgRole + } + if settings.AnonymousOrgRole != "" { + return settings.AnonymousOrgRole + } + return none.String() +} + +// GetCurrentUser returns current Grafana user. +// If anonymous mode is enabled and no auth headers are present, it returns +// a synthetic anonymous user when /api/user responds with 401. +func (c *Client) GetCurrentUser(ctx context.Context, authHeaders http.Header) (CurrentUser, error) { + var user CurrentUser + err := c.do(ctx, http.MethodGet, "/api/user", "", authHeaders, nil, &user) + if err == nil { + return user, nil + } + + var cErr *clientError + if !errors.As(errors.Cause(err), &cErr) || cErr.Code != http.StatusUnauthorized || hasAuthHeaders(authHeaders) { + return CurrentUser{}, err + } + + settings, settingsErr := c.getFrontendSettings(ctx) + if settingsErr != nil || !settings.AnonymousEnabled { + return CurrentUser{}, err + } + if c.resolveAnonymousRole(settings) == none.String() { + // Anonymous mode is enabled but role is not configured. + // Return empty payload instead of Unauthorized. + return CurrentUser{}, nil + } + + orgID := settings.User.OrgID + if orgID == 0 { + orgID = 1 + } + + return CurrentUser{ + ID: 0, + Email: "", + Name: "Anonymous", + Login: "anonymous", + CreatedAt: "", + OrgID: orgID, + IsAnonymous: true, + IsDisabled: false, + IsExternal: false, + IsExtarnallySynced: false, + IsGrafanaAdmin: false, + IsGrafanaAdminExternallySynced: false, + Theme: "", + }, nil +} + +// GetCurrentUserOrgs returns current Grafana user organizations. +// If anonymous mode is enabled and no auth headers are present, it returns +// a synthetic org list when /api/user/orgs responds with 401. +func (c *Client) GetCurrentUserOrgs(ctx context.Context, authHeaders http.Header) ([]CurrentUserOrg, error) { + var orgs []CurrentUserOrg + err := c.do(ctx, http.MethodGet, "/api/user/orgs", "", authHeaders, nil, &orgs) + if err == nil { + return orgs, nil + } + + var cErr *clientError + if !errors.As(errors.Cause(err), &cErr) || cErr.Code != http.StatusUnauthorized || hasAuthHeaders(authHeaders) { + return nil, err + } + + settings, settingsErr := c.getFrontendSettings(ctx) + if settingsErr != nil || !settings.AnonymousEnabled { + return nil, err + } + role := c.resolveAnonymousRole(settings) + if role == none.String() { + // Anonymous mode is enabled but role is not configured. + // Return empty payload instead of Unauthorized. + return []CurrentUserOrg{}, nil + } + + orgID := settings.User.OrgID + if orgID == 0 { + orgID = 1 + } + orgName := settings.User.OrgName + if orgName == "" { + orgName = "Main Org." + } + + return []CurrentUserOrg{ + { + OrgID: orgID, + Name: orgName, + Role: role, + }, + }, nil +} + func (c *Client) getRoleForServiceToken(ctx context.Context, token string) (role, error) { header := http.Header{} header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) diff --git a/managed/services/user/current_http.go b/managed/services/user/current_http.go new file mode 100644 index 00000000000..51cb603e64b --- /dev/null +++ b/managed/services/user/current_http.go @@ -0,0 +1,68 @@ +package user + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/sirupsen/logrus" + + "github.com/percona/pmm/managed/services/grafana" +) + +// currentUserClient provides methods for current user endpoints. +type currentUserClient interface { + GetCurrentUser(ctx context.Context, authHeaders http.Header) (grafana.CurrentUser, error) + GetCurrentUserOrgs(ctx context.Context, authHeaders http.Header) ([]grafana.CurrentUserOrg, error) +} + +type currentHTTPHandler struct { + l *logrus.Entry + c currentUserClient +} + +// NewCurrentHTTPHandler creates handler for current user JSON endpoints. +func NewCurrentHTTPHandler(c currentUserClient) http.Handler { + return ¤tHTTPHandler{ + c: c, + l: logrus.WithField("component", "user/current-http"), + } +} + +func (h *currentHTTPHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + authHeaders := make(http.Header) + for _, k := range []string{"Authorization", "Cookie"} { + if v := req.Header.Get(k); v != "" { + authHeaders.Set(k, v) + } + } + + rw.Header().Set("Content-Type", "application/json") + + switch req.URL.Path { + case "/v1/users/current": + user, err := h.c.GetCurrentUser(req.Context(), authHeaders) + if err != nil { + h.l.WithError(err).Warn("failed to get current user") + rw.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(rw).Encode(map[string]string{"message": "Unauthorized"}) + return + } + if user == (grafana.CurrentUser{}) { + _ = json.NewEncoder(rw).Encode(map[string]any{}) + return + } + _ = json.NewEncoder(rw).Encode(user) + case "/v1/users/current/orgs": + orgs, err := h.c.GetCurrentUserOrgs(req.Context(), authHeaders) + if err != nil { + h.l.WithError(err).Warn("failed to get current user orgs") + rw.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(rw).Encode(map[string]string{"message": "Unauthorized"}) + return + } + _ = json.NewEncoder(rw).Encode(orgs) + default: + http.NotFound(rw, req) + } +} diff --git a/ui/apps/pmm/src/App.tsx b/ui/apps/pmm/src/App.tsx index e677b8064ec..390c9cbd5f4 100644 --- a/ui/apps/pmm/src/App.tsx +++ b/ui/apps/pmm/src/App.tsx @@ -15,6 +15,7 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, + retry: false, }, }, }); diff --git a/ui/apps/pmm/src/api/user.ts b/ui/apps/pmm/src/api/user.ts index 5ad32fe407a..6f4995a4321 100644 --- a/ui/apps/pmm/src/api/user.ts +++ b/ui/apps/pmm/src/api/user.ts @@ -9,12 +9,12 @@ import { import { api, grafanaApi } from './api'; export const getCurrentUser = async () => { - const res = await grafanaApi.get('/user'); + const res = await api.get('/users/current'); return res.data; }; export const getCurrentUserOrgs = async () => { - const res = await grafanaApi.get('/user/orgs'); + const res = await api.get('/users/current/orgs'); return res.data; }; diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx b/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx index aacf59750f9..014c5313f9c 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx +++ b/ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx @@ -16,7 +16,7 @@ import { import { useUser } from 'contexts/user'; import { useAdvisors } from 'hooks/api/useAdvisors'; import { useColorMode } from 'hooks/theme'; -import { ALL_SERVICE_TYPES, INTERVALS_MS } from 'lib/constants'; +import { INTERVALS_MS } from 'lib/constants'; import { useSettings } from 'contexts/settings'; import { NAV_BACKUPS, @@ -34,7 +34,6 @@ import { useHaInfo } from 'hooks/api/useHA'; export const NavigationProvider: FC = ({ children }) => { const { user } = useUser(); const { data: serviceTypes } = useServiceTypes({ - enabled: !!user, refetchInterval: INTERVALS_MS.SERVICE_TYPES, }); const { settings } = useSettings(); @@ -48,14 +47,14 @@ export const NavigationProvider: FC = ({ children }) => { 'pmm-ui.sidebar.expanded', true ); - const { data: haInfo } = useHaInfo(); - + const { data: haInfo } = useHaInfo({ + enabled: user?.isAnonymous === false + }); + const navTree = useMemo(() => { const items: NavItem[] = []; // provide all service types for anonymous mode - const currentServiceTypes = user - ? serviceTypes?.serviceTypes || [] - : ALL_SERVICE_TYPES; + const currentServiceTypes = serviceTypes?.serviceTypes || [] items.push(addHomePage(user?.preferences)); @@ -105,7 +104,6 @@ export const NavigationProvider: FC = ({ children }) => { } else { items.push(NAV_SIGN_IN); } - return items; }, [ status, diff --git a/ui/apps/pmm/src/contexts/settings/settings.provider.tsx b/ui/apps/pmm/src/contexts/settings/settings.provider.tsx index 991ff01bfe5..ab9906204a4 100644 --- a/ui/apps/pmm/src/contexts/settings/settings.provider.tsx +++ b/ui/apps/pmm/src/contexts/settings/settings.provider.tsx @@ -11,10 +11,11 @@ import { useUser } from 'contexts/user'; export const SettingsProvider: FC = ({ children }) => { const { user } = useUser(); const settings = useSettings({ - enabled: !!user && user.isPMMAdmin, + enabled: user?.isAnonymous === false && user?.isPMMAdmin, }); + const readonlySettings = useReadonlySettings({ - enabled: !!user && !user.isPMMAdmin, + enabled: user?.isAnonymous === false && !user?.isPMMAdmin, }); const frontendSettings = useFrontendSettings({ refetchOnMount: false, diff --git a/ui/apps/pmm/src/contexts/user/user.provider.tsx b/ui/apps/pmm/src/contexts/user/user.provider.tsx index f837dc4fc3b..0d219f5f330 100644 --- a/ui/apps/pmm/src/contexts/user/user.provider.tsx +++ b/ui/apps/pmm/src/contexts/user/user.provider.tsx @@ -8,39 +8,50 @@ import { } from 'hooks/api/useUser'; import { getPerconaUser, isAuthorized } from './user.utils'; import { useAuth } from 'contexts/auth'; +import { GetPreferenceResponse, UserInfo } from 'types/user.types'; export const UserProvider: FC = ({ children }) => { const auth = useAuth(); - const userQuery = useCurrentUser({ - enabled: auth.isLoggedIn, - }); + const userQuery = useCurrentUser(); const userInfoQuery = useUserInfo({ enabled: auth.isLoggedIn, }); - const orgsQuery = useCurrentUserOrgs({ - enabled: auth.isLoggedIn, - }); + const orgsQuery = useCurrentUserOrgs(); const preferencesQuery = useUserPreferences({ enabled: auth.isLoggedIn, }); const user = useMemo(() => { - if ( - !userQuery.data || - !orgsQuery.data || - !userInfoQuery.data || - !preferencesQuery.data - ) { + if (!userQuery.data || !orgsQuery.data) { + return; + } + + const anonymousInfo: UserInfo = { + userId: 0, + alertingTourCompleted: false, + productTourCompleted: false, + snoozedAt: null, + snoozeCount: 0, + snoozedPmmVersion: '', + }; + const anonymousPreferences: GetPreferenceResponse = {}; + + const info = auth.isLoggedIn ? userInfoQuery.data : anonymousInfo; + const preferences = auth.isLoggedIn + ? preferencesQuery.data + : anonymousPreferences; + + if (!info || !preferences) { return; } return getPerconaUser( userQuery.data, orgsQuery.data, - userInfoQuery.data, - preferencesQuery.data, + info, + preferences, isAuthorized(userQuery.error) ); - }, [userQuery, orgsQuery, userInfoQuery, preferencesQuery]); + }, [auth.isLoggedIn, userQuery, orgsQuery, userInfoQuery, preferencesQuery]); return ( { - const statusQuery = useHAStatus(); +export const useHaInfo = (options?: Partial>) => { + const statusQuery = useHAStatus(options); const nodesQuery = useHANodes({ enabled: statusQuery.data?.status === 'Enabled', refetchInterval: 15000, diff --git a/ui/apps/pmm/src/types/user.types.ts b/ui/apps/pmm/src/types/user.types.ts index 23e95a683dd..131800329b1 100644 --- a/ui/apps/pmm/src/types/user.types.ts +++ b/ui/apps/pmm/src/types/user.types.ts @@ -12,6 +12,7 @@ export interface User { id: number; name: string; login: string; + isAnonymous: boolean; orgId: number; orgRole: OrgRole | ''; isAuthorized: boolean; @@ -31,6 +32,7 @@ export interface GetUserResponse { login: string; createdAt: string; orgId: number; + isAnonymous: boolean; isDisabled: boolean; isExternal: boolean; isExtarnallySynced: boolean; diff --git a/ui/apps/pmm/src/utils/testStubs.ts b/ui/apps/pmm/src/utils/testStubs.ts index 63fe7333270..135c85de18b 100644 --- a/ui/apps/pmm/src/utils/testStubs.ts +++ b/ui/apps/pmm/src/utils/testStubs.ts @@ -17,6 +17,7 @@ export const TEST_USER_ADMIN: User = { id: 1, login: 'admin', name: 'admin', + isAnonymous: false, isAuthorized: true, isViewer: true, isEditor: true, From 53ca5b27e98f35af4827270a679f9419818d4425 Mon Sep 17 00:00:00 2001 From: Fabio Silva Date: Wed, 25 Mar 2026 10:54:54 +0000 Subject: [PATCH 2/2] chore: tests anonymous user functions --- managed/services/grafana/client_test.go | 198 ++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/managed/services/grafana/client_test.go b/managed/services/grafana/client_test.go index c157e9edf8d..4f493344d9e 100644 --- a/managed/services/grafana/client_test.go +++ b/managed/services/grafana/client_test.go @@ -19,6 +19,7 @@ import ( "context" "fmt" "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -31,6 +32,203 @@ import ( stringsgen "github.com/percona/pmm/utils/strings" ) +func TestGetAuthUserAnonymousFallback(t *testing.T) { + t.Parallel() + + l := logrus.WithField("test", t.Name()) + ctx := context.Background() + + t.Run("returns anonymous role from frontend settings", func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":true,"user":{"orgRole":"Editor"}}`) + case "/api/user": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Unauthorized"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + + u, err := c.getAuthUser(ctx, http.Header{}, l) + require.NoError(t, err) + assert.Equal(t, editor, u.role) + assert.Equal(t, 0, u.userID) + }) + + t.Run("no anonymous fallback when credentials are present", func(t *testing.T) { + t.Parallel() + + settingsCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + settingsCalled = true + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":true,"user":{"orgRole":"Admin"}}`) + case "/api/user": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Invalid username or password"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + headers := http.Header{} + headers.Set("Authorization", "Basic YmFkOnBhc3M=") + + u, err := c.getAuthUser(ctx, headers, l) + require.Error(t, err) + assert.Equal(t, none, u.role) + assert.False(t, settingsCalled) + }) + + t.Run("no fallback when anonymous is disabled", func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":false}`) + case "/api/user": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Unauthorized"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + + u, err := c.getAuthUser(ctx, http.Header{}, l) + require.Error(t, err) + assert.Equal(t, none, u.role) + }) +} + +func TestCurrentUserAnonymousFallback(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("GetCurrentUser uses anonymous fallback", func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":true,"anonymousOrgRole":"Viewer","user":{"orgId":1,"orgName":"Main Org."}}`) + case "/api/user": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Unauthorized"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + user, err := c.GetCurrentUser(ctx, http.Header{}) + require.NoError(t, err) + assert.Equal(t, "anonymous", user.Login) + assert.Equal(t, 1, user.OrgID) + assert.True(t, user.IsAnonymous) + }) + + t.Run("GetCurrentUserOrgs returns role from frontend settings", func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":true,"anonymousOrgRole":"Editor","user":{"orgId":1,"orgName":"Main Org."}}`) + case "/api/user": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Unauthorized"}`) + case "/api/user/orgs": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Unauthorized"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + orgs, err := c.GetCurrentUserOrgs(ctx, http.Header{}) + user, err := c.GetCurrentUser(ctx, http.Header{}) + require.NoError(t, err) + require.Len(t, orgs, 1) + assert.Equal(t, "Editor", orgs[0].Role) + assert.Equal(t, 1, orgs[0].OrgID) + assert.True(t, user.IsAnonymous) + + }) + + t.Run("GetCurrentUserOrgs returns empty when anonymous role missing", func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":true}`) + case "/api/user/orgs": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Unauthorized"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + orgs, err := c.GetCurrentUserOrgs(ctx, http.Header{}) + require.NoError(t, err) + assert.Empty(t, orgs) + }) + + t.Run("GetCurrentUser does not fallback when credentials are present", func(t *testing.T) { + t.Parallel() + + settingsCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/frontend/settings": + settingsCalled = true + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, `{"anonymousEnabled":true,"anonymousOrgRole":"Viewer"}`) + case "/api/user": + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Invalid username or password"}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := NewClient(strings.TrimPrefix(ts.URL, "http://")) + headers := http.Header{} + headers.Set("Authorization", "Basic YmFkOnBhc3M=") + _, err := c.GetCurrentUser(ctx, headers) + require.Error(t, err) + assert.False(t, settingsCalled) + }) +} + func TestClient(t *testing.T) { logrus.SetLevel(logrus.TraceLevel) l := logrus.WithField("test", t.Name())