diff --git a/config/config.go b/config/config.go index ecfcbd272e..61b5a84ac4 100644 --- a/config/config.go +++ b/config/config.go @@ -7,6 +7,7 @@ package config import ( "encoding/json" + "fmt" "os" "path/filepath" @@ -18,19 +19,19 @@ import ( // Config represents the configuration file that OPA can be started with. type Config struct { - Services json.RawMessage `json:"services"` - Labels map[string]string `json:"labels"` - Discovery json.RawMessage `json:"discovery"` - Bundle json.RawMessage `json:"bundle"` // Deprecated: Use `bundles` instead - Bundles json.RawMessage `json:"bundles"` - DecisionLogs json.RawMessage `json:"decision_logs"` - Status json.RawMessage `json:"status"` - Plugins map[string]json.RawMessage `json:"plugins"` - Keys json.RawMessage `json:"keys"` - DefaultDecision *string `json:"default_decision"` - DefaultAuthorizationDecision *string `json:"default_authorization_decision"` - Caching json.RawMessage `json:"caching"` - PersistenceDirectory *string `json:"persistence_directory"` + Services json.RawMessage `json:"services,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Discovery json.RawMessage `json:"discovery,omitempty"` + Bundle json.RawMessage `json:"bundle,omitempty"` // Deprecated: Use `bundles` instead + Bundles json.RawMessage `json:"bundles,omitempty"` + DecisionLogs json.RawMessage `json:"decision_logs,omitempty"` + Status json.RawMessage `json:"status,omitempty"` + Plugins map[string]json.RawMessage `json:"plugins,omitempty"` + Keys json.RawMessage `json:"keys,omitempty"` + DefaultDecision *string `json:"default_decision,omitempty"` + DefaultAuthorizationDecision *string `json:"default_authorization_decision,omitempty"` + Caching json.RawMessage `json:"caching,omitempty"` + PersistenceDirectory *string `json:"persistence_directory,omitempty"` } // ParseConfig returns a valid Config object with defaults injected. The id @@ -105,6 +106,91 @@ func (c Config) GetPersistenceDirectory() (string, error) { return *c.PersistenceDirectory, nil } +// ActiveConfig returns OPA's active configuration +// with the credentials and crypto keys removed +func (c *Config) ActiveConfig() (interface{}, error) { + bs, err := json.Marshal(c) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := util.Unmarshal(bs, &result); err != nil { + return nil, err + } + + if result["services"] != nil { + err = removeServiceCredentials(result["services"]) + if err != nil { + return nil, err + } + } + + if result["keys"] != nil { + err = removeCryptoKeys(result["keys"]) + if err != nil { + return nil, err + } + } + + return result, nil +} + +func removeServiceCredentials(x interface{}) error { + + switch x := x.(type) { + case []interface{}: + for _, v := range x { + err := removeKey(v, "credentials") + if err != nil { + return err + } + } + + case map[string]interface{}: + for _, v := range x { + err := removeKey(v, "credentials") + if err != nil { + return err + } + } + default: + return fmt.Errorf("illegal service config type: %T", x) + } + + return nil +} + +func removeCryptoKeys(x interface{}) error { + + switch x := x.(type) { + case map[string]interface{}: + for _, v := range x { + err := removeKey(v, "key", "private_key") + if err != nil { + return err + } + } + default: + return fmt.Errorf("illegal keys config type: %T", x) + } + + return nil +} + +func removeKey(x interface{}, keys ...string) error { + val, ok := x.(map[string]interface{}) + if !ok { + return fmt.Errorf("type assertion error") + } + + for _, key := range keys { + delete(val, key) + } + + return nil +} + const ( defaultDecisionPath = "/system/main" defaultAuthorizationDecisionPath = "/system/authz/allow" diff --git a/config/config_test.go b/config/config_test.go index ab20d23496..ef749a7cc6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -6,9 +6,14 @@ package config import ( "encoding/json" + "fmt" "os" "path/filepath" + "reflect" "testing" + + "github.com/open-policy-agent/opa/util" + "github.com/open-policy-agent/opa/version" ) func TestConfigPluginsEnabled(t *testing.T) { @@ -98,3 +103,193 @@ func TestPersistDirectory(t *testing.T) { t.Errorf("expected peristDir %v and dir %v to be equal", persistDir, dir) } } + +func TestActiveConfig(t *testing.T) { + + common := `"labels": { + "region": "west" + }, + "keys": { + "global_key": { + "algorithm": HS256, + "key": "secret" + }, + "local_key": { + "private_key": "some_private_key" + } + }, + "decision_logs": { + "service": "acmecorp", + "reporting": { + "min_delay_seconds": 300, + "max_delay_seconds": 600 + } + }, + "plugins": { + "some-plugin": {} + }, + "discovery": {"name": "config"}` + + serviceObj := `"services": { + "acmecorp": { + "url": "https://example.com/control-plane-api/v1", + "response_header_timeout_seconds": 5, + "headers": {"foo": "bar"}, + "credentials": {"bearer": {"token": "test"}} + }, + "opa.example.com": { + "url": "https://opa.example.com", + "headers": {"foo": "bar"}, + "credentials": {"gcp_metadata": {"audience": "test"}} + } + },` + + servicesList := `"services": [ + { + "name": "acmecorp", + "url": "https://example.com/control-plane-api/v1", + "response_header_timeout_seconds": 5, + "headers": {"foo": "bar"}, + "credentials": {"bearer": {"token": "test"}} + }, + { + "name": "opa.example.com", + "url": "https://opa.example.com", + "headers": {"foo": "bar"}, + "credentials": {"gcp_metadata": {"audience": "test"}} + } + ],` + + expectedCommon := fmt.Sprintf(`"labels": { + "id": "foo", + "version": %v, + "region": "west" + }, + "keys": { + "global_key": { + "algorithm": HS256 + }, + "local_key": {} + }, + "decision_logs": { + "service": "acmecorp", + "reporting": { + "min_delay_seconds": 300, + "max_delay_seconds": 600 + } + }, + "plugins": { + "some-plugin": {} + }, + "default_authorization_decision": "/system/authz/allow", + "default_decision": "/system/main", + "discovery": {"name": "config"}`, version.Version) + + expectedServiceObj := `"services": { + "acmecorp": { + "url": "https://example.com/control-plane-api/v1", + "response_header_timeout_seconds": 5, + "headers": {"foo": "bar"} + }, + "opa.example.com": { + "url": "https://opa.example.com", + "headers": {"foo": "bar"} + } + },` + + expectedServicesList := `"services": [ + { + "name": "acmecorp", + "url": "https://example.com/control-plane-api/v1", + "response_header_timeout_seconds": 5, + "headers": {"foo": "bar"} + }, + { + "name": "opa.example.com", + "url": "https://opa.example.com", + "headers": {"foo": "bar"} + } + ],` + + badKeysConfig := []byte(`{ + "keys": [ + { + "algorithm": "HS256" + } + ] + }`) + + badServicesConfig := []byte(`{ + "services": { + "acmecorp": ["foo"] + } + }`) + + tests := map[string]struct { + raw []byte + expected []byte + wantErr bool + err error + }{ + "valid_config_with_svc_object": { + []byte(fmt.Sprintf(`{ %v %v }`, serviceObj, common)), + []byte(fmt.Sprintf(`{ %v %v }`, expectedServiceObj, expectedCommon)), + false, + nil, + }, + "valid_config_with_svc_list": { + []byte(fmt.Sprintf(`{ %v %v }`, servicesList, common)), + []byte(fmt.Sprintf(`{ %v %v }`, expectedServicesList, expectedCommon)), + false, + nil, + }, + "invalid_config_with_bad_keys": { + badKeysConfig, + nil, + true, + fmt.Errorf("illegal keys config type: []interface {}"), + }, + "invalid_config_with_bad_creds": { + badServicesConfig, + nil, + true, + fmt.Errorf("type assertion error"), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + + conf, err := ParseConfig(tc.raw, "foo") + if err != nil { + t.Fatal(err) + } + + actual, err := conf.ActiveConfig() + + if tc.wantErr { + if err == nil { + t.Fatal("Expected error but got nil") + } + + if tc.err != nil && tc.err.Error() != err.Error() { + t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error()) + } + } else { + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + var expected map[string]interface{} + if err := util.Unmarshal(tc.expected, &expected); err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("want %v got %v", expected, actual) + } + } + }) + } + +} diff --git a/docs/content/rest-api.md b/docs/content/rest-api.md index fd932189e4..bb42b8f23c 100644 --- a/docs/content/rest-api.md +++ b/docs/content/rest-api.md @@ -1828,3 +1828,67 @@ Content-Type: application/json ```json {} ``` + +## Config API + +The `/config` API endpoint returns OPA's active configuration. When the discovery feature is enabled, this API can be +used to fetch the discovered configuration in the last evaluated discovery bundle. The `credentials` field in the +[Services](../configuration#services) configuration and the `private_key` and `key` fields in the [Keys](../configuration#keys) +configuration will be omitted from the API response. + +### Get Config + +``` +GET /v1/config HTTP/1.1 +``` + +#### Query Parameters + +- **pretty** - If parameter is `true`, response will formatted for humans. + +#### Status Codes + +- **200** - no error +- **500** - server error + +#### Example Request +```http +GET /v1/config HTTP/1.1 +``` + +#### Example Response +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` +```json +{ + "services": { + "acmecorp": { + "url": "https://example.com/control-plane-api/v1" + } + }, + "labels": { + "id": "test-id", + "version": "0.27.0" + }, + "keys": { + "global_key": { + "scope": "read" + } + }, + "decision_logs": { + "service": "acmecorp" + }, + "status": { + "service": "acmecorp" + }, + "bundles": { + "authz": { + "service": "acmecorp" + } + }, + "default_authorization_decision": "/system/authz/allow", + "default_decision": "/system/main" +} +``` diff --git a/plugins/discovery/discovery_test.go b/plugins/discovery/discovery_test.go index a8679cd1f3..4c3c177333 100644 --- a/plugins/discovery/discovery_test.go +++ b/plugins/discovery/discovery_test.go @@ -170,6 +170,173 @@ func TestProcessBundle(t *testing.T) { } +func TestProcessBundleWithActiveConfig(t *testing.T) { + + ctx := context.Background() + + manager, err := plugins.New([]byte(`{ + "labels": {"x": "y"}, + "services": { + "localhost": { + "url": "http://localhost:9999", + "credentials": {"bearer": {"token": "test"}} + } + }, + "keys": { + "local_key": { + "private_key": "local" + } + }, + "discovery": {"name": "config"}, + }`), "test-id", inmem.New()) + if err != nil { + t.Fatal(err) + } + + initialBundle := makeDataBundle(1, ` + { + "config": { + "services": { + "acmecorp": { + "url": "https://example.com/control-plane-api/v1", + "credentials": {"bearer": {"token": "test-acmecorp"}} + } + }, + "bundles": {"test-bundle": {"service": "localhost"}}, + "status": {"partition_name": "foo"}, + "decision_logs": {"partition_name": "bar"}, + "default_decision": "bar/baz", + "default_authorization_decision": "baz/qux", + "keys": { + "global_key": { + "scope": "read", + "key": "secret" + } + } + } + } + `) + + disco, err := New(manager) + if err != nil { + t.Fatal(err) + } + + _, err = disco.processBundle(ctx, initialBundle) + if err != nil { + t.Fatal(err) + } + + actual, err := manager.Config.ActiveConfig() + if err != nil { + t.Fatal(err) + } + + expectedConfig := fmt.Sprintf(`{ + "services": { + "acmecorp": { + "url": "https://example.com/control-plane-api/v1" + } + }, + "labels": { + "id": "test-id", + "version": %v, + "x": "y" + }, + "keys": { + "global_key": { + "scope": "read" + } + }, + "decision_logs": { + "partition_name": "bar" + }, + "status": { + "partition_name": "foo" + }, + "bundles": { + "test-bundle": { + "service": "localhost" + } + }, + "default_authorization_decision": "baz/qux", + "default_decision": "bar/baz"}`, version.Version) + + var expected map[string]interface{} + if err := util.Unmarshal([]byte(expectedConfig), &expected); err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("want %v got %v", expected, actual) + } + + initialBundle = makeDataBundle(2, ` + { + "config": { + "services": { + "opa.example.com": { + "url": "https://opa.example.com", + "credentials": {"bearer": {"token": "test-opa"}} + } + }, + "bundles": {"test-bundle-2": {"service": "opa.example.com"}}, + "decision_logs": {}, + "keys": { + "global_key_2": { + "scope": "write", + "key": "secret_2" + } + } + } + } + `) + + _, err = disco.processBundle(ctx, initialBundle) + if err != nil { + t.Fatal(err) + } + + actual, err = manager.Config.ActiveConfig() + if err != nil { + t.Fatal(err) + } + + expectedConfig2 := fmt.Sprintf(`{ + "services": { + "opa.example.com": { + "url": "https://opa.example.com" + } + }, + "labels": { + "id": "test-id", + "version": %v, + "x": "y" + }, + "keys": { + "global_key_2": { + "scope": "write" + } + }, + "decision_logs": {}, + "bundles": { + "test-bundle-2": { + "service": "opa.example.com" + } + }, + "default_authorization_decision": "/system/authz/allow", + "default_decision": "/system/main"}`, version.Version) + + var expected2 map[string]interface{} + if err := util.Unmarshal([]byte(expectedConfig2), &expected2); err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, expected2) { + t.Fatalf("want %v got %v", expected, actual) + } +} + type testFactory struct { p *reconfigureTestPlugin } diff --git a/server/server.go b/server/server.go index 28214a77e5..bd927245ae 100644 --- a/server/server.go +++ b/server/server.go @@ -76,6 +76,7 @@ const ( PromHandlerV1Query = "v1/query" PromHandlerV1Policies = "v1/policies" PromHandlerV1Compile = "v1/compile" + PromHandlerV1Config = "v1/config" PromHandlerIndex = "index" PromHandlerCatch = "catchall" PromHandlerHealth = "health" @@ -612,6 +613,7 @@ func (s *Server) initRouters() { s.registerHandler(mainRouter, 1, "/query", http.MethodGet, s.instrumentHandler(s.v1QueryGet, PromHandlerV1Query)) s.registerHandler(mainRouter, 1, "/query", http.MethodPost, s.instrumentHandler(s.v1QueryPost, PromHandlerV1Query)) s.registerHandler(mainRouter, 1, "/compile", http.MethodPost, s.instrumentHandler(s.v1CompilePost, PromHandlerV1Compile)) + s.registerHandler(mainRouter, 1, "/config", http.MethodGet, s.instrumentHandler(s.v1ConfigGet, PromHandlerV1Config)) mainRouter.Handle("/", s.instrumentHandler(s.unversionedPost, PromHandlerIndex)).Methods(http.MethodPost) mainRouter.Handle("/", s.instrumentHandler(s.indexGet, PromHandlerIndex)).Methods(http.MethodGet) @@ -2004,6 +2006,17 @@ func (s *Server) v1QueryPost(w http.ResponseWriter, r *http.Request) { writer.JSON(w, 200, results, pretty) } +func (s *Server) v1ConfigGet(w http.ResponseWriter, r *http.Request) { + pretty := getBoolParam(r.URL, types.ParamPrettyV1, true) + result, err := s.manager.Config.ActiveConfig() + if err != nil { + writer.ErrorAuto(w, err) + return + } + + writer.JSON(w, 200, result, pretty) +} + func (s *Server) checkPolicyIDScope(ctx context.Context, txn storage.Transaction, id string) error { bs, err := s.store.GetPolicy(ctx, txn, id) diff --git a/server/server_test.go b/server/server_test.go index 07dfeb3dd9..6147bcb5d3 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -22,6 +22,7 @@ import ( "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/bundle" + "github.com/open-policy-agent/opa/config" "github.com/open-policy-agent/opa/metrics" "github.com/open-policy-agent/opa/plugins" pluginBundle "github.com/open-policy-agent/opa/plugins/bundle" @@ -1178,6 +1179,68 @@ p = true { false }` } } +func TestConfigV1(t *testing.T) { + f := newFixture(t) + + c := []byte(`{"services": { + "acmecorp": { + "url": "https://example.com/control-plane-api/v1", + "credentials": {"bearer": {"token": "test"}} + } + }, + "labels": { + "region": "west" + }, + "keys": { + "global_key": { + "algorithm": HS256, + "key": "secret" + } + }}`) + + conf, err := config.ParseConfig(c, "foo") + if err != nil { + t.Fatal(err) + } + + f.server.manager.Config = conf + + expected := map[string]interface{}{} + expected["labels"] = map[string]interface{}{"id": "foo", "version": version.Version, "region": "west"} + expected["keys"] = map[string]interface{}{"global_key": map[string]interface{}{"algorithm": "HS256"}} + expected["services"] = map[string]interface{}{"acmecorp": map[string]interface{}{"url": "https://example.com/control-plane-api/v1"}} + expected["default_authorization_decision"] = "/system/authz/allow" + expected["default_decision"] = "/system/main" + + bs, err := json.Marshal(expected) + if err != nil { + t.Fatal(err) + } + + if err := f.v1(http.MethodGet, "/config", "", 200, string(bs)); err != nil { + t.Fatal(err) + } + + badServicesConfig := []byte(`{ + "services": { + "acmecorp": ["foo"] + } + }`) + + conf, err = config.ParseConfig(badServicesConfig, "foo") + if err != nil { + t.Fatal(err) + } + + f.server.manager.Config = conf + + if err := f.v1(http.MethodGet, "/config", "", 500, `{ + "code": "internal_error", + "message": "type assertion error"}`); err != nil { + t.Fatal(err) + } +} + func TestDataYAML(t *testing.T) { testMod1 := `package testmod