diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 8eedd73ec1..bc5451dd74 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -523,6 +523,45 @@ bundles: max_delay_seconds: 120 ``` +#### Azure Managed Identities Token + +OPA will authenticate with an [Azure managed identities](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) token. +The [token request](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) +can be configured via the plugin to customize the base URL, API version, and resource. Specific managed identity IDs can be optionally provided as well. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `services[_].credentials.azure_managed_identity.endpoint` | `string` | No | Request endpoint. (default: `http://169.254.169.254/metadata/identity/oauth2/token`, the Azure Instance Metadata Service endpoint (recommended))| +| `services[_].credentials.azure_managed_identity.api_version` | `string` | No | API version to use. (default: `2018-02-01`, the minimum version) | +| `services[_].credentials.azure_managed_identity.resource` | `string` | No | App ID URI of the target resource. (default: `https://storage.azure.com/`) | +| `services[_].credentials.azure_managed_identity.object_id` | `string` | No | Optional object ID of the managed identity you would like the token for. Required, if your VM has multiple user-assigned managed identites. | +| `services[_].credentials.azure_managed_identity.client_id` | `string` | No | Optional client ID of the managed identity you would like the token for. Required, if your VM has multiple user-assigned managed identites. | +| `services[_].credentials.azure_managed_identity.mi_res_id` | `string` | No | Optional Azure Resource ID of the managed identity you would like the token for. Required, if your VM has multiple user-assigned managed identites. | + +##### Example +Use an [Azure storage account](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-overview) as a bundle service backend. +Note that the `x-ms-version` header must be specified for the storage account service, and a minimum version of `2017-11-09` must be provided as per [Azure documentation](https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-azure-active-directory#call-storage-operations-with-oauth-tokens). + +```yaml +services: + azure_storage_account: + url: ${STORAGE_ACCOUNT_URL} + headers: + x-ms-version: 2017-11-09 + response_header_timeout_seconds: 5 + credentials: + azure_managed_identity: {} + +bundles: + authz: + service: azure_storage_account + resource: bundles/http/example/authz.tar.gz + persist: true + polling: + min_delay_seconds: 60 + max_delay_seconds: 120 +``` + #### Custom Plugin If none of the existing credential options work for a service, OPA can authenticate using a custom plugin, enabling support for any authentication scheme. diff --git a/plugins/rest/azure.go b/plugins/rest/azure.go new file mode 100644 index 0000000000..b40aca1cb2 --- /dev/null +++ b/plugins/rest/azure.go @@ -0,0 +1,152 @@ +package rest + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" +) + +var ( + azureIMDSEndpoint = "http://169.254.169.254/metadata/identity/oauth2/token" + defaultAPIVersion = "2018-02-01" + defaultResource = "https://storage.azure.com/" + timeout = 5 * time.Second +) + +// azureManagedIdentitiesToken holds a token for managed identities for Azure resources +type azureManagedIdentitiesToken struct { + AccessToken string `json:"access_token"` + ExpiresIn string `json:"expires_in"` + ExpiresOn string `json:"expires_on"` + NotBefore string `json:"not_before"` + Resource string `json:"resource"` + TokenType string `json:"token_type"` +} + +// azureManagedIdentitiesError represents an error fetching an azureManagedIdentitiesToken +type azureManagedIdentitiesError struct { + Err string `json:"error"` + Description string `json:"error_description"` + Endpoint string + StatusCode int +} + +func (e *azureManagedIdentitiesError) Error() string { + return fmt.Sprintf("%v %s retrieving azure token from %s: %s", e.StatusCode, e.Err, e.Endpoint, e.Description) +} + +// azureManagedIdentitiesAuthPlugin uses an azureManagedIdentitiesToken.AccessToken for bearer authorization +type azureManagedIdentitiesAuthPlugin struct { + Endpoint string `json:"endpoint"` + APIVersion string `json:"api_version"` + Resource string `json:"resource"` + ObjectID string `json:"object_id"` + ClientID string `json:"client_id"` + MiResID string `json:"mi_res_id"` +} + +func (ap *azureManagedIdentitiesAuthPlugin) NewClient(c Config) (*http.Client, error) { + if ap.Endpoint == "" { + ap.Endpoint = azureIMDSEndpoint + } + + if ap.Resource == "" { + ap.Resource = defaultResource + } + + if ap.APIVersion == "" { + ap.APIVersion = defaultAPIVersion + } + + t, err := DefaultTLSConfig(c) + if err != nil { + return nil, err + } + + return DefaultRoundTripperClient(t, *c.ResponseHeaderTimeoutSeconds), nil +} + +func (ap *azureManagedIdentitiesAuthPlugin) Prepare(req *http.Request) error { + token, err := azureManagedIdentitiesTokenRequest( + ap.Endpoint, ap.APIVersion, ap.Resource, + ap.ObjectID, ap.ClientID, ap.MiResID, + ) + if err != nil { + return err + } + + req.Header.Add("Authorization", "Bearer "+token.AccessToken) + return nil +} + +// azureManagedIdentitiesTokenRequest fetches an azureManagedIdentitiesToken +func azureManagedIdentitiesTokenRequest( + endpoint, apiVersion, resource, objectID, clientID, miResID string, +) (azureManagedIdentitiesToken, error) { + var token azureManagedIdentitiesToken + e := buildAzureManagedIdentitiesRequestPath(endpoint, apiVersion, resource, objectID, clientID, miResID) + + request, err := http.NewRequest("GET", e, nil) + if err != nil { + return token, err + } + request.Header.Add("Metadata", "true") + + httpClient := http.Client{Timeout: timeout} + response, err := httpClient.Do(request) + if err != nil { + return token, err + } + defer response.Body.Close() + + data, err := ioutil.ReadAll(response.Body) + if err != nil { + return token, err + } + + if s := response.StatusCode; s != http.StatusOK { + var azureError azureManagedIdentitiesError + err = json.Unmarshal(data, &azureError) + if err != nil { + return token, err + } + + azureError.Endpoint = e + azureError.StatusCode = s + return token, &azureError + } + + err = json.Unmarshal(data, &token) + if err != nil { + return token, err + } + + return token, nil +} + +// buildAzureManagedIdentitiesRequestPath constructs the request URL for an Azure managed identities token request +func buildAzureManagedIdentitiesRequestPath( + endpoint, apiVersion, resource, objectID, clientID, miResID string, +) string { + params := url.Values{ + "api-version": []string{apiVersion}, + "resource": []string{resource}, + } + + if objectID != "" { + params.Add("object_id", objectID) + } + + if clientID != "" { + params.Add("client_id", clientID) + } + + if miResID != "" { + params.Add("mi_res_id", miResID) + } + + return endpoint + "?" + params.Encode() +} diff --git a/plugins/rest/azure_test.go b/plugins/rest/azure_test.go new file mode 100644 index 0000000000..f2ddeb1283 --- /dev/null +++ b/plugins/rest/azure_test.go @@ -0,0 +1,185 @@ +package rest + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/open-policy-agent/opa/keys" +) + +func assertStringsEqual(t *testing.T, expected string, actual string, label string) { + t.Helper() + if actual != expected { + t.Errorf("%s: expected %s, got %s", label, expected, actual) + } +} + +func assertParamsEqual(t *testing.T, expected url.Values, actual url.Values, label string) { + t.Helper() + if !reflect.DeepEqual(expected, actual) { + t.Errorf("%s: expected %s, got %s", label, expected.Encode(), actual.Encode()) + } +} + +func TestAzureManagedIdentitiesAuthPlugin_NewClient(t *testing.T) { + tests := []struct { + label string + endpoint string + apiVersion string + resource string + objectID string + clientID string + miResID string + }{ + { + "test all defaults", + "", "", "", "", "", "", + }, + { + "test no defaults", + "some_endpoint", "some_version", "some_resource", "some_oid", "some_cid", "some_miresid", + }, + } + + nonEmptyString := func(value string, defaultValue string) string { + if value == "" { + return defaultValue + } + return value + } + + for _, tt := range tests { + config := generateConfigString(tt.endpoint, tt.apiVersion, tt.resource, tt.objectID, tt.clientID, tt.miResID) + + client, err := New([]byte(config), map[string]*keys.Config{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ap := client.config.Credentials.AzureManagedIdentity + _, err = ap.NewClient(client.config) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // We test that default values are set correctly in the azureManagedIdentitiesAuthPlugin + // Note that there is significant overlap between TestAzureManagedIdentitiesAuthPlugin_NewClient and TestAzureManagedIdentitiesAuthPlugin + // This is because the latter cannot test default endpoint setting, which we do here + assertStringsEqual(t, nonEmptyString(tt.endpoint, azureIMDSEndpoint), ap.Endpoint, tt.label) + assertStringsEqual(t, nonEmptyString(tt.apiVersion, defaultAPIVersion), ap.APIVersion, tt.label) + assertStringsEqual(t, nonEmptyString(tt.resource, defaultResource), ap.Resource, tt.label) + assertStringsEqual(t, tt.objectID, ap.ObjectID, tt.label) + assertStringsEqual(t, tt.clientID, ap.ClientID, tt.label) + assertStringsEqual(t, tt.miResID, ap.MiResID, tt.label) + } +} + +func TestAzureManagedIdentitiesAuthPlugin(t *testing.T) { + tests := []struct { + label string + apiVersion string + resource string + objectID string + clientID string + miResID string + expectedParams url.Values + }{ + { + "test all defaults", + "", "", "", "", "", + url.Values{ + "api-version": []string{"2018-02-01"}, + "resource": []string{"https://storage.azure.com/"}, + }, + }, + { + "test custom api version", + "2021-02-01", "", "", "", "", + url.Values{ + "api-version": []string{"2021-02-01"}, + "resource": []string{"https://storage.azure.com/"}, + }, + }, + { + "test custom resource", + "", "https://management.azure.com/", "", "", "", + url.Values{ + "api-version": []string{"2018-02-01"}, + "resource": []string{"https://management.azure.com/"}, + }, + }, + { + "test custom IDs", + "", "", "oid", "cid", "mrid", + url.Values{ + "api-version": []string{"2018-02-01"}, + "resource": []string{"https://storage.azure.com/"}, + "object_id": []string{"oid"}, + "client_id": []string{"cid"}, + "mi_res_id": []string{"mrid"}, + }, + }, + } + + for _, tt := range tests { + ts := azureManagedIdentitiesTestServer{ + t: t, + label: tt.label, + expectedParams: tt.expectedParams, + } + ts.start() + + config := generateConfigString(ts.server.URL, tt.apiVersion, tt.resource, tt.objectID, tt.clientID, tt.miResID) + + client, err := New([]byte(config), map[string]*keys.Config{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ctx := context.Background() + _, _ = client.Do(ctx, "GET", "test") + ts.stop() + } +} + +type azureManagedIdentitiesTestServer struct { + t *testing.T + server *httptest.Server + label string + expectedParams url.Values +} + +func (t *azureManagedIdentitiesTestServer) handle(_ http.ResponseWriter, r *http.Request) { + assertParamsEqual(t.t, t.expectedParams, r.URL.Query(), t.label) +} + +func (t *azureManagedIdentitiesTestServer) start() { + t.server = httptest.NewServer(http.HandlerFunc(t.handle)) +} + +func (t *azureManagedIdentitiesTestServer) stop() { + t.server.Close() +} + +func generateConfigString(endpoint, apiVersion, resource, objectID, clientID, miResID string) string { + return fmt.Sprintf(`{ + "name": "name", + "url": "url", + "allow_insecure_tls": true, + "credentials": { + "azure_managed_identity": { + "endpoint": "%s", + "api_version": "%s", + "resource": "%s", + "object_id": "%s", + "client_id": "%s", + "mi_res_id": "%s" + } + } + }`, endpoint, apiVersion, resource, objectID, clientID, miResID) +} diff --git a/plugins/rest/rest.go b/plugins/rest/rest.go index 4e18ffe86e..805409644d 100644 --- a/plugins/rest/rest.go +++ b/plugins/rest/rest.go @@ -44,12 +44,13 @@ type Config struct { ResponseHeaderTimeoutSeconds *int64 `json:"response_header_timeout_seconds,omitempty"` TLS *serverTLSConfig `json:"tls,omitempty"` Credentials struct { - Bearer *bearerAuthPlugin `json:"bearer,omitempty"` - OAuth2 *oauth2ClientCredentialsAuthPlugin `json:"oauth2,omitempty"` - ClientTLS *clientTLSAuthPlugin `json:"client_tls,omitempty"` - S3Signing *awsSigningAuthPlugin `json:"s3_signing,omitempty"` - GCPMetadata *gcpMetadataAuthPlugin `json:"gcp_metadata,omitempty"` - Plugin *string `json:"plugin,omitempty"` + Bearer *bearerAuthPlugin `json:"bearer,omitempty"` + OAuth2 *oauth2ClientCredentialsAuthPlugin `json:"oauth2,omitempty"` + ClientTLS *clientTLSAuthPlugin `json:"client_tls,omitempty"` + S3Signing *awsSigningAuthPlugin `json:"s3_signing,omitempty"` + GCPMetadata *gcpMetadataAuthPlugin `json:"gcp_metadata,omitempty"` + AzureManagedIdentity *azureManagedIdentitiesAuthPlugin `json:"azure_managed_identity,omitempty"` + Plugin *string `json:"plugin,omitempty"` } `json:"credentials"` keys map[string]*keys.Config logger logging.Logger