// Copyright (c) 2015-2024 MinIO, Inc. // // This file is part of MinIO Object Storage stack // // 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 . package cmd import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/minio/madmin-go/v3" "github.com/minio/minio/internal/kms" "github.com/minio/pkg/v3/policy" ) const ( // KMS API paths // For example: /minio/kms/v1/key/list?pattern=* kmsURL = kmsPathPrefix + kmsAPIVersionPrefix kmsStatusPath = kmsURL + "/status" kmsMetricsPath = kmsURL + "/metrics" kmsAPIsPath = kmsURL + "/apis" kmsVersionPath = kmsURL + "/version" kmsKeyCreatePath = kmsURL + "/key/create" kmsKeyListPath = kmsURL + "/key/list" kmsKeyStatusPath = kmsURL + "/key/status" // Admin API paths // For example: /minio/admin/v3/kms/status adminURL = adminPathPrefix + adminAPIVersionPrefix kmsAdminStatusPath = adminURL + "/kms/status" kmsAdminKeyStatusPath = adminURL + "/kms/key/status" kmsAdminKeyCreate = adminURL + "/kms/key/create" ) const ( userAccessKey = "miniofakeuseraccesskey" userSecretKey = "miniofakeusersecret" ) type kmsTestCase struct { name string method string path string query map[string]string // User credentials and policy for request policy string asRoot bool // Wanted in response. wantStatusCode int wantKeyNames []string wantResp []string } func TestKMSHandlersCreateKey(t *testing.T) { adminTestBed, tearDown := setupKMSTest(t, true) defer tearDown() tests := []kmsTestCase{ // Create key test { name: "create key as user with no policy want forbidden", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "new-test-key"}, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "create key as user with no resources specified want success", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "new-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:CreateKey"] }`, wantStatusCode: http.StatusOK, }, { name: "create key as user set policy to allow want success", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "second-new-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:CreateKey"], "Resource": ["arn:minio:kms:::second-new-test-*"] }`, wantStatusCode: http.StatusOK, }, { name: "create key as user set policy to non matching resource want forbidden", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "third-new-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:CreateKey"], "Resource": ["arn:minio:kms:::non-matching-key-name"] }`, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, } for testNum, test := range tests { t.Run(fmt.Sprintf("%d %s", testNum+1, test.name), func(t *testing.T) { execKMSTest(t, test, adminTestBed) }) } } func TestKMSHandlersKeyStatus(t *testing.T) { adminTestBed, tearDown := setupKMSTest(t, true) defer tearDown() tests := []kmsTestCase{ { name: "create a first key root user", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: true, wantStatusCode: http.StatusOK, }, { name: "key status as root want success", method: http.MethodGet, path: kmsKeyStatusPath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"abc-test-key"}, }, { name: "key status as user no policy want forbidden", method: http.MethodGet, path: kmsKeyStatusPath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "key status as user legacy no resources specified want success", method: http.MethodGet, path: kmsKeyStatusPath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:KeyStatus"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"abc-test-key"}, }, { name: "key status as user set policy to allow only one key", method: http.MethodGet, path: kmsKeyStatusPath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:KeyStatus"], "Resource": ["arn:minio:kms:::abc-test-*"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"abc-test-key"}, }, { name: "key status as user set policy to allow non-matching key", method: http.MethodGet, path: kmsKeyStatusPath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:KeyStatus"], "Resource": ["arn:minio:kms:::xyz-test-key"] }`, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, } for testNum, test := range tests { t.Run(fmt.Sprintf("%d %s", testNum+1, test.name), func(t *testing.T) { execKMSTest(t, test, adminTestBed) }) } } func TestKMSHandlersAPIs(t *testing.T) { adminTestBed, tearDown := setupKMSTest(t, true) defer tearDown() tests := []kmsTestCase{ // Version test { name: "version as root want success", method: http.MethodGet, path: kmsVersionPath, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"version"}, }, { name: "version as user with no policy want forbidden", method: http.MethodGet, path: kmsVersionPath, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "version as user with policy ignores resource want success", method: http.MethodGet, path: kmsVersionPath, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:Version"], "Resource": ["arn:minio:kms:::does-not-matter-it-is-ignored"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"version"}, }, // APIs test { name: "apis as root want success", method: http.MethodGet, path: kmsAPIsPath, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"stub/path"}, }, { name: "apis as user with no policy want forbidden", method: http.MethodGet, path: kmsAPIsPath, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "apis as user with policy ignores resource want success", method: http.MethodGet, path: kmsAPIsPath, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:API"], "Resource": ["arn:minio:kms:::does-not-matter-it-is-ignored"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"stub/path"}, }, // Metrics test { name: "metrics as root want success", method: http.MethodGet, path: kmsMetricsPath, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"kms"}, }, { name: "metrics as user with no policy want forbidden", method: http.MethodGet, path: kmsMetricsPath, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "metrics as user with policy ignores resource want success", method: http.MethodGet, path: kmsMetricsPath, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:Metrics"], "Resource": ["arn:minio:kms:::does-not-matter-it-is-ignored"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"kms"}, }, // Status tests { name: "status as root want success", method: http.MethodGet, path: kmsStatusPath, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"MinIO builtin"}, }, { name: "status as user with no policy want forbidden", method: http.MethodGet, path: kmsStatusPath, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "status as user with policy ignores resource want success", method: http.MethodGet, path: kmsStatusPath, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:Status"], "Resource": ["arn:minio:kms:::does-not-matter-it-is-ignored"]}`, wantStatusCode: http.StatusOK, wantResp: []string{"MinIO builtin"}, }, } for testNum, test := range tests { t.Run(fmt.Sprintf("%d %s", testNum+1, test.name), func(t *testing.T) { execKMSTest(t, test, adminTestBed) }) } } func TestKMSHandlersListKeys(t *testing.T) { adminTestBed, tearDown := setupKMSTest(t, true) defer tearDown() tests := []kmsTestCase{ { name: "create a first key root user", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "abc-test-key"}, asRoot: true, wantStatusCode: http.StatusOK, }, { name: "create a second key root user", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "xyz-test-key"}, asRoot: true, wantStatusCode: http.StatusOK, }, // List keys tests { name: "list keys as root want all to be returned", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, asRoot: true, wantStatusCode: http.StatusOK, wantKeyNames: []string{"default-test-key", "abc-test-key", "xyz-test-key"}, }, { name: "list keys as user with no policy want forbidden", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "list keys as user with no resources specified want success", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:ListKeys"] }`, wantStatusCode: http.StatusOK, wantKeyNames: []string{"default-test-key", "abc-test-key", "xyz-test-key"}, }, { name: "list keys as user set policy resource to allow only one key", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:ListKeys"], "Resource": ["arn:minio:kms:::abc*"]}`, wantStatusCode: http.StatusOK, wantKeyNames: []string{"abc-test-key"}, }, { name: "list keys as user set policy to allow only one key, use pattern that includes correct key", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "abc*"}, policy: `{"Effect": "Allow", "Action": ["kms:ListKeys"], "Resource": ["arn:minio:kms:::abc*"]}`, wantStatusCode: http.StatusOK, wantKeyNames: []string{"abc-test-key"}, }, { name: "list keys as user set policy to allow only one key, use pattern that excludes correct key", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "xyz*"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:ListKeys"], "Resource": ["arn:minio:kms:::abc*"]}`, wantStatusCode: http.StatusOK, wantKeyNames: []string{}, }, { name: "list keys as user set policy that has no matching key resources", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["kms:ListKeys"], "Resource": ["arn:minio:kms:::nonematch*"]}`, wantStatusCode: http.StatusOK, wantKeyNames: []string{}, }, { name: "list keys as user set policy that allows listing but denies specific keys", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, asRoot: false, // It looks like this should allow listing any key that isn't "default-test-key", however // the policy engine matches all Deny statements first, without regard to Resources (for KMS). // This is for backwards compatibility where historically KMS statements ignored Resources. policy: `{ "Effect": "Allow", "Action": ["kms:ListKeys"] },{ "Effect": "Deny", "Action": ["kms:ListKeys"], "Resource": ["arn:minio:kms:::default-test-key"] }`, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, } for testNum, test := range tests { t.Run(fmt.Sprintf("%d %s", testNum+1, test.name), func(t *testing.T) { execKMSTest(t, test, adminTestBed) }) } } func TestKMSHandlerAdminAPI(t *testing.T) { adminTestBed, tearDown := setupKMSTest(t, true) defer tearDown() tests := []kmsTestCase{ // Create key tests { name: "create a key root user", method: http.MethodPost, path: kmsAdminKeyCreate, query: map[string]string{"key-id": "abc-test-key"}, asRoot: true, wantStatusCode: http.StatusOK, }, { name: "create key as user with no policy want forbidden", method: http.MethodPost, path: kmsAdminKeyCreate, query: map[string]string{"key-id": "new-test-key"}, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "create key as user with no resources specified want success", method: http.MethodPost, path: kmsAdminKeyCreate, query: map[string]string{"key-id": "new-test-key"}, asRoot: false, policy: `{"Effect": "Allow", "Action": ["admin:KMSCreateKey"] }`, wantStatusCode: http.StatusOK, }, { name: "create key as user set policy to non matching resource want success", method: http.MethodPost, path: kmsAdminKeyCreate, query: map[string]string{"key-id": "third-new-test-key"}, asRoot: false, // Admin actions ignore Resources policy: `{"Effect": "Allow", "Action": ["admin:KMSCreateKey"], "Resource": ["arn:minio:kms:::this-is-disregarded"] }`, wantStatusCode: http.StatusOK, }, // Status tests { name: "status as root want success", method: http.MethodPost, path: kmsAdminStatusPath, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"MinIO builtin"}, }, { name: "status as user with no policy want forbidden", method: http.MethodPost, path: kmsAdminStatusPath, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "status as user with policy ignores resource want success", method: http.MethodPost, path: kmsAdminStatusPath, asRoot: false, policy: `{"Effect": "Allow", "Action": ["admin:KMSKeyStatus"], "Resource": ["arn:minio:kms:::does-not-matter-it-is-ignored"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"MinIO builtin"}, }, // Key status tests { name: "key status as root want success", method: http.MethodGet, path: kmsAdminKeyStatusPath, asRoot: true, wantStatusCode: http.StatusOK, wantResp: []string{"key-id"}, }, { name: "key status as user with no policy want forbidden", method: http.MethodGet, path: kmsAdminKeyStatusPath, asRoot: false, wantStatusCode: http.StatusForbidden, wantResp: []string{"AccessDenied"}, }, { name: "key status as user with policy ignores resource want success", method: http.MethodGet, path: kmsAdminKeyStatusPath, asRoot: false, policy: `{"Effect": "Allow", "Action": ["admin:KMSKeyStatus"], "Resource": ["arn:minio:kms:::does-not-matter-it-is-ignored"] }`, wantStatusCode: http.StatusOK, wantResp: []string{"key-id"}, }, } for testNum, test := range tests { t.Run(fmt.Sprintf("%d %s", testNum+1, test.name), func(t *testing.T) { execKMSTest(t, test, adminTestBed) }) } } // execKMSTest runs a single test case for KMS handlers func execKMSTest(t *testing.T, test kmsTestCase, adminTestBed *adminErasureTestBed) { var accessKey, secretKey string if test.asRoot { accessKey, secretKey = globalActiveCred.AccessKey, globalActiveCred.SecretKey } else { setupKMSUser(t, userAccessKey, userSecretKey, test.policy) accessKey = userAccessKey secretKey = userSecretKey } req := buildKMSRequest(t, test.method, test.path, accessKey, secretKey, test.query) rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) t.Logf("HTTP req: %s, resp code: %d, resp body: %s", req.URL.String(), rec.Code, rec.Body.String()) // Check status code if rec.Code != test.wantStatusCode { t.Errorf("want status code %d, got %d", test.wantStatusCode, rec.Code) } // Check returned key list is correct if test.wantKeyNames != nil { gotKeyNames := keyNamesFromListKeysResp(t, rec.Body.Bytes()) if len(test.wantKeyNames) != len(gotKeyNames) { t.Fatalf("want keys len: %d, got len: %d", len(test.wantKeyNames), len(gotKeyNames)) } for i, wantKeyName := range test.wantKeyNames { if gotKeyNames[i] != wantKeyName { t.Fatalf("want key name %s, in position %d, got %s", wantKeyName, i, gotKeyNames[i]) } } } // Check generic text in the response if test.wantResp != nil { for _, want := range test.wantResp { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("want response to contain %s, got %s", want, rec.Body.String()) } } } } // TestKMSHandlerNotConfiguredOrInvalidCreds tests KMS handlers for situations where KMS is not configured // or invalid credentials are provided. func TestKMSHandlerNotConfiguredOrInvalidCreds(t *testing.T) { adminTestBed, tearDown := setupKMSTest(t, false) defer tearDown() tests := []struct { name string method string path string query map[string]string }{ { name: "GET status", method: http.MethodGet, path: kmsStatusPath, }, { name: "GET metrics", method: http.MethodGet, path: kmsMetricsPath, }, { name: "GET apis", method: http.MethodGet, path: kmsAPIsPath, }, { name: "GET version", method: http.MethodGet, path: kmsVersionPath, }, { name: "POST key create", method: http.MethodPost, path: kmsKeyCreatePath, query: map[string]string{"key-id": "master-key-id"}, }, { name: "GET key list", method: http.MethodGet, path: kmsKeyListPath, query: map[string]string{"pattern": "*"}, }, { name: "GET key status", method: http.MethodGet, path: kmsKeyStatusPath, query: map[string]string{"key-id": "master-key-id"}, }, } // Test when the GlobalKMS is not configured for _, test := range tests { t.Run(test.name+" not configured", func(t *testing.T) { req := buildKMSRequest(t, test.method, test.path, "", "", test.query) rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) if rec.Code != http.StatusNotImplemented { t.Errorf("want status code %d, got %d", http.StatusNotImplemented, rec.Code) } }) } // Test when the GlobalKMS is configured but the credentials are invalid GlobalKMS = kms.NewStub("default-test-key") for _, test := range tests { t.Run(test.name+" invalid credentials", func(t *testing.T) { req := buildKMSRequest(t, test.method, test.path, userAccessKey, userSecretKey, test.query) rec := httptest.NewRecorder() adminTestBed.router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Errorf("want status code %d, got %d", http.StatusForbidden, rec.Code) } }) } } func setupKMSTest(t *testing.T, enableKMS bool) (*adminErasureTestBed, func()) { adminTestBed, err := prepareAdminErasureTestBed(context.Background()) if err != nil { t.Fatal(err) } registerKMSRouter(adminTestBed.router) if enableKMS { GlobalKMS = kms.NewStub("default-test-key") } tearDown := func() { adminTestBed.TearDown() GlobalKMS = nil } return adminTestBed, tearDown } func buildKMSRequest(t *testing.T, method, path, accessKey, secretKey string, query map[string]string) *http.Request { if len(query) > 0 { queryVal := url.Values{} for k, v := range query { queryVal.Add(k, v) } path = path + "?" + queryVal.Encode() } if accessKey == "" && secretKey == "" { accessKey = globalActiveCred.AccessKey secretKey = globalActiveCred.SecretKey } req, err := newTestSignedRequestV4(method, path, 0, nil, accessKey, secretKey, nil) if err != nil { t.Fatal(err) } return req } // setupKMSUser is a test helper that creates a new user with the provided access key and secret key // and applies the given policy to the user. func setupKMSUser(t *testing.T, accessKey, secretKey, p string) { ctx := context.Background() createUserParams := madmin.AddOrUpdateUserReq{ SecretKey: secretKey, Status: madmin.AccountEnabled, } _, err := globalIAMSys.CreateUser(ctx, accessKey, createUserParams) if err != nil { t.Fatal(err) } testKMSPolicyName := "testKMSPolicy" if p != "" { p = `{"Version":"2012-10-17","Statement":[` + p + `]}` policyData, err := policy.ParseConfig(strings.NewReader(p)) if err != nil { t.Fatal(err) } _, err = globalIAMSys.SetPolicy(ctx, testKMSPolicyName, *policyData) if err != nil { t.Fatal(err) } _, err = globalIAMSys.PolicyDBSet(ctx, accessKey, testKMSPolicyName, regUser, false) if err != nil { t.Fatal(err) } } else { err = globalIAMSys.DeletePolicy(ctx, testKMSPolicyName, false) if err != nil { t.Fatal(err) } _, err = globalIAMSys.PolicyDBSet(ctx, accessKey, "", regUser, false) if err != nil { t.Fatal(err) } } } func keyNamesFromListKeysResp(t *testing.T, b []byte) []string { var keyInfos []madmin.KMSKeyInfo err := json.Unmarshal(b, &keyInfos) if err != nil { t.Fatalf("cannot unmarshal '%s', err: %v", b, err) } var gotKeyNames []string for _, keyInfo := range keyInfos { gotKeyNames = append(gotKeyNames, keyInfo.Name) } return gotKeyNames }