config: Add API to expose OPA's active config

This commit adds a new API endpoint to fetch 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.

Fixes: #2020

Signed-off-by: Ashutosh Narkar <anarkar4387@gmail.com>
This commit is contained in:
Ashutosh Narkar
2021-03-18 21:53:56 -07:00
parent ad08b207f6
commit cb104e318b
6 changed files with 601 additions and 13 deletions
+99 -13
View File
@@ -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"
+195
View File
@@ -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)
}
}
})
}
}
+64
View File
@@ -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"
}
```
+167
View File
@@ -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
}
+13
View File
@@ -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)
+63
View File
@@ -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