Repository: cloudflare/cloudflare-go
Pull request #1956 (2024-05-06 18:00:19)

Add support for access reusable policies
1. Adds support for reusable access policies. Reusable policies are not bound to a single access application, but can instead be applied to multiple apps. 
2. Allows setting the 'policies' array field in access apps update/create request. This defines the order of which policies apply to an access application.
3. Return the access policies currently in use for an application under the application response JSON.

## Description
Reusable access policies have been a requested feature for a while now, the

## Has your change been tested?
Tested manually and with terraform in staging.

## Screenshots (if appropriate):

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [X] My code follows the code style of this project.
- [X] My change requires a change to the documentation.
- [X] I have updated the documentation accordingly. - PENDING RELEASE
- [X] I have added tests to cover my changes.
- [X] All new and existing tests passed.
- [X] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/


github-actions[bot]: Oops! It looks like no changelog entry is attached to this PR. Please include a release note as described in https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md.

Example: 

~~~
```release-note:TYPE
Release note
```
~~~

If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label.

Patch:
diff --git a/.changelog/1956.txt b/.changelog/1956.txt
new file mode 100644
index 000000000..2f149db7d
--- /dev/null
+++ b/.changelog/1956.txt
@@ -0,0 +1,7 @@
+```release-note:enhancement
+access_policy: add support for reusable policies
+```
+
+```release-note:enhancement
+access_application: add support for `policies` array
+```
\ No newline at end of file
diff --git a/access_application.go b/access_application.go
index aeca100dd..27f0c3f91 100644
--- a/access_application.go
+++ b/access_application.go
@@ -58,6 +58,7 @@ type AccessApplication struct {
 	CustomPages              []string                       `json:"custom_pages,omitempty"`
 	Tags                     []string                       `json:"tags,omitempty"`
 	SCIMConfig               *AccessApplicationSCIMConfig   `json:"scim_config,omitempty"`
+	Policies                 []AccessPolicy                 `json:"policies,omitempty"`
 	AccessAppLauncherCustomization
 }
 
@@ -277,6 +278,8 @@ type CreateAccessApplicationParams struct {
 	CustomPages              []string                       `json:"custom_pages,omitempty"`
 	Tags                     []string                       `json:"tags,omitempty"`
 	SCIMConfig               *AccessApplicationSCIMConfig   `json:"scim_config,omitempty"`
+	// List of policy ids to link to this application in ascending order of precedence.
+	Policies []string `json:"policies,omitempty"`
 	AccessAppLauncherCustomization
 }
 
@@ -310,6 +313,10 @@ type UpdateAccessApplicationParams struct {
 	CustomPages              []string                       `json:"custom_pages,omitempty"`
 	Tags                     []string                       `json:"tags,omitempty"`
 	SCIMConfig               *AccessApplicationSCIMConfig   `json:"scim_config,omitempty"`
+	// List of policy ids to link to this application in ascending order of precedence.
+	// Can reference reusable policies and policies specific to this application.
+	// If this field is not provided, the existing policies will not be modified.
+	Policies *[]string `json:"policies,omitempty"`
 	AccessAppLauncherCustomization
 }
 
diff --git a/access_policy.go b/access_policy.go
index 0e1ae41c6..a70ceede5 100644
--- a/access_policy.go
+++ b/access_policy.go
@@ -2,7 +2,6 @@ package cloudflare
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"time"
@@ -10,10 +9,6 @@ import (
 	"github.com/goccy/go-json"
 )
 
