Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PMM-13315 Service account max length. #3221

Open
wants to merge 12 commits into
base: v3
Choose a base branch
from
17 changes: 14 additions & 3 deletions api-tests/server/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package server

import (
"bytes"
"crypto/md5" //nolint:gosec
"encoding/json"
"fmt"
"io"
Expand All @@ -35,6 +36,7 @@ import (
pmmapitests "github.com/percona/pmm/api-tests"
serverClient "github.com/percona/pmm/api/server/v1/json/client"
server "github.com/percona/pmm/api/server/v1/json/client/server_service"
stringsgen "github.com/percona/pmm/managed/utils/strings"
)

const (
Expand Down Expand Up @@ -362,7 +364,8 @@ func TestServiceAccountPermissions(t *testing.T) {
// service token role options: editor, admin
// basic auth format is skipped, endpoint /auth/serviceaccount (to get info about currently used token in request) requires Bearer authorization
// service_token:token format could be used in pmm-agent and pmm-admin (its transformed into Bearer authorization)
nodeName := "test-node"
nodeName, err := stringsgen.GenerateRandomString(256)
require.NoError(t, err)

viewerNodeName := fmt.Sprintf("%s-viewer", nodeName)
viewerAccountID := createServiceAccountWithRole(t, "Viewer", viewerNodeName)
Expand Down Expand Up @@ -520,7 +523,7 @@ func createServiceAccountWithRole(t *testing.T, role, nodeName string) int {

name := fmt.Sprintf("%s-%s", pmmServiceAccountName, nodeName)
data, err := json.Marshal(map[string]string{
"name": name,
"name": sanitizeSAName(name),
"role": role,
})
require.NoError(t, err)
Expand Down Expand Up @@ -582,7 +585,7 @@ func createServiceToken(t *testing.T, serviceAccountID int, nodeName string) (in

name := fmt.Sprintf("%s-%s", pmmServiceTokenName, nodeName)
data, err := json.Marshal(map[string]string{
"name": name,
"name": sanitizeSAName(name),
})
require.NoError(t, err)

Expand Down Expand Up @@ -617,3 +620,11 @@ func deleteServiceToken(t *testing.T, serviceAccountID, serviceTokenID int) {

require.Equalf(t, http.StatusOK, resp.StatusCode, "failed to delete service token, status code: %d, response: %s", resp.StatusCode, b)
}

func sanitizeSAName(name string) string {
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we can't keep and then import this method from github.com/percona/pmm/managed/utils/strings package?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is to avoid dependencies between managed and API tests. To keep them separate. We doing this in more places. It was discussed with Nurlan.

if len(name) <= 180 {
return name
}

return fmt.Sprintf("%s%x", name[:148], md5.Sum([]byte(name[148:]))) //nolint:gosec
}
29 changes: 21 additions & 8 deletions managed/services/grafana/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package grafana
import (
"bytes"
"context"
"crypto/md5" //nolint:gosec
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -350,7 +351,7 @@ type serviceAccountSearch struct {

func (c *Client) getServiceAccountIDFromName(ctx context.Context, nodeName string, authHeaders http.Header) (int, error) {
var res serviceAccountSearch
serviceAccountName := fmt.Sprintf("%s-%s", pmmServiceAccountName, nodeName)
serviceAccountName := sanitizeSAName(fmt.Sprintf("%s-%s", pmmServiceAccountName, nodeName))
if err := c.do(ctx, http.MethodGet, "/api/serviceaccounts/search", fmt.Sprintf("query=%s", serviceAccountName), authHeaders, nil, &res); err != nil {
return 0, err
}
Expand Down Expand Up @@ -383,7 +384,7 @@ func (c *Client) getNotPMMAgentTokenCountForServiceAccount(ctx context.Context,
count := 0
for _, token := range tokens {
serviceTokenName := fmt.Sprintf("%s-%s", pmmServiceTokenName, nodeName)
if !strings.HasPrefix(token.Name, serviceTokenName) {
if !strings.HasPrefix(token.Name, sanitizeSAName(serviceTokenName)) {
count++
}
}
Expand Down Expand Up @@ -672,15 +673,27 @@ type serviceToken struct {
Role string `json:"role"`
}

// Max length of service account name is 190 chars (limit in Grafana Postgres DB).
// However, prefix added by grafana is counted too. Prefix is sa-{orgID}-.
// Bare minimum is 5 chars reserved (orgID is <10, like sa-1-) and could be more depends
// on orgID number. Let's reserve 10 chars. It will cover almost one million orgIDs.
// Sanitizing, ensure its length by hashing postfix when length is exceeded.
// MD5 is used because it has fixed length 32 chars.
func sanitizeSAName(name string) string {
Copy link
Member

Choose a reason for hiding this comment

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

Isn't that a utility method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if it belongs to utils when its related only to service accounts, which are part of Grafana.

if len(name) <= 180 {
return name
}

return fmt.Sprintf("%s%x", name[:148], md5.Sum([]byte(name[148:]))) //nolint:gosec
}

func (c *Client) createServiceAccount(ctx context.Context, role role, nodeName string, reregister bool, authHeaders http.Header) (int, error) {
if role == none {
return 0, errors.New("you cannot create service account with empty role")
}

// Max length of service account name is 190 chars (default limit in Grafana). In reality it is 187.
// Some tests could fail if you pass a name longer than 187 chars.
serviceAccountName := fmt.Sprintf("%s-%s", pmmServiceAccountName, nodeName)
b, err := json.Marshal(serviceAccount{Name: serviceAccountName, Role: role.String(), Force: reregister})
b, err := json.Marshal(serviceAccount{Name: sanitizeSAName(serviceAccountName), Role: role.String(), Force: reregister})
if err != nil {
return 0, errors.WithStack(err)
}
Expand Down Expand Up @@ -714,7 +727,7 @@ func (c *Client) createServiceToken(ctx context.Context, serviceAccountID int, n
}
}

b, err := json.Marshal(serviceToken{Name: serviceTokenName, Role: admin.String()})
b, err := json.Marshal(serviceToken{Name: sanitizeSAName(serviceTokenName), Role: admin.String()})
if err != nil {
return 0, "", errors.WithStack(err)
}
Expand All @@ -737,7 +750,7 @@ func (c *Client) serviceTokenExists(ctx context.Context, serviceAccountID int, n

serviceTokenName := fmt.Sprintf("%s-%s", pmmServiceTokenName, nodeName)
for _, token := range tokens {
if !strings.HasPrefix(token.Name, serviceTokenName) {
if !strings.HasPrefix(token.Name, sanitizeSAName(serviceTokenName)) {
continue
}

Expand All @@ -755,7 +768,7 @@ func (c *Client) deletePMMAgentServiceToken(ctx context.Context, serviceAccountI

serviceTokenName := fmt.Sprintf("%s-%s", pmmServiceTokenName, nodeName)
for _, token := range tokens {
if strings.HasPrefix(token.Name, serviceTokenName) {
if strings.HasPrefix(token.Name, sanitizeSAName(serviceTokenName)) {
if err := c.do(ctx, "DELETE", fmt.Sprintf("/api/serviceaccounts/%d/tokens/%d", serviceAccountID, token.ID), "", authHeaders, nil, nil); err != nil {
return err
}
Expand Down
19 changes: 18 additions & 1 deletion managed/services/grafana/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

stringsgen "github.com/percona/pmm/managed/utils/strings"
)

func TestClient(t *testing.T) {
Expand Down Expand Up @@ -144,7 +146,9 @@ func TestClient(t *testing.T) {
})

t.Run(fmt.Sprintf("Service token auth %s", role.String()), func(t *testing.T) {
nodeName := fmt.Sprintf("test-node-%s", role)
name, err := stringsgen.GenerateRandomString(256)
require.NoError(t, err)
nodeName := fmt.Sprintf("%s-%s", name, role)
serviceAccountID, err := c.createServiceAccount(ctx, role, nodeName, true, authHeaders)
require.NoError(t, err)
defer func() {
Expand Down Expand Up @@ -234,3 +238,16 @@ func TestClient(t *testing.T) {
require.NoError(t, err)
})
}

func Test_sanitizeSAName(t *testing.T) {
// max possible length without hashing
len180, err := stringsgen.GenerateRandomString(180)
require.NoError(t, err)
require.Equal(t, len180, sanitizeSAName(len180))

// too long length - postfix hashed
len200, err := stringsgen.GenerateRandomString(200)
require.NoError(t, err)
len200sanitized := sanitizeSAName(len200)
require.Equal(t, fmt.Sprintf("%s%s", len200[:148], len200sanitized[148:]), len200sanitized)
}
32 changes: 32 additions & 0 deletions managed/utils/strings/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (C) 2023 Percona LLC
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

// Package stringsgen provides functions to generate random strings.
package stringsgen

import (
"crypto/rand"
"encoding/base64"
)

// GenerateRandomString returns random string with given length.
func GenerateRandomString(length int) (string, error) {
buffer := make([]byte, length)
_, err := rand.Read(buffer)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(buffer)[:length], nil
}
Loading