Repository: amp-labs/connectors
Pull request #1609 (2025-04-30 00:46:03)

[CON-1667] feat(aws): Read
# Notes

* No max page cap is specified. Exceeding this value for the service will return an error. Each service has its own internal cap.
* Token based pagination is natural to CLI and SDK. Pagination is implemented.
* There is no incremental reading.
* Found out that NextPage token is temporary. After some time it becomes invalid. This has no impact on functionality.

# Testing
## Groups
From `identitystore` service.
![image](https://github.com/user-attachments/assets/d9b74ba3-ba21-49c8-8fdb-82113025e8e7)
## Users
From `identitystore` service.
![image](https://github.com/user-attachments/assets/3dca3e52-c01f-4658-aa42-5ca7096c59cf)
## Instances
From `sso-admin` service.
![image](https://github.com/user-attachments/assets/27e40259-7eab-45fe-b0b9-2502b2ccaf36)
Next page is empty but valid.
![image](https://github.com/user-attachments/assets/cac0a754-f253-4fb5-b27a-7f91c8036abd)


# Objects
Reading each and every possible object.
* ✅ -- means object returns data
* Empty output -- hypothetically it can return data.
* error -- issues with particular Identity Center instance setup.
![image](https://github.com/user-attachments/assets/74279c87-7acd-45e2-a81c-e6bc0f1a875b)



Cobalt0s: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1609?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#1609** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1609?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1609?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1608** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1608?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1603** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1603?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#1604](https://github.com/amp-labs/connectors/pull/1604) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1604?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#1378** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1378?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/go.mod b/go.mod
index 38113968..add93e76 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/chromedp/chromedp v0.13.6
 	github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168
 	github.com/gertd/go-pluralize v0.2.1
-	github.com/getkin/kin-openapi v0.131.0
+	github.com/getkin/kin-openapi v0.132.0
 	github.com/go-playground/validator v9.31.0+incompatible
 	github.com/go-test/deep v1.1.1
 	github.com/gobwas/glob v0.2.3
diff --git a/go.sum b/go.sum
index 09f0ecf2..1d96f03d 100644
--- a/go.sum
+++ b/go.sum
@@ -53,8 +53,8 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
 github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
-github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE=
-github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
+github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
+github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
 github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 h1:yE7argOs92u+sSCRgqqe6eF+cDaVhSPlioy1UkA0p/w=
 github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
diff --git a/internal/datautils/merge.go b/internal/datautils/merge.go
index d15f8866..6b9687d9 100644
--- a/internal/datautils/merge.go
+++ b/internal/datautils/merge.go
@@ -38,3 +38,15 @@ func MergeUniqueLists[ID, V comparable](lists ...UniqueLists[ID, V]) UniqueLists
 
 	return result
 }
+
+func MergeSets[T comparable](sets ...Set[T]) Set[T] {
+	result := NewSet[T]()
+
+	for _, set := range sets {
+		for value := range set {
+			result.AddOne(value)
+		}
+	}
+
+	return result
+}
diff --git a/providers/aws/connector.go b/providers/aws/connector.go
index 500bda7b..71d16bfa 100644
--- a/providers/aws/connector.go
+++ b/providers/aws/connector.go
@@ -4,8 +4,12 @@ import (
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
 	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/aws/internal/core"
 )
 
 type Connector struct {
@@ -40,7 +44,6 @@ func NewConnector(params common.Parameters) (*Connector, error) {
 		return nil, err
 	}
 
-	// TODO this should be resolved by the ProviderInfo initialization.
 	conn.region = params.Metadata["region"]
 	conn.identityStoreId = params.Metadata["identityStoreId"]
 	conn.instanceARN = params.Metadata["instanceARN"]
@@ -61,6 +64,28 @@ func constructor(base *components.Connector, expectedMetadataKeys []string) (*Co
 		},
 	}
 
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	errorHandler := interpreter.ErrorHandler{
+		Custom: map[interpreter.Mime]interpreter.FaultyResponseHandler{
+			core.Mime: interpreter.NewFaultyResponder(errorFormats, nil),
+		},
+	}.Handle
+
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
 	return connector, nil
 }
 
diff --git a/providers/aws/errors.go b/providers/aws/errors.go
new file mode 100644
index 00000000..65f1b2ee
--- /dev/null
+++ b/providers/aws/errors.go
@@ -0,0 +1,26 @@
+package aws
+
+import (
+	"fmt"
+
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+var errorFormats = interpreter.NewFormatSwitch( // nolint:gochecknoglobals
+	[]interpreter.FormatTemplate{
+		{
+			MustKeys: nil,
+			Template: func() interpreter.ErrorDescriptor { return &ResponseError{} },
+		},
+	}...,
+)
+
+// nolint:tagliatelle
+type ResponseError struct {
+	Type    string `json:"__type"`
+	Message string `json:"message"`
+}
+
+func (r ResponseError) CombineErr(base error) error {
+	return fmt.Errorf("%w: %v", base, r.Message)
+}
diff --git a/providers/aws/internal/core/requests.go b/providers/aws/internal/core/requests.go
index 413c708d..755be83f 100644
--- a/providers/aws/internal/core/requests.go
+++ b/providers/aws/internal/core/requests.go
@@ -15,13 +15,11 @@ import (
 const (
 	// Mime https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-making-api-requests-json.html#sqs-api-constructing-endpoints-json
 	Mime = "application/x-amz-json-1.1"
-	// MaxPageSize https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-pagination.html
-	MaxPageSize = 1000
 )
 
 // nolint:tagliatelle
 type ReadPayload struct {
-	MaxResults int     `json:"MaxResults"`
+	MaxResults *int    `json:"MaxResults,omitempty"`
 	NextToken  *string `json:"NextToken,omitempty"`
 }
 
@@ -32,8 +30,7 @@ func NewReadPayload(params common.ReadParams) *ReadPayload {
 	}
 
 	return &ReadPayload{
-		MaxResults: MaxPageSize,
-		NextToken:  nextToken,
+		NextToken: nextToken,
 	}
 }
 
diff --git a/providers/aws/internal/identitystore/objects.go b/providers/aws/internal/identitystore/objects.go
new file mode 100644
index 00000000..dd2e4d28
--- /dev/null
+++ b/providers/aws/internal/identitystore/objects.go
@@ -0,0 +1,17 @@
+// nolint:gochecknoglobals
+package identitystore
+
+import (
+	"github.com/amp-labs/connectors/internal/datautils"
+)
+
+const (
+	ServiceName        = "AWSIdentityStore"
+	ServiceDomain      = "identitystore"
+	ServiceSigningName = "identitystore"
+)
+
+var ReadObjectCommands = datautils.Map[string, string]{
+	"Groups": "ListGroups",
+	"Users":  "ListUsers",
+}
diff --git a/providers/aws/internal/identitystore/read.go b/providers/aws/internal/identitystore/read.go
new file mode 100644
index 00000000..59e2abb8
--- /dev/null
+++ b/providers/aws/internal/identitystore/read.go
@@ -0,0 +1,26 @@
+package identitystore
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/aws/internal/core"
+)
+
+// nolint:tagliatelle
+type readPayload struct {
+	*core.ReadPayload
+	IdentityStoreID string `json:"IdentityStoreId"`
+}
+
+func ReadRequest(
+	ctx context.Context, params common.ReadParams, baseURL, identityStoreID string,
+) (*http.Request, error) {
+	command := core.FormatCommand(ServiceName, ReadObjectCommands, params.ObjectName)
+
+	return core.BuildRequest(ctx, baseURL, ServiceDomain, ServiceSigningName, command, readPayload{
+		ReadPayload:     core.NewReadPayload(params),
+		IdentityStoreID: identityStoreID,
+	})
+}
diff --git a/providers/aws/internal/identitystore/schemas.json b/providers/aws/internal/identitystore/schemas.json
index e0091c85..fa72c7c4 100644
--- a/providers/aws/internal/identitystore/schemas.json
+++ b/providers/aws/internal/identitystore/schemas.json
@@ -34,16 +34,6 @@
             "UserName": "User Name",
             "UserType": "User Type"
           }
-        },
-        "GroupMemberships": {
-          "displayName": "Group Memberships",
-          "responseKey": "GroupMemberships",
-          "fields": {
-            "IdentityStoreId": "Identity Store Id",
-            "GroupId": "Group Id",
-            "MemberId": "Member Id",
-            "MembershipId": "Membership Id"
-          }
         }
       }
     }