-var (
-	ErrMissingApplicationID = errors.New("missing required application ID")
-)
-
 type AccessApprovalGroup struct {
 	EmailListUuid   string   `json:"email_list_uuid,omitempty"`
 	EmailAddresses  []string `json:"email_addresses,omitempty"`
@@ -23,11 +18,16 @@ type AccessApprovalGroup struct {
 // AccessPolicy defines a policy for allowing or disallowing access to
 // one or more Access applications.
 type AccessPolicy struct {
-	ID         string     `json:"id,omitempty"`
+	ID string `json:"id,omitempty"`
+	// Precedence is the order in which the policy is executed in an Access application.
+	// As a general rule, lower numbers take precedence over higher numbers.
+	// This field can only be zero when a reusable policy is requested outside the context
+	// of an Access application.
 	Precedence int        `json:"precedence"`
 	Decision   string     `json:"decision"`
 	CreatedAt  *time.Time `json:"created_at"`
 	UpdatedAt  *time.Time `json:"updated_at"`
+	Reusable   *bool      `json:"reusable,omitempty"`
 	Name       string     `json:"name"`
 
 	IsolationRequired            *bool                 `json:"isolation_required,omitempty"`
@@ -68,18 +68,28 @@ type AccessPolicyDetailResponse struct {
 }
 
 type ListAccessPoliciesParams struct {
+	// ApplicationID is the application ID to list attached access policies for.
+	// If omitted, only reusable policies for the account are returned.
 	ApplicationID string `json:"-"`
 	ResultInfo
 }
 
 type GetAccessPolicyParams struct {
+	PolicyID string `json:"-"`
+	// ApplicationID is the application ID for which to scope the policy for.
+	// Optional, but if included, the policy returned will include its execution precedence within the application.
 	ApplicationID string `json:"-"`
-	PolicyID      string `json:"-"`
 }
 
 type CreateAccessPolicyParams struct {
+	// ApplicationID is the application ID for which to create the policy for.
+	// Pass an empty value to create a reusable policy.
 	ApplicationID string `json:"-"`
 
+	// Precedence is the order in which the policy is executed in an Access application.
+	// As a general rule, lower numbers take precedence over higher numbers.
+	// This field is ignored when creating a reusable policy.
+	// Read more here https://developers.cloudflare.com/cloudflare-one/policies/access/#order-of-execution
 	Precedence int    `json:"precedence"`
 	Decision   string `json:"decision"`
 	Name       string `json:"name"`
@@ -105,9 +115,14 @@ type CreateAccessPolicyParams struct {
 }
 
 type UpdateAccessPolicyParams struct {
+	// ApplicationID is the application ID that owns the existing policy.
+	// Pass an empty value if the existing policy is reusable.
 	ApplicationID string `json:"-"`
 	PolicyID      string `json:"-"`
 
+	// Precedence is the order in which the policy is executed in an Access application.
+	// As a general rule, lower numbers take precedence over higher numbers.
+	// This field is ignored when updating a reusable policy.
 	Precedence int    `json:"precedence"`
 	Decision   string `json:"decision"`
 	Name       string `json:"name"`
@@ -133,26 +148,33 @@ type UpdateAccessPolicyParams struct {
 }
 
 type DeleteAccessPolicyParams struct {
+	// ApplicationID is the application ID the policy belongs to.
+	// If the existing policy is reusable, this field must be omitted. Otherwise, it is required.
 	ApplicationID string `json:"-"`
 	PolicyID      string `json:"-"`
 }
 
-// ListAccessPolicies returns all access policies for an access application.
+// ListAccessPolicies returns all access policies that match the parameters.
 //
 // Account API reference: https://developers.cloudflare.com/api/operations/access-policies-list-access-policies
 // Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-list-access-policies
 func (api *API) ListAccessPolicies(ctx context.Context, rc *ResourceContainer, params ListAccessPoliciesParams) ([]AccessPolicy, *ResultInfo, error) {
-	if params.ApplicationID == "" {
-		return []AccessPolicy{}, &ResultInfo{}, ErrMissingApplicationID
+	var baseURL string
+	if params.ApplicationID != "" {
+		baseURL = fmt.Sprintf(
+			"/%s/%s/access/apps/%s/policies",
+			rc.Level,
+			rc.Identifier,
+			params.ApplicationID,
+		)
+	} else {
+		baseURL = fmt.Sprintf(
+			"/%s/%s/access/policies",
+			rc.Level,
+			rc.Identifier,
+		)
 	}
 
-	baseURL := fmt.Sprintf(
-		"/%s/%s/access/apps/%s/policies",
-		rc.Level,
-		rc.Identifier,
-		params.ApplicationID,
-	)
-
 	autoPaginate := true
 	if params.PerPage >= 1 || params.Page >= 1 {
 		autoPaginate = false
@@ -194,13 +216,23 @@ func (api *API) ListAccessPolicies(ctx context.Context, rc *ResourceContainer, p
 // Account API reference: https://developers.cloudflare.com/api/operations/access-policies-get-an-access-policy
 // Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-get-an-access-policy
 func (api *API) GetAccessPolicy(ctx context.Context, rc *ResourceContainer, params GetAccessPolicyParams) (AccessPolicy, error) {
-	uri := fmt.Sprintf(
-		"/%s/%s/access/apps/%s/policies/%s",
-		rc.Level,
-		rc.Identifier,
-		params.ApplicationID,
-		params.PolicyID,
-	)
+	var uri string
+	if params.ApplicationID != "" {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/apps/%s/policies/%s",
+			rc.Level,
+			rc.Identifier,
+			params.ApplicationID,
+			params.PolicyID,
+		)
+	} else {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/policies/%s",
+			rc.Level,
+			rc.Identifier,
+			params.PolicyID,
+		)
+	}
 
 	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
 	if err != nil {
@@ -221,12 +253,21 @@ func (api *API) GetAccessPolicy(ctx context.Context, rc *ResourceContainer, para
 // Account API reference: https://developers.cloudflare.com/api/operations/access-policies-create-an-access-policy
 // Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-create-an-access-policy
 func (api *API) CreateAccessPolicy(ctx context.Context, rc *ResourceContainer, params CreateAccessPolicyParams) (AccessPolicy, error) {
-	uri := fmt.Sprintf(
-		"/%s/%s/access/apps/%s/policies",
-		rc.Level,
-		rc.Identifier,
-		params.ApplicationID,
-	)
+	var uri string
+	if params.ApplicationID != "" {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/apps/%s/policies",
+			rc.Level,
+			rc.Identifier,
+			params.ApplicationID,
+		)
+	} else {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/policies",
+			rc.Level,
+			rc.Identifier,
+		)
+	}
 
 	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
 	if err != nil {
@@ -251,13 +292,23 @@ func (api *API) UpdateAccessPolicy(ctx context.Context, rc *ResourceContainer, p
 		return AccessPolicy{}, fmt.Errorf("access policy ID cannot be empty")
 	}
 
-	uri := fmt.Sprintf(
-		"/%s/%s/access/apps/%s/policies/%s",
-		rc.Level,
-		rc.Identifier,
-		params.ApplicationID,
-		params.PolicyID,
-	)
+	var uri string
+	if params.ApplicationID != "" {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/apps/%s/policies/%s",
+			rc.Level,
+			rc.Identifier,
+			params.ApplicationID,
+			params.PolicyID,
+		)
+	} else {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/policies/%s",
+			rc.Level,
+			rc.Identifier,
+			params.PolicyID,
+		)
+	}
 
 	res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params)
 	if err != nil {
@@ -278,13 +329,23 @@ func (api *API) UpdateAccessPolicy(ctx context.Context, rc *ResourceContainer, p
 // Account API reference: https://developers.cloudflare.com/api/operations/access-policies-delete-an-access-policy
 // Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-delete-an-access-policy
 func (api *API) DeleteAccessPolicy(ctx context.Context, rc *ResourceContainer, params DeleteAccessPolicyParams) error {
-	uri := fmt.Sprintf(
-		"/%s/%s/access/apps/%s/policies/%s",
-		rc.Level,
-		rc.Identifier,
-		params.ApplicationID,
-		params.PolicyID,
-	)
+	var uri string
+	if params.ApplicationID != "" {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/apps/%s/policies/%s",
+			rc.Level,
+			rc.Identifier,
+			params.ApplicationID,
+			params.PolicyID,
+		)
+	} else {
+		uri = fmt.Sprintf(
+			"/%s/%s/access/policies/%s",
+			rc.Level,
+			rc.Identifier,
+			params.PolicyID,
+		)
+	}
 
 	_, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
 	if err != nil {


Test patch:
diff --git a/access_application_test.go b/access_application_test.go
index 7b8da1d63..11187ebb9 100644
--- a/access_application_test.go
+++ b/access_application_test.go
@@ -3,6 +3,7 @@ package cloudflare
 import (
 	"context"
 	"fmt"
+	"io"
 	"net/http"
 	"testing"
 	"time"
@@ -51,7 +52,25 @@ func TestAccessApplications(t *testing.T) {
 					"custom_pages": ["480f4f69-1a28-4fdd-9240-1ed29f0ac1dc"],
 					"tags": ["engineers"],
 					"allow_authenticate_via_warp": true,
-					"options_preflight_bypass": false
+					"options_preflight_bypass": false,
+					"policies": [
+						{
+							"id": "699d98642c564d2e855e9661899b7252",
+							"precedence": 1,
+							"reusable": true,
+							"decision": "allow",
+							"created_at": "2014-01-01T05:20:00.12345Z",
+							"updated_at": "2014-01-01T05:20:00.12345Z",
+							"name": "Allow devs",
+							"include": [
+								{
+									"email": {
+										"email": "test@example.com"
+									}
+								}
+							]
+						}
+					]
 				}
 			],
 			"result_info": {
@@ -93,6 +112,20 @@ func TestAccessApplications(t *testing.T) {
 		CustomNonIdentityDenyURL: "https://blocked.com",
 		AllowAuthenticateViaWarp: BoolPtr(true),
 		OptionsPreflightBypass:   BoolPtr(false),
+		Policies: []AccessPolicy{
+			{
+				ID:         "699d98642c564d2e855e9661899b7252",
+				Precedence: 1,
+				Reusable:   BoolPtr(true),
+				Decision:   "allow",
+				CreatedAt:  &createdAt,
+				UpdatedAt:  &updatedAt,
+				Name:       "Allow devs",
+				Include: []any{
+					map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}},
+				},
+			},
+		},
 	}}
 
 	mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler)
@@ -146,7 +179,25 @@ func TestAccessApplication(t *testing.T) {
 				"http_only_cookie_attribute": false,
 				"path_cookie_attribute": false,
 				"allow_authenticate_via_warp": false,
-				"options_preflight_bypass": false
+				"options_preflight_bypass": false,
+				"policies": [
+					{
+						"id": "699d98642c564d2e855e9661899b7252",
+						"precedence": 1,
+						"reusable": true,
+						"decision": "allow",
+						"created_at": "2014-01-01T05:20:00.12345Z",
+						"updated_at": "2014-01-01T05:20:00.12345Z",
+						"name": "Allow devs",
+						"include": [
+							{
+								"email": {
+									"email": "test@example.com"
+								}
+							}
+						]
+					}
+				]
 			}
 		}
 		`)
@@ -179,6 +230,20 @@ func TestAccessApplication(t *testing.T) {
 		CustomNonIdentityDenyURL: "https://blocked.com",
 		AllowAuthenticateViaWarp: BoolPtr(false),
 		OptionsPreflightBypass:   BoolPtr(false),
+		Policies: []AccessPolicy{
+			{
+				ID:         "699d98642c564d2e855e9661899b7252",
+				Precedence: 1,
+				Reusable:   BoolPtr(true),
+				Decision:   "allow",
+				CreatedAt:  &createdAt,
+				UpdatedAt:  &updatedAt,
+				Name:       "Allow devs",
+				Include: []any{
+					map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}},
+				},
+			},
+		},
 	}
 
 	mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler)
@@ -231,7 +296,25 @@ func TestCreateAccessApplications(t *testing.T) {
 				"service_auth_401_redirect": true,
 				"tags": ["engineers"],
 				"allow_authenticate_via_warp": false,
-				"options_preflight_bypass": true
+				"options_preflight_bypass": true,
+				"policies": [
+					{
+						"id": "699d98642c564d2e855e9661899b7252",
+						"precedence": 1,
+						"reusable": true,
+						"decision": "allow",
+						"created_at": "2014-01-01T05:20:00.12345Z",
+						"updated_at": "2014-01-01T05:20:00.12345Z",
+						"name": "Allow devs",
+						"include": [
+							{
+								"email": {
+									"email": "test@example.com"
+								}
+							}
+						]
+					}
+				]
 			}
 		}
 		`)
@@ -262,6 +345,20 @@ func TestCreateAccessApplications(t *testing.T) {
 		Tags:                     []string{"engineers"},
 		AllowAuthenticateViaWarp: BoolPtr(false),
 		OptionsPreflightBypass:   BoolPtr(true),
+		Policies: []AccessPolicy{
+			{
+				ID:         "699d98642c564d2e855e9661899b7252",
+				Precedence: 1,
+				Reusable:   BoolPtr(true),
+				Decision:   "allow",
+				CreatedAt:  &createdAt,
+				UpdatedAt:  &updatedAt,
+				Name:       "Allow devs",
+				Include: []any{
+					map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}},
+				},
+			},
+		},
 	}
 
 	mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler)
@@ -270,6 +367,7 @@ func TestCreateAccessApplications(t *testing.T) {
 		Name:            "Admin Site",
 		Domain:          "test.example.com/admin",
 		SessionDuration: "24h",
+		Policies:        []string{"699d98642c564d2e855e9661899b7252"},
 	})
 
 	if assert.NoError(t, err) {
@@ -282,6 +380,7 @@ func TestCreateAccessApplications(t *testing.T) {
 		Name:            "Admin Site",
 		Domain:          "test.example.com/admin",
 		SessionDuration: "24h",
+		Policies:        []string{"699d98642c564d2e855e9661899b7252"},
 	})
 
 	if assert.NoError(t, err) {
@@ -322,7 +421,25 @@ func TestUpdateAccessApplication(t *testing.T) {
 				"service_auth_401_redirect": true,
 				"tags": ["engineers"],
 				"allow_authenticate_via_warp": true,
-				"options_preflight_bypass": true
+				"options_preflight_bypass": true,
+				"policies": [
+					{
+						"id": "699d98642c564d2e855e9661899b7252",
+						"precedence": 1,
+						"reusable": true,
+						"decision": "allow",
+						"created_at": "2014-01-01T05:20:00.12345Z",
+						"updated_at": "2014-01-01T05:20:00.12345Z",
+						"name": "Allow devs",
+						"include": [
+							{
+								"email": {
+									"email": "test@example.com"
+								}
+							}
+						]
+					}
+				]
 			}
 		}
 		`)
@@ -351,6 +468,180 @@ func TestUpdateAccessApplication(t *testing.T) {
 		OptionsPreflightBypass:   BoolPtr(true),
 		CreatedAt:                &createdAt,
 		UpdatedAt:                &updatedAt,
+		Policies: []AccessPolicy{
+			{
+				ID:         "699d98642c564d2e855e9661899b7252",
+				Precedence: 1,
+				Reusable:   BoolPtr(true),
+				Decision:   "allow",
+				CreatedAt:  &createdAt,
+				UpdatedAt:  &updatedAt,
+				Name:       "Allow devs",
+				Include: []any{
+					map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}},
+				},
+			},
+		},
+	}
+
+	params := UpdateAccessApplicationParams{
+		ID:                       "480f4f69-1a28-4fdd-9240-1ed29f0ac1db",
+		Name:                     "Admin Site",
+		Domain:                   "test.example.com/admin",
+		SelfHostedDomains:        []string{"test.example.com/admin", "test.example.com/admin2"},
+		Type:                     "self_hosted",
+		SessionDuration:          "24h",
+		AUD:                      "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+		AllowedIdps:              []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"},
+		AutoRedirectToIdentity:   BoolPtr(false),
+		EnableBindingCookie:      BoolPtr(false),
+		AppLauncherVisible:       BoolPtr(true),
+		ServiceAuth401Redirect:   BoolPtr(true),
+		CustomDenyMessage:        "denied!",
+		CustomDenyURL:            "https://www.example.com",
+		LogoURL:                  "https://www.example.com/example.png",
+		SkipInterstitial:         BoolPtr(true),
+		CustomNonIdentityDenyURL: "https://blocked.com",
+		Tags:                     []string{"engineers"},
+		AllowAuthenticateViaWarp: BoolPtr(true),
+		OptionsPreflightBypass:   BoolPtr(true),
+		Policies:                 &[]string{"699d98642c564d2e855e9661899b7252"},
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler)
+
+	actual, err := client.UpdateAccessApplication(context.Background(), AccountIdentifier(testAccountID), params)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, fullAccessApplication, actual)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler)
+
+	actual, err = client.UpdateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), UpdateAccessApplicationParams{
+		ID:                       "480f4f69-1a28-4fdd-9240-1ed29f0ac1db",
+		Name:                     "Admin Site",
+		Domain:                   "test.example.com/admin",
+		SelfHostedDomains:        []string{"test.example.com/admin", "test.example.com/admin2"},
+		Type:                     "self_hosted",
+		SessionDuration:          "24h",
+		AUD:                      "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+		AllowedIdps:              []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"},
+		AutoRedirectToIdentity:   BoolPtr(false),
+		EnableBindingCookie:      BoolPtr(false),
+		AppLauncherVisible:       BoolPtr(true),
+		ServiceAuth401Redirect:   BoolPtr(true),
+		CustomDenyMessage:        "denied!",
+		CustomDenyURL:            "https://www.example.com",
+		LogoURL:                  "https://www.example.com/example.png",
+		SkipInterstitial:         BoolPtr(true),
+		CustomNonIdentityDenyURL: "https://blocked.com",
+		OptionsPreflightBypass:   BoolPtr(true),
+		Policies:                 &[]string{"699d98642c564d2e855e9661899b7252"},
+	})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, fullAccessApplication, actual)
+	}
+}
+
+func TestUpdateAccessApplicationOmitPolicies(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		reqBody, err := io.ReadAll(r.Body)
+		assert.NoError(t, err)
+		assert.NotContains(t, string(reqBody), "policies")
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+				"id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db",
+				"created_at": "2014-01-01T05:20:00.12345Z",
+				"updated_at": "2014-01-01T05:20:00.12345Z",
+				"aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+				"name": "Admin Site",
+				"domain": "test.example.com/admin",
+				"self_hosted_domains": ["test.example.com/admin", "test.example.com/admin2"],
+				"type": "self_hosted",
+				"session_duration": "24h",
+				"allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"],
+				"auto_redirect_to_identity": false,
+				"enable_binding_cookie": false,
+				"custom_deny_url": "https://www.example.com",
+				"custom_deny_message": "denied!",
+				"custom_non_identity_deny_url": "https://blocked.com",
+				"logo_url": "https://www.example.com/example.png",
+				"skip_interstitial": true,
+				"app_launcher_visible": true,
+				"service_auth_401_redirect": true,
+				"tags": ["engineers"],
+				"allow_authenticate_via_warp": true,
+				"options_preflight_bypass": true,
+				"policies": [
+					{
+						"id": "699d98642c564d2e855e9661899b7252",
+						"precedence": 1,
+						"reusable": true,
+						"decision": "allow",
+						"created_at": "2014-01-01T05:20:00.12345Z",
+						"updated_at": "2014-01-01T05:20:00.12345Z",
+						"name": "Allow devs",
+						"include": [
+							{
+								"email": {
+									"email": "test@example.com"
+								}
+							}
+						]
+					}
+				]
+			}
+		}
+		`)
+	}
+
+	fullAccessApplication := AccessApplication{
+		ID:                       "480f4f69-1a28-4fdd-9240-1ed29f0ac1db",
+		Name:                     "Admin Site",
+		Domain:                   "test.example.com/admin",
+		SelfHostedDomains:        []string{"test.example.com/admin", "test.example.com/admin2"},
+		Type:                     "self_hosted",
+		SessionDuration:          "24h",
+		AUD:                      "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+		AllowedIdps:              []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"},
+		AutoRedirectToIdentity:   BoolPtr(false),
+		EnableBindingCookie:      BoolPtr(false),
+		AppLauncherVisible:       BoolPtr(true),
+		ServiceAuth401Redirect:   BoolPtr(true),
+		CustomDenyMessage:        "denied!",
+		CustomDenyURL:            "https://www.example.com",
+		LogoURL:                  "https://www.example.com/example.png",
+		CustomNonIdentityDenyURL: "https://blocked.com",
+		Tags:                     []string{"engineers"},
+		SkipInterstitial:         BoolPtr(true),
+		AllowAuthenticateViaWarp: BoolPtr(true),
+		OptionsPreflightBypass:   BoolPtr(true),
+		CreatedAt:                &createdAt,
+		UpdatedAt:                &updatedAt,
+		Policies: []AccessPolicy{
+			{
+				ID:         "699d98642c564d2e855e9661899b7252",
+				Precedence: 1,
+				Reusable:   BoolPtr(true),
+				Decision:   "allow",
+				CreatedAt:  &createdAt,
+				UpdatedAt:  &updatedAt,
+				Name:       "Allow devs",
+				Include: []any{
+					map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}},
+				},
+			},
+		},
 	}
 
 	params := UpdateAccessApplicationParams{
diff --git a/access_policy_test.go b/access_policy_test.go
index 6b9506023..756421382 100644
--- a/access_policy_test.go
+++ b/access_policy_test.go
@@ -142,6 +142,23 @@ func TestAccessPolicies(t *testing.T) {
 	if assert.NoError(t, err) {
 		assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual)
 	}
+
+	// Test Listing reusable policies
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/policies", handler)
+
+	actual, _, err = client.ListAccessPolicies(context.Background(), testAccountRC, ListAccessPoliciesParams{})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/policies", handler)
+
+	actual, _, err = client.ListAccessPolicies(context.Background(), testZoneRC, ListAccessPoliciesParams{})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual)
+	}
 }
 
 func TestAccessPolicy(t *testing.T) {
@@ -218,6 +235,23 @@ func TestAccessPolicy(t *testing.T) {
 	if assert.NoError(t, err) {
 		assert.Equal(t, expectedAccessPolicy, actual)
 	}
+
+	// Test getting a reusable policy
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/policies/"+accessPolicyID, handler)
+
+	actual, err = client.GetAccessPolicy(context.Background(), testAccountRC, GetAccessPolicyParams{PolicyID: accessPolicyID})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedAccessPolicy, actual)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/policies/"+accessPolicyID, handler)
+
+	actual, err = client.GetAccessPolicy(context.Background(), testZoneRC, GetAccessPolicyParams{PolicyID: accessPolicyID})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedAccessPolicy, actual)
+	}
 }
 
 func TestCreateAccessPolicy(t *testing.T) {
@@ -328,6 +362,24 @@ func TestCreateAccessPolicy(t *testing.T) {
 	if assert.NoError(t, err) {
 		assert.Equal(t, expectedAccessPolicy, actual)
 	}
+
+	// Test creating a reusable policy
+	accessPolicy.ApplicationID = ""
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/policies", handler)
+
+	actual, err = client.CreateAccessPolicy(context.Background(), testAccountRC, accessPolicy)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedAccessPolicy, actual)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/policies", handler)
+
+	actual, err = client.CreateAccessPolicy(context.Background(), testZoneRC, accessPolicy)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedAccessPolicy, actual)
+	}
 }
 
 func TestCreateAccessPolicyAuthContextRule(t *testing.T) {
@@ -571,6 +623,22 @@ func TestUpdateAccessPolicy(t *testing.T) {
 	if assert.NoError(t, err) {
 		assert.Equal(t, expectedAccessPolicy, actual)
 	}
+
+	// Test updating reusable policies
+	accessPolicy.ApplicationID = ""
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/policies/"+accessPolicyID, handler)
+	actual, err = client.UpdateAccessPolicy(context.Background(), testAccountRC, accessPolicy)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedAccessPolicy, actual)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/policies/"+accessPolicyID, handler)
+	actual, err = client.UpdateAccessPolicy(context.Background(), testZoneRC, accessPolicy)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedAccessPolicy, actual)
+	}
 }
 
 func TestUpdateAccessPolicyWithMissingID(t *testing.T) {
@@ -611,4 +679,15 @@ func TestDeleteAccessPolicy(t *testing.T) {
 	err = client.DeleteAccessPolicy(context.Background(), testZoneRC, DeleteAccessPolicyParams{ApplicationID: accessApplicationID, PolicyID: accessPolicyID})
 
 	assert.NoError(t, err)
+
+	// Test deleting a reusable policy
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/policies/"+accessPolicyID, handler)
+	err = client.DeleteAccessPolicy(context.Background(), testAccountRC, DeleteAccessPolicyParams{PolicyID: accessPolicyID})
+
+	assert.NoError(t, err)
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/policies/"+accessPolicyID, handler)
+	err = client.DeleteAccessPolicy(context.Background(), testZoneRC, DeleteAccessPolicyParams{PolicyID: accessPolicyID})
+
+	assert.NoError(t, err)
 }