Skip to content

Commit

Permalink
Add apikey subcommand (elastic#3157)
Browse files Browse the repository at this point in the history
  • Loading branch information
axw authored and graphaelli committed Jan 14, 2020
1 parent 88be1f6 commit 02c3d15
Show file tree
Hide file tree
Showing 31 changed files with 1,175 additions and 194 deletions.
5 changes: 5 additions & 0 deletions _meta/beat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ apm-server:

#protocol: "http"

# Username and password are only needed for the apm-server apikey sub-command, and they are ignored otherwise
# See `apm-server apikey --help` for details.
#username: "elastic"
#password: "changeme"

# Optional HTTP Path.
#path: ""

Expand Down
5 changes: 5 additions & 0 deletions apm-server.docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ apm-server:

#protocol: "http"

# Username and password are only needed for the apm-server apikey sub-command, and they are ignored otherwise
# See `apm-server apikey --help` for details.
#username: "elastic"
#password: "changeme"

# Optional HTTP Path.
#path: ""

Expand Down
5 changes: 5 additions & 0 deletions apm-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ apm-server:

#protocol: "http"

# Username and password are only needed for the apm-server apikey sub-command, and they are ignored otherwise
# See `apm-server apikey --help` for details.
#username: "elastic"
#password: "changeme"

# Optional HTTP Path.
#path: ""

Expand Down
10 changes: 5 additions & 5 deletions beater/api/mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func NewMux(beaterConfig *config.Config, report publish.Reporter) (*http.ServeMu

func profileHandler(cfg *config.Config, builder *authorization.Builder, reporter publish.Reporter) (request.Handler, error) {
h := profile.Handler(systemMetadataDecoder(cfg, emptyDecoder), transform.Config{}, reporter)
authHandler := builder.ForPrivilege(authorization.PrivilegeEventWrite)
authHandler := builder.ForPrivilege(authorization.PrivilegeEventWrite.Action)
return middleware.Wrap(h, backendMiddleware(cfg, authHandler, profile.MonitoringMap)...)
}

Expand All @@ -137,7 +137,7 @@ func backendIntakeHandler(cfg *config.Config, builder *authorization.Builder, re
MaxEventSize: cfg.MaxEventSize,
},
reporter)
authHandler := builder.ForPrivilege(authorization.PrivilegeEventWrite)
authHandler := builder.ForPrivilege(authorization.PrivilegeEventWrite.Action)
return middleware.Wrap(h, backendMiddleware(cfg, authHandler, intake.MonitoringMap)...)
}

Expand All @@ -162,12 +162,12 @@ func sourcemapHandler(cfg *config.Config, builder *authorization.Builder, report
return nil, err
}
h := sourcemap.Handler(systemMetadataDecoder(cfg, decoder.DecodeSourcemapFormData), psourcemap.Processor, *tcfg, reporter)
authHandler := builder.ForPrivilege(authorization.PrivilegeSourcemapWrite)
authHandler := builder.ForPrivilege(authorization.PrivilegeSourcemapWrite.Action)
return middleware.Wrap(h, sourcemapMiddleware(cfg, authHandler)...)
}

func backendAgentConfigHandler(cfg *config.Config, builder *authorization.Builder, _ publish.Reporter) (request.Handler, error) {
authHandler := builder.ForPrivilege(authorization.PrivilegeAgentConfigRead)
authHandler := builder.ForPrivilege(authorization.PrivilegeAgentConfigRead.Action)
return agentConfigHandler(cfg, authHandler, backendMiddleware)
}

Expand All @@ -193,7 +193,7 @@ func agentConfigHandler(cfg *config.Config, authHandler *authorization.Handler,

func rootHandler(cfg *config.Config, builder *authorization.Builder, _ publish.Reporter) (request.Handler, error) {
return middleware.Wrap(root.Handler(),
rootMiddleware(cfg, builder.ForAnyOfPrivileges(authorization.PrivilegesAll))...)
rootMiddleware(cfg, builder.ForAnyOfPrivileges(authorization.ActionAny))...)
}

