Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions managed/cmd/pmm-managed/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
})
})

Expand Down
9 changes: 8 additions & 1 deletion managed/services/grafana/auth_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand Down
195 changes: 195 additions & 0 deletions managed/services/grafana/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,30 @@ type authUser struct {
userID int
}

// CurrentUser represents Grafana user payload.
type CurrentUser struct {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are all fields needed in these structs

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.
Expand Down Expand Up @@ -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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like errors.Cause() is not needed here

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
}

Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this method the result from API endpoint /api/frontend/settings is parsed into frontendSettings struct, but the same endpoint call in getFrontendSettings is parsed into frontendSettingsFull struct. What is the purpose?

if err := c.do(ctx, http.MethodGet, "/api/frontend/settings", "", nil, nil, &settings); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a separate method introduced getFrontendSettings for this

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. looks like no need to call Cause() because errors.As() iterates though errors chain
  2. seems the condition shall be
if (errors.As(err, &cErr) && cErr.Code != http.StatusUnauthorized) || hasAuthHeaders(authHeaders) {

because cErr.Code has value only in case errors.As() call was successful

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to return an Unauthorized error here and decrease the number of variations (empty user w/ error vs empty user w/o error, etc). Anything (wrong credentials, other errors, not properly configured anonymous mode) - returns error

}

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: "",
Comment on lines +447 to +452
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure these fields are required here

}, 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same with Cause() and condition as above

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same regarding error as in GetCurrentUser

}

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))
Expand Down
Loading
Loading