From 136ce8146b1122ca207da3024c368a85c5c46f37 Mon Sep 17 00:00:00 2001
From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com>
Date: Tue, 13 Apr 2021 21:07:33 +0000
Subject: [PATCH] Implement support for API Key metadata (#195) (#232)

* Implement support for API Key metadata

* Adjust apikey.Create to make the metadata functional options

* Address code review feedback

* Make metadata properties json omitempty

* Additional changes to the metatadata format

(cherry picked from commit 82ea1e798ac682929ebcc65b390d6d556a803c86)

Co-authored-by: Aleksandr Maus <aleksandr.maus@elastic.co>
---
 cmd/fleet/handleEnroll.go                     |  6 +-
 internal/pkg/apikey/apikey.go                 |  1 +
 .../pkg/apikey/apikey_integration_test.go     | 79 +++++++++++++++++++
 internal/pkg/apikey/apikey_test.go            |  5 +-
 internal/pkg/apikey/create.go                 | 11 +--
 internal/pkg/apikey/get.go                    | 66 ++++++++++++++++
 internal/pkg/apikey/metadata.go               | 34 ++++++++
 7 files changed, 194 insertions(+), 8 deletions(-)
 create mode 100644 internal/pkg/apikey/apikey_integration_test.go
 create mode 100644 internal/pkg/apikey/get.go
 create mode 100644 internal/pkg/apikey/metadata.go

diff --git a/cmd/fleet/handleEnroll.go b/cmd/fleet/handleEnroll.go
index e15ceb26d..55ff4c2d1 100644
--- a/cmd/fleet/handleEnroll.go
+++ b/cmd/fleet/handleEnroll.go
@@ -280,12 +280,14 @@ func createFleetAgent(ctx context.Context, bulker bulk.Bulk, id string, agent mo
 }
 
 func generateAccessApiKey(ctx context.Context, client *elasticsearch.Client, agentId string) (*apikey.ApiKey, error) {
-	return apikey.Create(ctx, client, agentId, "", []byte(kFleetAccessRolesJSON))
+	return apikey.Create(ctx, client, agentId, "", []byte(kFleetAccessRolesJSON),
+		apikey.NewMetadata(agentId, apikey.TypeAccess))
 }
 
 func generateOutputApiKey(ctx context.Context, client *elasticsearch.Client, agentId, outputName string, roles []byte) (*apikey.ApiKey, error) {
 	name := fmt.Sprintf("%s:%s", agentId, outputName)
-	return apikey.Create(ctx, client, name, "", roles)
+	return apikey.Create(ctx, client, name, "", roles,
+		apikey.NewMetadata(agentId, apikey.TypeOutput))
 }
 
 func (et *EnrollerT) fetchEnrollmentKeyRecord(ctx context.Context, id string) (*model.EnrollmentApiKey, error) {
diff --git a/internal/pkg/apikey/apikey.go b/internal/pkg/apikey/apikey.go
index afc02564b..9230cabea 100644
--- a/internal/pkg/apikey/apikey.go
+++ b/internal/pkg/apikey/apikey.go
@@ -22,6 +22,7 @@ var (
 	ErrMalformedHeader = errors.New("malformed authorization header")
 	ErrMalformedToken  = errors.New("malformed token")
 	ErrInvalidToken    = errors.New("token not valid utf8")
+	ErrApiKeyNotFound  = errors.New("api key not found")
 )
 
 var AuthKey = http.CanonicalHeaderKey("Authorization")
diff --git a/internal/pkg/apikey/apikey_integration_test.go b/internal/pkg/apikey/apikey_integration_test.go
new file mode 100644
index 000000000..f629f535c
--- /dev/null
+++ b/internal/pkg/apikey/apikey_integration_test.go
@@ -0,0 +1,79 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+// +build integration
+
+package apikey
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	ftesting "github.com/elastic/fleet-server/v7/internal/pkg/testing"
+
+	"github.com/gofrs/uuid"
+	"github.com/google/go-cmp/cmp"
+)
+
+const testFleetRoles = `
+{
+	"fleet-apikey-access": {
+		"cluster": [],
+		"applications": [{
+			"application": ".fleet",
+			"privileges": ["no-privileges"],
+			"resources": ["*"]
+		}]
+	}
+}
+`
+
+func TestCreateApiKeyWithMetadata(t *testing.T) {
+	ctx, cn := context.WithCancel(context.Background())
+	defer cn()
+
+	bulker := ftesting.SetupBulk(ctx, t)
+
+	// Create the key
+	agentId := uuid.Must(uuid.NewV4()).String()
+	name := uuid.Must(uuid.NewV4()).String()
+	akey, err := Create(ctx, bulker.Client(), name, "", []byte(testFleetRoles),
+		NewMetadata(agentId, TypeAccess))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Get the key and verify that metadata was saved correctly
+	aKeyMeta, err := Get(ctx, bulker.Client(), akey.Id)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	diff := cmp.Diff(ManagedByFleetServer, aKeyMeta.Metadata.ManagedBy)
+	if diff != "" {
+		t.Error(diff)
+	}
+
+	diff = cmp.Diff(true, aKeyMeta.Metadata.Managed)
+	if diff != "" {
+		t.Error(diff)
+	}
+
+	diff = cmp.Diff(agentId, aKeyMeta.Metadata.AgentId)
+	if diff != "" {
+		t.Error(diff)
+	}
+
+	diff = cmp.Diff(TypeAccess.String(), aKeyMeta.Metadata.Type)
+	if diff != "" {
+		t.Error(diff)
+	}
+
+	// Try to get the key that doesn't exists, expect ErrApiKeyNotFound
+	aKeyMeta, err = Get(ctx, bulker.Client(), "0000000000000")
+	if !errors.Is(err, ErrApiKeyNotFound) {
+		t.Errorf("Unexpected error type: %v", err)
+	}
+}
diff --git a/internal/pkg/apikey/apikey_test.go b/internal/pkg/apikey/apikey_test.go
index efd6d0fb6..12ca70613 100644
--- a/internal/pkg/apikey/apikey_test.go
+++ b/internal/pkg/apikey/apikey_test.go
@@ -2,12 +2,15 @@
 // or more contributor license agreements. Licensed under the Elastic License;
 // you may not use this file except in compliance with the Elastic License.
 
+// +build !integration
+
 package apikey
 
 import (
 	"encoding/base64"
-	"github.com/stretchr/testify/assert"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestMonitorLeadership(t *testing.T) {
diff --git a/internal/pkg/apikey/create.go b/internal/pkg/apikey/create.go
index 35d4b66b2..52f9c512f 100644
--- a/internal/pkg/apikey/create.go
+++ b/internal/pkg/apikey/create.go
@@ -14,16 +14,17 @@ import (
 	"github.com/elastic/go-elasticsearch/v8/esapi"
 )
 
-func Create(ctx context.Context, client *elasticsearch.Client, name, ttl string, roles []byte) (*ApiKey, error) {
-
+func Create(ctx context.Context, client *elasticsearch.Client, name, ttl string, roles []byte, meta interface{}) (*ApiKey, error) {
 	payload := struct {
 		Name       string          `json:"name,omitempty"`
 		Expiration string          `json:"expiration,omitempty"`
 		Roles      json.RawMessage `json:"role_descriptors,omitempty"`
+		Metadata   interface{}     `json:"metadata"`
 	}{
-		name,
-		ttl,
-		roles,
+		Name:       name,
+		Expiration: ttl,
+		Roles:      roles,
+		Metadata:   meta,
 	}
 
 	body, err := json.Marshal(&payload)
diff --git a/internal/pkg/apikey/get.go b/internal/pkg/apikey/get.go
new file mode 100644
index 000000000..7230dcd95
--- /dev/null
+++ b/internal/pkg/apikey/get.go
@@ -0,0 +1,66 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package apikey
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/elastic/go-elasticsearch/v8"
+	"github.com/elastic/go-elasticsearch/v8/esapi"
+)
+
+type ApiKeyMetadata struct {
+	Id       string
+	Metadata Metadata
+}
+
+func Get(ctx context.Context, client *elasticsearch.Client, id string) (apiKey ApiKeyMetadata, err error) {
+
+	opts := []func(*esapi.SecurityGetAPIKeyRequest){
+		client.Security.GetAPIKey.WithContext(ctx),
+		client.Security.GetAPIKey.WithID(id),
+	}
+
+	res, err := client.Security.GetAPIKey(
+		opts...,
+	)
+
+	if err != nil {
+		return
+	}
+
+	defer res.Body.Close()
+
+	if res.IsError() {
+		return apiKey, fmt.Errorf("fail GetAPIKey: %s, %w", res.String(), ErrApiKeyNotFound)
+	}
+
+	type APIKeyResponse struct {
+		Id       string   `json:"id"`
+		Metadata Metadata `json:"metadata"`
+	}
+	type GetAPIKeyResponse struct {
+		ApiKeys []APIKeyResponse `json:"api_keys"`
+	}
+
+	var resp GetAPIKeyResponse
+	d := json.NewDecoder(res.Body)
+	if err = d.Decode(&resp); err != nil {
+		return
+	}
+
+	if len(resp.ApiKeys) == 0 {
+		return apiKey, ErrApiKeyNotFound
+	}
+
+	first := resp.ApiKeys[0]
+
+	return ApiKeyMetadata{
+		Id:       first.Id,
+		Metadata: first.Metadata,
+	}, nil
+}
diff --git a/internal/pkg/apikey/metadata.go b/internal/pkg/apikey/metadata.go
new file mode 100644
index 000000000..5e347ecb8
--- /dev/null
+++ b/internal/pkg/apikey/metadata.go
@@ -0,0 +1,34 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package apikey
+
+const ManagedByFleetServer = "fleet-server"
+
+type Type int
+
+const (
+	TypeAccess Type = iota
+	TypeOutput
+)
+
+func (t Type) String() string {
+	return []string{"access", "output"}[t]
+}
+
+type Metadata struct {
+	AgentId   string `json:"agent_id,omitempty"`
+	Managed   bool   `json:"managed,omitempty"`
+	ManagedBy string `json:"managed_by,omitempty"`
+	Type      string `json:"type,omitempty"`
+}
+
+func NewMetadata(agentId string, typ Type) Metadata {
+	return Metadata{
+		AgentId:   agentId,
+		Managed:   true,
+		ManagedBy: ManagedByFleetServer,
+		Type:      typ.String(),
+	}
+}