func apmMiddleware(m map[request.ResultID]*monitoring.Int) []middleware.Middleware {
Expand Down
4 changes: 3 additions & 1 deletion beater/api/root/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ package root
import (
"time"

"github.com/elastic/apm-server/beater/authorization"

"github.com/elastic/beats/libbeat/common"
"github.com/elastic/beats/libbeat/monitoring"
"github.com/elastic/beats/libbeat/version"
Expand Down Expand Up @@ -51,7 +53,7 @@ func Handler() request.Handler {
}

c.Result.SetDefault(request.IDResponseValidOK)
authorized, err := c.Authorization.AuthorizedFor("")
authorized, err := c.Authorization.AuthorizedFor(authorization.ResourceInternal)
if err != nil {
c.Result.Err = err
}
Expand Down
4 changes: 3 additions & 1 deletion beater/authorization/allow.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@

package authorization

import "github.com/elastic/apm-server/elasticsearch"

// AllowAuth implements the Authorization interface. It allows all authorization requests.
type AllowAuth struct{}

// AuthorizedFor always returns true
func (AllowAuth) AuthorizedFor(_ string) (bool, error) {
func (AllowAuth) AuthorizedFor(_ elasticsearch.Resource) (bool, error) {
return true, nil
}

Expand Down
123 changes: 47 additions & 76 deletions beater/authorization/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,38 @@
package authorization

import (
"encoding/json"
"net/http"
"strings"
"time"

"github.com/pkg/errors"

"github.com/elastic/apm-server/beater/headers"
"github.com/elastic/apm-server/elasticsearch"
es "github.com/elastic/apm-server/elasticsearch"
)

const (
//DefaultResource for apm backend enabled API Keys
DefaultResource = "-"

application = "apm"
sep = `","`
const cleanupInterval = 60 * time.Second

cleanupInterval = 60 * time.Second
const (
// Application is a constant mapped to the "application" field for the Elasticsearch security API
// This identifies privileges and keys created for APM
Application es.AppName = "apm"
// ResourceInternal is only valid for first authorization of a request.
// The API Key needs to grant privileges to additional resources for successful processing of requests.
ResourceInternal = es.Resource("-")
ResourceAny = es.Resource("*")
)

type apikeyBuilder struct {
esClient elasticsearch.Client
esClient es.Client
cache *privilegesCache
anyOfPrivileges []string
anyOfPrivileges []es.PrivilegeAction
}

type apikeyAuth struct {
*apikeyBuilder
// key is base64(id:apiKey)
key string
}

type hasPrivilegesResponse struct {
Applications map[string]map[string]privileges `json:"application"`
}

func newApikeyBuilder(client elasticsearch.Client, cache *privilegesCache, anyOfPrivileges []string) *apikeyBuilder {
func newApikeyBuilder(client es.Client, cache *privilegesCache, anyOfPrivileges []es.PrivilegeAction) *apikeyBuilder {
return &apikeyBuilder{client, cache, anyOfPrivileges}
}

Expand All @@ -69,15 +64,11 @@ func (a *apikeyAuth) IsAuthorizationConfigured() bool {

// AuthorizedFor checks if the configured api key is authorized.
// An api key is considered to be authorized when the api key has the configured privileges for the requested resource.
// Privileges are fetched from Elasticsearch and then cached in a global cache.
func (a *apikeyAuth) AuthorizedFor(resource string) (bool, error) {
if resource == "" {
resource = DefaultResource
}

//fetch from cache
if allowed, found := a.fromCache(resource); found {
return allowed, nil
// Permissions are fetched from Elasticsearch and then cached in a global cache.
func (a *apikeyAuth) AuthorizedFor(resource es.Resource) (bool, error) {
privileges := a.cache.get(id(a.key, resource))
if privileges != nil {
return a.allowed(privileges), nil
}

if a.cache.isFull() {
Expand All @@ -86,71 +77,51 @@ func (a *apikeyAuth) AuthorizedFor(resource string) (bool, error) {
"or consider increasing config option `apm-server.api_key.limit`")
}

//fetch from ES
privileges, err := a.queryES(resource)
if err != nil {
return false, err
}
//add to cache
a.cache.add(id(a.key, resource), privileges)

allowed, _ := a.fromCache(resource)
return allowed, nil
return a.allowed(privileges), nil
}

func (a *apikeyAuth) fromCache(resource string) (allowed bool, found bool) {
privileges := a.cache.get(id(a.key, resource))
if privileges == nil {
return
}
found = true
allowed = false
func (a *apikeyAuth) allowed(permissions es.Permissions) bool {
var allowed bool
for _, privilege := range a.anyOfPrivileges {
if privilegeAllowed, ok := privileges[privilege]; ok && privilegeAllowed {
allowed = true
return
if privilege == ActionAny {
for _, value := range permissions {
allowed = allowed || value
}
}
allowed = allowed || permissions[privilege]
}
return
return allowed
}

func (a *apikeyAuth) queryES(resource string) (privileges, error) {
query := buildQuery(PrivilegesAll, resource)
statusCode, body, err := a.esClient.SecurityHasPrivilegesRequest(strings.NewReader(query),
http.Header{headers.Authorization: []string{headers.APIKey + " " + a.key}})
if err != nil {
return nil, err
}
defer body.Close()
if statusCode != http.StatusOK {
// return nil privileges for queried apps to ensure they are cached
return privileges{}, nil
func (a *apikeyAuth) queryES(resource es.Resource) (es.Permissions, error) {
request := es.HasPrivilegesRequest{
Applications: []es.Application{
{
Name: Application,
// it is important to query all privilege actions because they are cached by api key+resources
// querying a.anyOfPrivileges would result in an incomplete cache entry
Privileges: ActionsAll(),
Resources: []es.Resource{resource},
},
},
}

var decodedResponse hasPrivilegesResponse
if err := json.NewDecoder(body).Decode(&decodedResponse); err != nil {
info, err := es.HasPrivileges(a.esClient, request, a.key)
if err != nil {
return nil, err
}
if resources, ok := decodedResponse.Applications[application]; ok {
if privileges, ok := resources[resource]; ok {
return privileges, nil
if resources, ok := info.Application[Application]; ok {
if permissions, ok := resources[resource]; ok {
return permissions, nil
}
}
return privileges{}, nil
}

func buildQuery(privileges []string, resource string) string {
var b strings.Builder
b.WriteString(`{"application":[{"application":"`)
b.WriteString(application)
b.WriteString(`","privileges":["`)
b.WriteString(strings.Join(privileges, sep))
b.WriteString(`"],"resources":"`)
b.WriteString(resource)
b.WriteString(`"}]}`)
return b.String()
return es.Permissions{}, nil
}

func id(apiKey, resource string) string {
return apiKey + "_" + resource
func id(apiKey string, resource es.Resource) string {
return apiKey + "_" + string(resource)
}
32 changes: 16 additions & 16 deletions beater/authorization/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ func TestApikeyBuilder(t *testing.T) {
handler2 := tc.builder.forKey(key)

// add existing privileges to shared cache
privilegesValid := privileges{}
privilegesValid := elasticsearch.Permissions{}
for _, p := range PrivilegesAll {
privilegesValid[p] = true
privilegesValid[p.Action] = true
}
resource := "service-go"
resource := elasticsearch.Resource("service-go")
tc.cache.add(id(key, resource), privilegesValid)

// check that cache is actually shared between apiKeyHandlers
Expand Down Expand Up @@ -85,12 +85,12 @@ func TestAPIKey_AuthorizedFor(t *testing.T) {
tc.setup(t)
key := ""
handler := tc.builder.forKey(key)
resourceValid := "foo"
resourceInvalid := "bar"
resourceMissing := "missing"
resourceValid := elasticsearch.Resource("foo")
resourceInvalid := elasticsearch.Resource("bar")
resourceMissing := elasticsearch.Resource("missing")

tc.cache.add(id(key, resourceValid), privileges{tc.anyOfPrivileges[0]: true})
tc.cache.add(id(key, resourceInvalid), privileges{tc.anyOfPrivileges[0]: false})
tc.cache.add(id(key, resourceValid), elasticsearch.Permissions{tc.anyOfPrivileges[0]: true})
tc.cache.add(id(key, resourceInvalid), elasticsearch.Permissions{tc.anyOfPrivileges[0]: false})

valid, err := handler.AuthorizedFor(resourceValid)
require.NoError(t, err)
Expand Down Expand Up @@ -143,9 +143,9 @@ func TestAPIKey_AuthorizedFor(t *testing.T) {
handler := tc.builder.forKey("12a3")

valid, err := handler.AuthorizedFor("xyz")
require.NoError(t, err)
require.Error(t, err)
assert.False(t, valid)
assert.Equal(t, 1, tc.cache.cache.ItemCount())
assert.Equal(t, 0, tc.cache.cache.ItemCount())
})

t.Run("decode error from ES", func(t *testing.T) {
Expand All @@ -163,7 +163,7 @@ type apikeyTestcase struct {
transport *estest.Transport
client elasticsearch.Client
cache *privilegesCache
anyOfPrivileges []string
anyOfPrivileges []elasticsearch.PrivilegeAction

builder *apikeyBuilder
}
Expand All @@ -174,19 +174,19 @@ func (tc *apikeyTestcase) setup(t *testing.T) {
if tc.transport == nil {
tc.transport = estest.NewTransport(t, http.StatusOK, map[string]interface{}{
"application": map[string]interface{}{
application: map[string]privileges{
"foo": {PrivilegeAgentConfigRead: true, PrivilegeEventWrite: true, PrivilegeSourcemapWrite: false},
"bar": {PrivilegeAgentConfigRead: true, PrivilegeEventWrite: false},
"apm": map[string]map[string]interface{}{
"foo": {"config_agent:read": true, "event:write": true, "sourcemap:write": false},
"bar": {"config_agent:read": true, "event:write": false},
}}})
}
tc.client, err = estest.NewElasticsearchClient(tc.transport)
require.NoError(t, err)
}
if tc.cache == nil {
tc.cache = newPrivilegesCache(time.Millisecond, 5)
tc.cache = newPrivilegesCache(time.Minute, 5)
}
if tc.anyOfPrivileges == nil {
tc.anyOfPrivileges = []string{PrivilegeEventWrite, PrivilegeSourcemapWrite}
tc.anyOfPrivileges = []elasticsearch.PrivilegeAction{PrivilegeEventWrite.Action, PrivilegeSourcemapWrite.Action}
}
tc.builder = newApikeyBuilder(tc.client, tc.cache, tc.anyOfPrivileges)
}
4 changes: 3 additions & 1 deletion beater/authorization/bearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package authorization

import (
"crypto/subtle"

"github.com/elastic/apm-server/elasticsearch"
)

type bearerBuilder struct {
Expand All @@ -39,7 +41,7 @@ func (b bearerBuilder) forToken(token string) *bearerAuth {
configured: true}
}

func (b *bearerAuth) AuthorizedFor(_ string) (bool, error) {
func (b *bearerAuth) AuthorizedFor(_ elasticsearch.Resource) (bool, error) {
return b.authorized, nil
}

Expand Down
Loading

0 comments on commit 02c3d15

Please sign in to comment.