diff --git a/providers/aws/internal/ssoadmin/objects.go b/providers/aws/internal/ssoadmin/objects.go
new file mode 100644
index 00000000..fd27595a
--- /dev/null
+++ b/providers/aws/internal/ssoadmin/objects.go
@@ -0,0 +1,22 @@
+// nolint:gochecknoglobals
+package ssoadmin
+
+import (
+	"github.com/amp-labs/connectors/internal/datautils"
+)
+
+const (
+	ServiceName        = "SWBExternalService"
+	ServiceDomain      = "sso"
+	ServiceSigningName = "sso"
+)
+
+var ReadObjectCommands = datautils.Map[string, string]{
+	"Instances":                       "ListInstances",
+	"Applications":                    "ListApplications",
+	"ApplicationProviders":            "ListApplicationProviders",
+	"AccountAssignmentCreationStatus": "ListAccountAssignmentCreationStatus",
+	"AccountAssignmentDeletionStatus": "ListAccountAssignmentDeletionStatus",
+	"PermissionSetProvisioningStatus": "ListPermissionSetProvisioningStatus",
+	"TrustedTokenIssuers":             "ListTrustedTokenIssuers",
+}
diff --git a/providers/aws/internal/ssoadmin/read.go b/providers/aws/internal/ssoadmin/read.go
new file mode 100644
index 00000000..a5ea6965
--- /dev/null
+++ b/providers/aws/internal/ssoadmin/read.go
@@ -0,0 +1,26 @@
+package ssoadmin
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/aws/internal/core"
+)
+
+// nolint:tagliatelle
+type readPayload struct {
+	*core.ReadPayload
+	InstanceArn string `json:"InstanceArn"`
+}
+
+func ReadRequest(
+	ctx context.Context, params common.ReadParams, baseURL, instanceArn string,
+) (*http.Request, error) {
+	command := core.FormatCommand(ServiceName, ReadObjectCommands, params.ObjectName)
+
+	return core.BuildRequest(ctx, baseURL, ServiceDomain, ServiceSigningName, command, readPayload{
+		ReadPayload: core.NewReadPayload(params),
+		InstanceArn: instanceArn,
+	})
+}
diff --git a/providers/aws/requests.go b/providers/aws/requests.go
new file mode 100644
index 00000000..0eefaf7e
--- /dev/null
+++ b/providers/aws/requests.go
@@ -0,0 +1,26 @@
+package aws
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/aws/internal/identitystore"
+	"github.com/amp-labs/connectors/providers/aws/internal/ssoadmin"
+)
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	baseURL, err := c.getModuleURL()
+	if err != nil {
+		return nil, err
+	}
+
+	switch {
+	case identitystore.ReadObjectCommands.Has(params.ObjectName):
+		return identitystore.ReadRequest(ctx, params, baseURL, c.identityStoreId)
+	case ssoadmin.ReadObjectCommands.Has(params.ObjectName):
+		return ssoadmin.ReadRequest(ctx, params, baseURL, c.instanceARN)
+	default:
+		return nil, common.ErrObjectNotSupported
+	}
+}
diff --git a/providers/aws/responses.go b/providers/aws/responses.go
new file mode 100644
index 00000000..ae86b312
--- /dev/null
+++ b/providers/aws/responses.go
@@ -0,0 +1,29 @@
+package aws
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	recordsLocation := params.ObjectName
+
+	return common.ParseResult(
+		response,
+		common.ExtractOptionalRecordsFromPath(recordsLocation),
+		func(node *ajson.Node) (string, error) {
+			return jsonquery.New(node).StrWithDefault("NextToken", "")
+		},
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
diff --git a/providers/aws/supports.go b/providers/aws/supports.go
new file mode 100644
index 00000000..d7f2ba7f
--- /dev/null
+++ b/providers/aws/supports.go
@@ -0,0 +1,27 @@
+package aws
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/aws/internal/identitystore"
+	"github.com/amp-labs/connectors/providers/aws/internal/ssoadmin"
+)
+
+func supportedOperations() components.EndpointRegistryInput {
+	return components.EndpointRegistryInput{
+		providers.ModuleAWSIdentityCenter: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(
+					datautils.MergeSets(
+						identitystore.ReadObjectCommands.KeySet(),
+						ssoadmin.ReadObjectCommands.KeySet(),
+					).List(), ",")),
+				Support: components.ReadSupport,
+			},
+		},
+	}
+}


