Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
}
Comment thread
fabio-silva marked this conversation as resolved.

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
180 changes: 180 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 {
Comment thread
fabio-silva marked this conversation as resolved.
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 !hasAuthorizationHeader(authHeaders) {
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(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
Comment thread
fabio-silva marked this conversation as resolved.
Outdated
}
return emptyUser, err
}

Expand Down Expand Up @@ -302,6 +342,146 @@ func (c *Client) convertRole(role string) role {
}
}

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) {
settings, err := c.getFrontendSettings(ctx)
if err != nil {
return false, none
}

if !settings.AnonymousEnabled {
return false, none
}

parsedRole := c.convertRole(c.resolveAnonymousRole(settings))
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 hasAuthorizationHeader(authHeaders http.Header) bool {
return authHeaders.Get("Authorization") != ""
}

func (c *Client) resolveAnonymousRole(settings frontendSettingsFull) string {
Comment thread
fabio-silva marked this conversation as resolved.
Outdated
role := settings.User.OrgRole
if role == "" {
role = settings.AnonymousOrgRole
}

switch role {
case viewer.String():
return role
case editor.String(), admin.String(), grafanaAdmin.String():
// Grafana is deprecating anonymous roles other than Viewer.
// Keep PMM behavior aligned and never grant elevated anonymous role.
return viewer.String()
default:
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(err, &cErr) || cErr.Code != http.StatusUnauthorized || hasAuthorizationHeader(authHeaders) {
return CurrentUser{}, err
}

settings, settingsErr := c.getFrontendSettings(ctx)
if settingsErr != nil || !settings.AnonymousEnabled {
return CurrentUser{}, err
}
role := c.resolveAnonymousRole(settings)
if role == none.String() {
return CurrentUser{}, err
}

orgID := settings.User.OrgID
if orgID == 0 {
orgID = 1
}

return CurrentUser{
ID: 0,
Email: "",
Name: "Anonymous",
Login: "anonymous",
OrgID: orgID,
IsAnonymous: true,
IsGrafanaAdmin: false,
}, 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(err, &cErr) || cErr.Code != http.StatusUnauthorized || hasAuthorizationHeader(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() {
return nil, err
}

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