diff --git a/cmd/adapter/main.go b/cmd/adapter/main.go index 6d42f0e..3e6683c 100644 --- a/cmd/adapter/main.go +++ b/cmd/adapter/main.go @@ -14,6 +14,7 @@ import ( grpc_proxy_v1 "github.com/sgnl-ai/adapter-framework/pkg/grpc_proxy/v1" "github.com/sgnl-ai/adapter-framework/server" aws "github.com/sgnl-ai/adapters/pkg/aws" + awsidentitycenter "github.com/sgnl-ai/adapters/pkg/aws-identitycenter" aws_s3 "github.com/sgnl-ai/adapters/pkg/aws-s3" "github.com/sgnl-ai/adapters/pkg/azuread" "github.com/sgnl-ai/adapters/pkg/bamboohr" @@ -85,7 +86,7 @@ func main() { logger.Fatalf("Failed to create a datasource to query AWS S3: %v", err) } - // Initialize the client to fetch data from AWS. + // Initialize the client to fetch data from AWS IAM. awsClient, err := aws.NewClient( client.NewSGNLHTTPClientWithProxy(timeout, "sgnl-AWS/1.0.0", grpc_proxy_v1.NewProxyServiceClient(connectorServiceClient), @@ -95,8 +96,19 @@ func main() { logger.Fatalf("Failed to create a datasource to query AWS: %v", err) } + // Initialize the client to fetch data from AWS Identity Center. + awsICClient, err := awsidentitycenter.NewClient( + client.NewSGNLHTTPClientWithProxy(timeout, "sgnl-AWSIdentityCenter/1.0.0", + grpc_proxy_v1.NewProxyServiceClient(connectorServiceClient), + ), nil, + ) + if err != nil { + logger.Fatalf("Failed to create a datasource to query AWS: %v", err) + } + // Register adapters here alphabetically. server.RegisterAdapter(adapterServer, "AWS-1.0.0", aws.NewAdapter(awsClient)) + server.RegisterAdapter(adapterServer, "AWSIdentityCenter-1.0.0", awsidentitycenter.NewAdapter(awsICClient)) server.RegisterAdapter( adapterServer, "AzureAD-1.0.1", diff --git a/go.mod b/go.mod index de213c4..0339b53 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.29.14 github.com/aws/aws-sdk-go-v2/credentials v1.17.67 github.com/aws/aws-sdk-go-v2/service/iam v1.41.1 + github.com/aws/aws-sdk-go-v2/service/identitystore v1.28.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 + github.com/aws/aws-sdk-go-v2/service/ssoadmin v1.31.0 github.com/aws/smithy-go v1.22.3 github.com/bwmarrin/go-objectsid v0.0.0-20191126144531-5fee401a2f37 github.com/docker/go-connections v0.5.0 diff --git a/go.sum b/go.sum index 164560a..2a2a53c 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,9 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcu github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= github.com/aws/aws-sdk-go-v2/service/iam v1.41.1 h1:Kq3R+K49y23CGC5UQF3Vpw5oZEQk5gF/nn+MekPD0ZY= github.com/aws/aws-sdk-go-v2/service/iam v1.41.1/go.mod h1:mPJkGQzeCoPs82ElNILor2JzZgYENr4UaSKUT8K27+c= +github.com/aws/aws-sdk-go-v2/service/identitystore v1.26.0/go.mod h1:zVLejeKzvUdQD69k8ladCxzC7SnlG1EJwJloK21x/QM= +github.com/aws/aws-sdk-go-v2/service/identitystore v1.28.3 h1:zQMIlYXYHFzrurTKozpXFTGs0M5kwWgG8jL8EKGp8xg= +github.com/aws/aws-sdk-go-v2/service/identitystore v1.28.3/go.mod h1:7nGvrQXBNp7k5yYpwpmxGucYTPY39d0cxjmANAeWwYE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 h1:4nm2G6A4pV9rdlWzGMPv4BNtQp22v1hg3yrtkYpeLl8= @@ -53,6 +56,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6U github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k= github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssoadmin v1.31.0 h1:mBlGhCX5dS6Z1qUiVMrUFaAZiZZ5ARYTrKLOBGfq0EU= +github.com/aws/aws-sdk-go-v2/service/ssoadmin v1.31.0/go.mod h1:znVkl7Y14sZKEL/sbRQ6qgD8wj8VdTcVVQp5iRaKXcc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= diff --git a/pkg/aws-identitycenter/adapter.go b/pkg/aws-identitycenter/adapter.go new file mode 100644 index 0000000..30ca9ed --- /dev/null +++ b/pkg/aws-identitycenter/adapter.go @@ -0,0 +1,97 @@ +package awsidentitycenter + +import ( + "context" + "fmt" + "time" + + framework "github.com/sgnl-ai/adapter-framework" + api_adapter_v1 "github.com/sgnl-ai/adapter-framework/api/adapter/v1" + "github.com/sgnl-ai/adapter-framework/web" + "github.com/sgnl-ai/adapters/pkg/config" + "github.com/sgnl-ai/adapters/pkg/pagination" +) + +// Adapter implements the framework.Adapter interface to query pages of objects +// from datasources. +type Adapter struct { + Client Client +} + +// NewAdapter instantiates a new Adapter. +func NewAdapter(client Client) framework.Adapter[Config] { + return &Adapter{Client: client} +} + +// GetPage is called by SGNL's ingestion service to query a page of objects +// from a datasource. +func (a *Adapter) GetPage(ctx context.Context, request *framework.Request[Config]) framework.Response { + if err := a.ValidateGetPageRequest(ctx, request); err != nil { + return framework.NewGetPageResponseError(err) + } + + return a.RequestPageFromDatasource(ctx, request) +} + +// RequestPageFromDatasource requests a page of objects from a datasource. +func (a *Adapter) RequestPageFromDatasource( + ctx context.Context, request *framework.Request[Config], +) framework.Response { + var commonConfig *config.CommonConfig + if request.Config != nil { + commonConfig = request.Config.CommonConfig + } + + commonConfig = config.SetMissingCommonConfigDefaults(commonConfig) + + // Unmarshal the current cursor. + cursor, err := pagination.UnmarshalCursor[string](request.Cursor) + if err != nil { + return framework.NewGetPageResponseError(err) + } + + awsReq := &Request{ + Auth: Auth{ + AccessKey: request.Auth.Basic.Username, + SecretKey: request.Auth.Basic.Password, + Region: request.Config.Region, + }, + IdentityStoreID: request.Config.IdentityStoreID, + InstanceARN: request.Config.InstanceARN, + MaxResults: int32(request.PageSize), + EntityExternalID: request.Entity.ExternalId, + Cursor: cursor, + RequestTimeoutSeconds: *commonConfig.RequestTimeoutSeconds, + } + + resp, err := a.Client.GetPage(ctx, awsReq) + if err != nil { + return framework.NewGetPageResponseError(err) + } + + if adapterErr := web.HTTPError(resp.StatusCode, resp.RetryAfterHeader); adapterErr != nil { + return framework.NewGetPageResponseError(adapterErr) + } + + parsedObjects, parserErr := web.ConvertJSONObjectList( + &request.Entity, + resp.Objects, + web.WithJSONPathAttributeNames(), + web.WithLocalTimeZoneOffset(commonConfig.LocalTimeZoneOffset), + web.WithDateTimeFormats( + []web.DateTimeFormatWithTimeZone{{Format: time.RFC3339, HasTimeZone: true}}..., + ), + ) + if parserErr != nil { + return framework.NewGetPageResponseError( + &framework.Error{Message: fmt.Sprintf("Failed to convert datasource response objects: %v.", parserErr), Code: api_adapter_v1.ErrorCode_ERROR_CODE_INTERNAL}, + ) + } + + nextCursor, err := pagination.MarshalCursor(resp.NextCursor) + if err != nil { + return framework.NewGetPageResponseError(err) + } + + return framework.NewGetPageResponseSuccess(&framework.Page{Objects: parsedObjects, NextCursor: nextCursor}) +} diff --git a/pkg/aws-identitycenter/client.go b/pkg/aws-identitycenter/client.go new file mode 100644 index 0000000..ea350a5 --- /dev/null +++ b/pkg/aws-identitycenter/client.go @@ -0,0 +1,62 @@ +package awsidentitycenter + +import ( + "context" + + framework "github.com/sgnl-ai/adapter-framework" + "github.com/sgnl-ai/adapters/pkg/pagination" +) + +// Client is a client that allows querying the datasource which contains JSON objects. +type Client interface { + GetPage(ctx context.Context, request *Request) (*Response, *framework.Error) +} + +type Auth struct { + // AccessKey is the access key to authenticate with AWS. + AccessKey string + + // SecretKey is the secret key to authenticate with AWS. + SecretKey string + + // Region is the AWS region to query. + Region string +} + +// Request is a request to the datasource. +type Request struct { + Auth + + // IdentityStoreID is the AWS Identity Store identifier. + IdentityStoreID string + + // InstanceARN is the AWS Identity Center instance ARN. + InstanceARN string + + // MaxResults is the maximum number of objects to return from the entity. + MaxResults int32 + + // EntityExternalID is the external ID of the entity. + EntityExternalID string + + // Cursor identifies the first object of the page to return. + Cursor *pagination.CompositeCursor[string] + + // RequestTimeoutSeconds is the timeout duration for requests made to datasources. + RequestTimeoutSeconds int +} + +// Response is a response returned by the datasource. +type Response struct { + // StatusCode is an HTTP status code. + StatusCode int + + // RetryAfterHeader is the Retry-After response HTTP header, if set. + RetryAfterHeader string + + // Objects is the list of items returned by the datasource. + Objects []map[string]any + + // NextCursor is the cursor that identifies the first object of the next page. + NextCursor *pagination.CompositeCursor[string] +} diff --git a/pkg/aws-identitycenter/common_test.go b/pkg/aws-identitycenter/common_test.go new file mode 100644 index 0000000..5ed7774 --- /dev/null +++ b/pkg/aws-identitycenter/common_test.go @@ -0,0 +1,21 @@ +package awsidentitycenter_test + +import ( + framework "github.com/sgnl-ai/adapter-framework" + adapter "github.com/sgnl-ai/adapters/pkg/aws-identitycenter" +) + +var ( + validAuthCredentials = &framework.DatasourceAuthCredentials{ + Basic: &framework.BasicAuthCredentials{ + Username: "access", + Password: "secret", + }, + } + + validConfig = &adapter.Config{ + Region: "us-west-2", + IdentityStoreID: "d-1234567890", + InstanceARN: "arn:aws:sso:::instance/ssoins-1234567890", + } +) diff --git a/pkg/aws-identitycenter/config.go b/pkg/aws-identitycenter/config.go new file mode 100644 index 0000000..4a55634 --- /dev/null +++ b/pkg/aws-identitycenter/config.go @@ -0,0 +1,46 @@ +package awsidentitycenter + +import ( + "context" + "errors" + + "github.com/sgnl-ai/adapters/pkg/config" +) + +// Config is the configuration passed in each GetPage calls to the adapter. +// AWS Identity Center Adapter configuration example: +// +// { +// "region": "us-west-2", +// "identityStoreID": "d-1234567890", +// "instanceARN": "arn:aws:sso:::instance/ssoins-1234567890" +// } + +type Config struct { + *config.CommonConfig + + // Region is the AWS region to query. + Region string `json:"region"` + + // IdentityStoreID is the AWS Identity Store identifier. + IdentityStoreID string `json:"identityStoreID"` + + // InstanceARN is the AWS Identity Center instance ARN. + InstanceARN string `json:"instanceARN"` +} + +// ValidateConfig validates that a Config received in a GetPage call is valid. +func (c *Config) Validate(_ context.Context) error { + switch { + case c == nil: + return errors.New("the request contains an empty configuration") + case c.Region == "": + return errors.New("the AWS Region is not set in the configuration") + case c.IdentityStoreID == "": + return errors.New("identityStoreID is not set in the configuration") + case c.InstanceARN == "": + return errors.New("instanceARN is not set in the configuration") + default: + return nil + } +} diff --git a/pkg/aws-identitycenter/datasource.go b/pkg/aws-identitycenter/datasource.go new file mode 100644 index 0000000..ef9c965 --- /dev/null +++ b/pkg/aws-identitycenter/datasource.go @@ -0,0 +1,295 @@ +package awsidentitycenter + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" + aws_config "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/identitystore" + "github.com/aws/aws-sdk-go-v2/service/ssoadmin" + + framework "github.com/sgnl-ai/adapter-framework" + api_adapter_v1 "github.com/sgnl-ai/adapter-framework/api/adapter/v1" + awsadapter "github.com/sgnl-ai/adapters/pkg/aws" + customerror "github.com/sgnl-ai/adapters/pkg/errors" + "github.com/sgnl-ai/adapters/pkg/pagination" +) + +const ( + PermissionSet string = "PermissionSet" + User string = "User" + Group string = "Group" + GroupMembership string = "GroupMembership" + + unhandledStatusCode int = -1 +) + +var validEntities = map[string]struct{}{ + PermissionSet: {}, + User: {}, + Group: {}, + GroupMembership: {}, +} + +type Datasource struct { + Client *http.Client + AWSConfig *aws.Config +} + +// NewClient returns a Client to query the datasource. +func NewClient(client *http.Client, awsConfig *aws.Config) (Client, error) { + if awsConfig == nil { + cfg, err := aws_config.LoadDefaultConfig(context.TODO()) + if err != nil { + return nil, err + } + awsConfig = &cfg + } + + return &Datasource{AWSConfig: awsConfig, Client: client}, nil +} + +func (d *Datasource) GetPage(ctx context.Context, request *Request) (*Response, *framework.Error) { + if _, ok := validEntities[request.EntityExternalID]; !ok { + return nil, &framework.Error{ + Message: fmt.Sprintf("Unsupported entity type: %s", request.EntityExternalID), + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_ENTITY_CONFIG, + } + } + + ctx, cancel := context.WithTimeout(ctx, time.Duration(request.RequestTimeoutSeconds)*time.Second) + defer cancel() + + cfg := d.AWSConfig.Copy() + cfg.Credentials = credentials.NewStaticCredentialsProvider(request.AccessKey, request.SecretKey, "") + cfg.Region = request.Region + + resp := &Response{} + var ( + objects []map[string]any + nextToken *string + statusCode int + err error + ) + + switch request.EntityExternalID { + case PermissionSet: + client := ssoadmin.NewFromConfig(cfg) + input := &ssoadmin.ListPermissionSetsInput{ + InstanceArn: aws.String(request.InstanceARN), + MaxResults: aws.Int32(request.MaxResults), + } + if request.Cursor != nil && request.Cursor.Cursor != nil { + input.NextToken = request.Cursor.Cursor + } + + out, err2 := client.ListPermissionSets(ctx, input) + if err2 != nil { + err = err2 + statusCode = statusCodeFromResponseError(err2) + break + } + objects = make([]map[string]any, len(out.PermissionSets)) + for i, arn := range out.PermissionSets { + objects[i] = map[string]any{"Arn": arn} + } + nextToken = out.NextToken + statusCode = http.StatusOK + case User: + client := identitystore.NewFromConfig(cfg) + input := &identitystore.ListUsersInput{ + IdentityStoreId: aws.String(request.IdentityStoreID), + MaxResults: aws.Int32(request.MaxResults), + } + if request.Cursor != nil && request.Cursor.Cursor != nil { + input.NextToken = request.Cursor.Cursor + } + + // Add this log: + fmt.Printf("DEBUG: ListUsersInput: %+v\n", input) + + out, err2 := client.ListUsers(ctx, input) + if err2 != nil { + err = err2 + statusCode = statusCodeFromResponseError(err2) + // Add this log: + fmt.Printf("ERROR: AWS ListUsers API call failed. Status: %d, Error: %s\n", statusCode, err2.Error()) + // For even more detail, you might try: + // fmt.Printf("ERROR: AWS ListUsers API call failed. Detailed Error: %#v\n", err2) + break + } + objects = make([]map[string]any, len(out.Users)) + for i, u := range out.Users { + m, convErr := awsadapter.EntityToObjects(u) + if convErr != nil { + err = fmt.Errorf("failed to convert entity response to map: %w", convErr) + statusCode = http.StatusInternalServerError + break + } + objects[i] = m + } + nextToken = out.NextToken + statusCode = http.StatusOK + case Group: + client := identitystore.NewFromConfig(cfg) + input := &identitystore.ListGroupsInput{ + IdentityStoreId: aws.String(request.IdentityStoreID), + MaxResults: aws.Int32(request.MaxResults), + } + if request.Cursor != nil && request.Cursor.Cursor != nil { + input.NextToken = request.Cursor.Cursor + } + + out, err2 := client.ListGroups(ctx, input) + if err2 != nil { + err = err2 + statusCode = statusCodeFromResponseError(err2) + break + } + objects = make([]map[string]any, len(out.Groups)) + for i, g := range out.Groups { + m, convErr := awsadapter.EntityToObjects(g) + if convErr != nil { + err = fmt.Errorf("failed to convert entity response to map: %w", convErr) + statusCode = http.StatusInternalServerError + break + } + objects[i] = m + } + nextToken = out.NextToken + statusCode = http.StatusOK + case GroupMembership: + client := identitystore.NewFromConfig(cfg) + objects = []map[string]any{} + + // We need to first get all groups, then for each group get the memberships + // If we have a cursor that includes a GroupId, use it + var currentGroupId, currentGroupNextToken *string + if request.Cursor != nil && request.Cursor.Cursor != nil { + // Parse the composite cursor format: "groupId:nextToken" + cursorStr := *request.Cursor.Cursor + // Split by first colon only + parts := strings.SplitN(cursorStr, ":", 2) + if len(parts) >= 1 { + currentGroupId = aws.String(parts[0]) + if len(parts) >= 2 && parts[1] != "" { + currentGroupNextToken = aws.String(parts[1]) + } + } + } + + // If we don't have a currentGroupId, we need to list all groups first + if currentGroupId == nil { + groupsInput := &identitystore.ListGroupsInput{ + IdentityStoreId: aws.String(request.IdentityStoreID), + MaxResults: aws.Int32(100), // Get a reasonable batch of groups + } + + groupsOut, err2 := client.ListGroups(ctx, groupsInput) + if err2 != nil { + err = err2 + statusCode = statusCodeFromResponseError(err2) + break + } + + if len(groupsOut.Groups) == 0 { + // No groups to process + statusCode = http.StatusOK + break + } + + // Get the first group and use it + currentGroupId = groupsOut.Groups[0].GroupId + + // Store the remaining groups and next token for later processing + if len(groupsOut.Groups) > 1 || groupsOut.NextToken != nil { + // TODO: Store the list of remaining groups in a more persistent way + // for now we'll just use the first group and rely on pagination + } + } + + // Now fetch memberships for the current group + input := &identitystore.ListGroupMembershipsInput{ + IdentityStoreId: aws.String(request.IdentityStoreID), + GroupId: currentGroupId, + MaxResults: aws.Int32(request.MaxResults), + } + + if currentGroupNextToken != nil { + input.NextToken = currentGroupNextToken + } + + out, err2 := client.ListGroupMemberships(ctx, input) + if err2 != nil { + err = err2 + statusCode = statusCodeFromResponseError(err2) + break + } + + // Process the memberships + objects = make([]map[string]any, len(out.GroupMemberships)) + for i, mship := range out.GroupMemberships { + m, convErr := awsadapter.EntityToObjects(mship) + if convErr != nil { + err = fmt.Errorf("failed to convert entity response to map: %w", convErr) + statusCode = http.StatusInternalServerError + break + } + + // Add the GroupId to each membership since it's not in the response + m["GroupId"] = *currentGroupId + + // Extract UserId from MemberId object if it exists + if memberIdObj, ok := m["MemberId"].(map[string]interface{}); ok { + if userId, ok := memberIdObj["Value"].(string); ok { + // Replace the complex MemberId object with just the UserId string + m["MemberId"] = userId + } + } + + objects[i] = m + } + + // Set the next cursor using the composite format + if out.NextToken != nil { + // Continue with the same group but next page + nextToken = aws.String(fmt.Sprintf("%s:%s", *currentGroupId, *out.NextToken)) + } else { + // TODO: We need to implement proper pagination across groups + // For now, we'll just return the memberships for the first group + } + + statusCode = http.StatusOK + } + + if err != nil { + return nil, customerror.UpdateError(&framework.Error{ + Message: fmt.Sprintf("Failed to fetch AWS Identity Center entity: %s, error: %v.", request.EntityExternalID, err), + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INTERNAL, + }, customerror.WithRequestTimeoutMessage(err, request.RequestTimeoutSeconds)) + } + + resp.StatusCode = statusCode + resp.Objects = objects + if nextToken != nil { + resp.NextCursor = &pagination.CompositeCursor[string]{Cursor: nextToken} + } + + return resp, nil +} + +func statusCodeFromResponseError(err error) int { + var httpResponseErr *awshttp.ResponseError + if errors.As(err, &httpResponseErr) { + return httpResponseErr.HTTPStatusCode() + } + + return unhandledStatusCode +} diff --git a/pkg/aws-identitycenter/validation.go b/pkg/aws-identitycenter/validation.go new file mode 100644 index 0000000..9074eeb --- /dev/null +++ b/pkg/aws-identitycenter/validation.go @@ -0,0 +1,57 @@ +package awsidentitycenter + +import ( + "context" + "fmt" + + framework "github.com/sgnl-ai/adapter-framework" + api_adapter_v1 "github.com/sgnl-ai/adapter-framework/api/adapter/v1" +) + +const ( + // The AWS Identity Center APIs support returning up to 100 items per + // request. Reference: + // https://docs.aws.amazon.com/singlesignon/latest/IdentityStoreAPIReference/API_ListUsers.html + maxPageSize = 100 +) + +// ValidateGetPageRequest validates the fields of the GetPage Request. +func (a *Adapter) ValidateGetPageRequest(ctx context.Context, request *framework.Request[Config]) *framework.Error { + if err := request.Config.Validate(ctx); err != nil { + return &framework.Error{ + Message: fmt.Sprintf("AWS Identity Center config is invalid: %v.", err.Error()), + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_DATASOURCE_CONFIG, + } + } + + if request.Auth == nil || request.Auth.Basic == nil || + request.Auth.Basic.Username == "" || request.Auth.Basic.Password == "" { + return &framework.Error{ + Message: "Provided datasource auth is missing required AWS authorization credentials.", + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_DATASOURCE_CONFIG, + } + } + + if _, ok := validEntities[request.Entity.ExternalId]; !ok { + return &framework.Error{ + Message: "Provided entity external ID is invalid.", + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_ENTITY_CONFIG, + } + } + + if request.Ordered { + return &framework.Error{ + Message: "Ordered must be set to false.", + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_ENTITY_CONFIG, + } + } + + if request.PageSize > maxPageSize { + return &framework.Error{ + Message: fmt.Sprintf("Provided page size (%d) exceeds the maximum allowed (%d).", request.PageSize, maxPageSize), + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_PAGE_REQUEST_CONFIG, + } + } + + return nil +} diff --git a/pkg/aws-identitycenter/validation_test.go b/pkg/aws-identitycenter/validation_test.go new file mode 100644 index 0000000..d6e3d3b --- /dev/null +++ b/pkg/aws-identitycenter/validation_test.go @@ -0,0 +1,56 @@ +package awsidentitycenter_test + +import ( + "testing" + + framework "github.com/sgnl-ai/adapter-framework" + api_adapter_v1 "github.com/sgnl-ai/adapter-framework/api/adapter/v1" + adapter "github.com/sgnl-ai/adapters/pkg/aws-identitycenter" +) + +func TestValidateGetPageRequest(t *testing.T) { + tests := map[string]struct { + request *framework.Request[adapter.Config] + wantErr *framework.Error + }{ + "valid_request": { + request: &framework.Request[adapter.Config]{ + Auth: validAuthCredentials, + Entity: framework.EntityConfig{ + ExternalId: adapter.User, + Attributes: []*framework.AttributeConfig{ + {ExternalId: "UserId", Type: framework.AttributeTypeString, UniqueId: true}, + }, + }, + Config: validConfig, + PageSize: 10, + }, + wantErr: nil, + }, + "invalid_missing_auth": { + request: &framework.Request[adapter.Config]{ + Entity: framework.EntityConfig{ExternalId: adapter.User}, + Config: validConfig, + PageSize: 10, + }, + wantErr: &framework.Error{ + Message: "Provided datasource auth is missing required AWS authorization credentials.", + Code: api_adapter_v1.ErrorCode_ERROR_CODE_INVALID_DATASOURCE_CONFIG, + }, + }, + } + + a := &adapter.Adapter{} + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + gotErr := a.ValidateGetPageRequest(nil, tt.request) + if (gotErr == nil) != (tt.wantErr == nil) { + t.Fatalf("gotErr: %v, wantErr: %v", gotErr, tt.wantErr) + } + if gotErr != nil && gotErr.Message != tt.wantErr.Message { + t.Errorf("gotErr: %v, wantErr: %v", gotErr, tt.wantErr) + } + }) + } +}