Test patch:
diff --git a/providers/aws/metadata_test.go b/providers/aws/metadata_test.go
index 6f8f9f34..1b25aae3 100644
--- a/providers/aws/metadata_test.go
+++ b/providers/aws/metadata_test.go
@@ -101,15 +101,21 @@ func constructTestConnector(serverURL string) (*Connector, error) {
 		AuthenticatedClient: http.DefaultClient,
 		Metadata: map[string]string{
 			"region":          "test-region",
-			"identityStoreID": "test-identity-store-id",
-			"instanceArn":     "test-instance-arn",
+			"identityStoreId": "test-identity-store-id",
+			"instanceARN":     "test-instance-arn",
 		},
 	})
 	if err != nil {
 		return nil, err
 	}
 
-	connector.SetBaseURL(serverURL)
+	moduleInfo := (*connector.ProviderInfo().Modules)[providers.ModuleAWSIdentityCenter]
+
+	moduleInfo.BaseURL = serverURL
+
+	connector.ProviderInfo().Modules = &providers.Modules{
+		providers.ModuleAWSIdentityCenter: moduleInfo,
+	}
 
 	return connector, nil
 }
diff --git a/providers/aws/read_test.go b/providers/aws/read_test.go
new file mode 100644
index 00000000..0fab0171
--- /dev/null
+++ b/providers/aws/read_test.go
@@ -0,0 +1,127 @@
+package aws
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/aws/internal/core"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	responseErrorFormat := testutils.DataFromFile(t, "read/err-not-supported.json")
+	responseGroups := testutils.DataFromFile(t, "read/groups.json")
+	responseInstances := testutils.DataFromFile(t, "read/instances.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Read object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is requested",
+			Input:        common.ReadParams{ObjectName: "Groups"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:  "Correct error message is understood from JSON response",
+			Input: common.ReadParams{ObjectName: "Groups", Fields: connectors.Fields("id")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentMIME(core.Mime),
+				Always: mockserver.Response(http.StatusBadRequest, responseErrorFormat),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("The operation is not supported for this Identity Center instance"), // nolint:goerr113
+			},
+		},
+		{
+			Name: "Service IdentityStore returns Groups",
+			Input: common.ReadParams{
+				ObjectName: "Groups",
+				Fields:     connectors.Fields("GroupId", "DisplayName"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentMIME(core.Mime),
+				If: mockcond.And{
+					mockcond.Header(http.Header{
+						"X-Amz-Target": []string{"AWSIdentityStore.ListGroups"},
+					}),
+					mockcond.Body(`{"IdentityStoreId":"test-identity-store-id"}`),
+				},
+				Then: mockserver.Response(http.StatusOK, responseGroups),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"groupid":     "01cbe580-e041-7044-a154-832eab370f7a",
+						"displayname": "Engineering",
+					},
+					Raw: map[string]any{
+						"IdentityStoreId": "d-9a670e6550",
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Service SSO-Admin returns Instances",
+			Input: common.ReadParams{
+				ObjectName: "Instances",
+				Fields:     connectors.Fields("InstanceArn"),
+				NextPage:   "somePreviousToken",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentMIME(core.Mime),
+				If: mockcond.And{
+					mockcond.Header(http.Header{
+						"X-Amz-Target": []string{"SWBExternalService.ListInstances"},
+					}),
+					mockcond.Body(`{"NextToken":"somePreviousToken","InstanceArn":"test-instance-arn"}`),
+				},
+				Then: mockserver.Response(http.StatusOK, responseInstances),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"instancearn": "arn:aws:sso:::instance/ssoins-668432c2a02ced8f",
+					},
+					Raw: map[string]any{
+						"IdentityStoreId": "d-9a670e6550",
+						"OwnerAccountId":  "471112904037",
+					},
+				}},
+				NextPage: "AAMA-EFRSURBSGg1SWlhM3N2aTFLVjl0K1k4eDhHaGxDWTlZdnNmRldtUFl5b2hXeDVZVnV3RmVBL0wwS2pGRm1BQkVxYnhGQm9VaEFBQUFmakI4QmdrcWhraUc5dzBCQndhZ2J6QnRBZ0VBTUdnR0NTcUdTSWIzRFFFSEFUQWVCZ2xnaGtnQlpRTUVBUzR3RVFRTXArK0JMVERpOVBXcDQwWFVBZ0VRZ0R0dEtDRjBnS0xXaHhBQUkxdGlnOE9sT3c5K1ZDMHFVcS9TTkZPMWJpc1V6MlJFSkFuREhqTjA1aHQ2dGF2YXNvb0JDZi8rOEhSSzZhcEozQT091otgI5gnLV-Huqrp8MTjDuPmttTgsLqqh9S6A29al92lcQ9Wz2Z6PaKJM7VbkFZm78S1HC_gEIbp2Ot8OUCcgEkX_Z6cgMHo0XFfOr84", // nolint:lll
+				Done:     false,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/aws/test/read/err-not-supported.json b/providers/aws/test/read/err-not-supported.json
new file mode 100644
index 00000000..40af688e
--- /dev/null
+++ b/providers/aws/test/read/err-not-supported.json
@@ -0,0 +1,4 @@
+{
+  "__type": "ValidationException",
+  "Message": "The operation is not supported for this Identity Center instance"
+}
diff --git a/providers/aws/test/read/groups.json b/providers/aws/test/read/groups.json
new file mode 100644
index 00000000..89bcd905
--- /dev/null
+++ b/providers/aws/test/read/groups.json
@@ -0,0 +1,9 @@
+{
+  "Groups": [
+    {
+      "DisplayName": "Engineering",
+      "GroupId": "01cbe580-e041-7044-a154-832eab370f7a",
+      "IdentityStoreId": "d-9a670e6550"
+    }
+  ]
+}
diff --git a/providers/aws/test/read/instances.json b/providers/aws/test/read/instances.json
new file mode 100644
index 00000000..1728d8f1
--- /dev/null
+++ b/providers/aws/test/read/instances.json
@@ -0,0 +1,15 @@
+{
+  "Instances": [
+    {
+      "CreatedDate": 1.706125503668E9,
+      "IdentityStoreId": "d-9a670e6550",
+      "InstanceArn": "arn:aws:sso:::instance/ssoins-668432c2a02ced8f",
+      "OwnerAccountId": "471112904037",
+      "State": {
+        "Name": "ACTIVE"
+      },
+      "Status": "ACTIVE"
+    }
+  ],
+  "NextToken": "AAMA-EFRSURBSGg1SWlhM3N2aTFLVjl0K1k4eDhHaGxDWTlZdnNmRldtUFl5b2hXeDVZVnV3RmVBL0wwS2pGRm1BQkVxYnhGQm9VaEFBQUFmakI4QmdrcWhraUc5dzBCQndhZ2J6QnRBZ0VBTUdnR0NTcUdTSWIzRFFFSEFUQWVCZ2xnaGtnQlpRTUVBUzR3RVFRTXArK0JMVERpOVBXcDQwWFVBZ0VRZ0R0dEtDRjBnS0xXaHhBQUkxdGlnOE9sT3c5K1ZDMHFVcS9TTkZPMWJpc1V6MlJFSkFuREhqTjA1aHQ2dGF2YXNvb0JDZi8rOEhSSzZhcEozQT091otgI5gnLV-Huqrp8MTjDuPmttTgsLqqh9S6A29al92lcQ9Wz2Z6PaKJM7VbkFZm78S1HC_gEIbp2Ot8OUCcgEkX_Z6cgMHo0XFfOr84"
+}
diff --git a/test/aws/read/all/main.go b/test/aws/read/all/main.go
new file mode 100644
index 00000000..b40b1277
--- /dev/null
+++ b/test/aws/read/all/main.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+	connTest "github.com/amp-labs/connectors/test/aws"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetAWSConnector(ctx, providers.ModuleAWSIdentityCenter)
+
+	objects := []string{
+		"Instances",
+		"Applications",
+		"ApplicationProviders",
+		"AccountAssignmentCreationStatus",
+		"AccountAssignmentDeletionStatus",
+		"PermissionSetProvisioningStatus",
+		"TrustedTokenIssuers",
+		"Groups",
+		"Users",
+	}
+
+	for _, objectName := range objects {
+		res, err := conn.Read(ctx, common.ReadParams{
+			ObjectName: objectName,
+			Fields:     connectors.Fields("id"),
+		})
+		if err != nil {
+			fmt.Println("error", err.Error(), "object", objectName)
+
+			continue
+		}
+
+		if len(res.Data) == 0 {
+			fmt.Println("Empty output", objectName)
+		} else {
+			fmt.Println("✅", objectName)
+		}
+	}
+}
diff --git a/test/aws/read/groups/main.go b/test/aws/read/groups/main.go
new file mode 100644
index 00000000..89a813f1
--- /dev/null
+++ b/test/aws/read/groups/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+	connTest "github.com/amp-labs/connectors/test/aws"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetAWSConnector(ctx, providers.ModuleAWSIdentityCenter)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "Groups",
+		Fields:     connectors.Fields("id"),
+	})
+	if err != nil {
+		utils.Fail("error reading from provider", "error", err)
+	}
+
+	slog.Info("Reading")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/aws/read/instances/main.go b/test/aws/read/instances/main.go
new file mode 100644
index 00000000..b3634a2e
--- /dev/null
+++ b/test/aws/read/instances/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+	connTest "github.com/amp-labs/connectors/test/aws"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetAWSConnector(ctx, providers.ModuleAWSIdentityCenter)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "Instances",
+		Fields:     connectors.Fields("id"),
+		// NextPage:   "AAMA-EFRSURBSGg1SWlhM3N2aTFLVjl0K1k4eDhHaGxDWTlZdnNmRldtUFl5b2hXeDVZVnV3R242YkxCYmQ1d0RvNTRKM0NiRkVkQkFBQUFmakI4QmdrcWhraUc5dzBCQndhZ2J6QnRBZ0VBTUdnR0NTcUdTSWIzRFFFSEFUQWVCZ2xnaGtnQlpRTUVBUzR3RVFRTXVnWkVGT3M0bjlZWHRuTm9BZ0VRZ0R1blRlbmpkbnA2VnNQalpiU21nWk9QWjJDL0t3dER3WXZ3ZUdWZWt2SWtsVnBkMStIZzZnYTJJSkF5QURRZXA1bkgyMTM2d01UZkVEVE1sUT092onOxjWINLw5lr0v_JkanYWmoBgMMoJvjQ1QgMEMateuHFBalNp6fZJbFQagXQGGUI0D_KoV6k_qoNenHbmQtY_eL3RWQitx-CNgRvxJ",
+	})
+	if err != nil {
+		utils.Fail("error reading from provider", "error", err)
+	}
+
+	slog.Info("Reading")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/aws/read/users/main.go b/test/aws/read/users/main.go
new file mode 100644
index 00000000..2b3fa00b
--- /dev/null
+++ b/test/aws/read/users/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+	connTest "github.com/amp-labs/connectors/test/aws"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetAWSConnector(ctx, providers.ModuleAWSIdentityCenter)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "Users",
+		Fields:     connectors.Fields("id"),
+	})
+	if err != nil {
+		utils.Fail("error reading from provider", "error", err)
+	}
+
+	slog.Info("Reading")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/utils/testroutines/comparator.go b/test/utils/testroutines/comparator.go
index f95aeeef..df263837 100644
--- a/test/utils/testroutines/comparator.go
+++ b/test/utils/testroutines/comparator.go
@@ -20,9 +20,12 @@ type Comparator[Output any] func(serverURL string, actual, expected Output) bool
 // This is convenient for cases where the returned data is large,
 // allowing for a more concise test that still validates the desired behavior.
 func ComparatorSubsetRead(serverURL string, actual, expected *common.ReadResult) bool {
-	return mockutils.ReadResultComparator.SubsetFields(actual, expected) &&
-		mockutils.ReadResultComparator.SubsetRaw(actual, expected) &&
-		ComparatorPagination(serverURL, actual, expected)
+	a := mockutils.ReadResultComparator.SubsetFields(actual, expected)
+	b := mockutils.ReadResultComparator.SubsetRaw(actual, expected)
+	c := ComparatorPagination(serverURL, actual, expected)
+	return a &&
+		b &&
+		c
 }
 
 // ComparatorPagination will check pagination related fields.