From 310e6d41417d4523e3c0b5d56edc09308d5614c0 Mon Sep 17 00:00:00 2001 From: Shubham Date: Tue, 21 Apr 2026 16:10:30 +0530 Subject: [PATCH 1/5] Add Astro backend for user and team management Signed-off-by: Shubham --- appconfig/default.yaml | 11 + pkg/clients/astro/client.go | 169 +++++++++ pkg/clients/astro/client_test.go | 509 +++++++++++++++++++++++++++ pkg/clients/astro/team_membership.go | 165 +++++++++ pkg/clients/astro/teams.go | 181 ++++++++++ pkg/clients/astro/types.go | 147 ++++++++ pkg/clients/astro/users.go | 213 +++++++++++ pkg/clients/client.go | 9 + 8 files changed, 1404 insertions(+) create mode 100644 pkg/clients/astro/client.go create mode 100644 pkg/clients/astro/client_test.go create mode 100644 pkg/clients/astro/team_membership.go create mode 100644 pkg/clients/astro/teams.go create mode 100644 pkg/clients/astro/types.go create mode 100644 pkg/clients/astro/users.go diff --git a/appconfig/default.yaml b/appconfig/default.yaml index 527d3657..e9ca8c4b 100644 --- a/appconfig/default.yaml +++ b/appconfig/default.yaml @@ -30,6 +30,10 @@ pattern: - input: "^([^\\_]+)$" output: "$1" + astro: + - input: "(.*)" + output: "$1" + app: name: "usernaut" @@ -101,6 +105,13 @@ backends: url: "https://gitlab.test.com" token: file|/path/to/gitlab_token parent_group_id: 111111 + - name: astro + type: "astro" + enabled: true + connection: + api_token: file|/path/to/astro_token + organization_id: "your-organization-id" + base_url: "https://api.astro.test.com" apiServer: address: "0.0.0.0:8080" diff --git a/pkg/clients/astro/client.go b/pkg/clients/astro/client.go new file mode 100644 index 00000000..efe31a5e --- /dev/null +++ b/pkg/clients/astro/client.go @@ -0,0 +1,169 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/gojek/heimdall/v7" + "github.com/redhat-data-and-ai/usernaut/pkg/request" + "github.com/redhat-data-and-ai/usernaut/pkg/request/httpclient" +) + +// NewClient creates a new Astro client with the given configuration +func NewClient(connection map[string]interface{}, poolCfg httpclient.ConnectionPoolConfig, + hystrixCfg httpclient.HystrixResiliencyConfig) (*AstroClient, error) { + + // Extract connection parameters + apiToken, _ := connection["api_token"].(string) + organizationID, _ := connection["organization_id"].(string) + baseURL, _ := connection["base_url"].(string) + + if apiToken == "" || organizationID == "" { + return nil, errors.New("missing required astro connection params: api_token and organization_id") + } + + if baseURL == "" { + baseURL = DefaultBaseURL + } + + config := AstroConfig{ + APIToken: apiToken, + OrganizationID: organizationID, + BaseURL: baseURL, + } + + client, err := httpclient.InitializeClient( + "astro", + poolCfg, + hystrixCfg, + heimdall.NewRetrier(heimdall.NewConstantBackoff(100*time.Millisecond, 50*time.Millisecond)), 3, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to initialize http client: %w", err) + } + + return &AstroClient{ + config: &config, + client: client, + }, nil +} + +// prepareRequest creates and configures a request with common Astro headers +func (c *AstroClient) prepareRequest(ctx context.Context, endpoint, method string, + body interface{}) (request.IRequester, error) { + var requestBody []byte + if body != nil && (method != http.MethodGet && method != http.MethodDelete) { + var err error + requestBody, err = json.Marshal(body) + if err != nil { + return nil, err + } + } + + url := c.config.BaseURL + endpoint + req, err := request.NewRequest(ctx, method, url, requestBody) + if err != nil { + return nil, err + } + + // Set Astro-specific headers + headers := map[string]string{ + "Authorization": "Bearer " + c.config.APIToken, + "Content-Type": "application/json", + "Accept": "application/json", + } + req.SetHeaders(headers) + + return req, nil +} + +// makeRequest uses the common request package for HTTP requests +func (c *AstroClient) makeRequest(ctx context.Context, endpoint, + method string, body interface{}) ([]byte, int, error) { + req, err := c.prepareRequest(ctx, endpoint, method, body) + if err != nil { + return nil, 0, err + } + + return req.MakeRequest(c.client, method, "astro") +} + +// makeRequestWithHeader uses the common request package for HTTP requests +// and returns headers (with logging, tracing, etc.) +func (c *AstroClient) makeRequestWithHeader(ctx context.Context, endpoint, + method string, body interface{}) ([]byte, http.Header, int, error) { + req, err := c.prepareRequest(ctx, endpoint, method, body) + if err != nil { + return nil, nil, 0, err + } + + return req.MakeRequestWithHeader(c.client, method, "astro") +} + +// fetchAllWithPagination handles paginated requests using offset-based pagination +func (c *AstroClient) fetchAllWithPagination(ctx context.Context, + baseEndpoint string, processPage func([]byte) (int, error)) error { + offset := 0 + limit := DefaultPageLimit + + for { + // Build endpoint with pagination parameters + endpoint := fmt.Sprintf("%s?offset=%d&limit=%d", baseEndpoint, offset, limit) + + resp, _, status, err := c.makeRequestWithHeader(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return err + } + + if status != http.StatusOK { + return fmt.Errorf("failed to fetch data from %s, status: %s, body: %s", + endpoint, http.StatusText(status), string(resp)) + } + + // Process page and get count of items returned + count, err := processPage(resp) + if err != nil { + return err + } + + // If fewer items than limit, we've reached the end + if count < limit { + break + } + + offset += limit + } + + return nil +} + +// getOrganizationEndpoint returns the base endpoint for organization-scoped resources +func (c *AstroClient) getOrganizationEndpoint() string { + return fmt.Sprintf("/v1/organizations/%s", c.config.OrganizationID) +} + +// GetConfig returns the client configuration +func (c *AstroClient) GetConfig() *AstroConfig { + return c.config +} diff --git a/pkg/clients/astro/client_test.go b/pkg/clients/astro/client_test.go new file mode 100644 index 00000000..3c571735 --- /dev/null +++ b/pkg/clients/astro/client_test.go @@ -0,0 +1,509 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/request/httpclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestClient creates an AstroClient with a mock server +func createTestClient(t *testing.T, handler http.Handler) (*AstroClient, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + + client := &AstroClient{ + config: &AstroConfig{ + APIToken: "test-token", + OrganizationID: "test-org-id", + BaseURL: server.URL, + }, + client: server.Client(), + } + + return client, server +} + +func TestNewClient(t *testing.T) { + t.Run("Success", func(t *testing.T) { + connection := map[string]interface{}{ + "api_token": "test-token", + "organization_id": "test-org-id", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + client, err := NewClient(connection, poolCfg, hystrixCfg) + require.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "test-token", client.config.APIToken) + assert.Equal(t, "test-org-id", client.config.OrganizationID) + assert.Equal(t, DefaultBaseURL, client.config.BaseURL) + }) + + t.Run("CustomBaseURL", func(t *testing.T) { + connection := map[string]interface{}{ + "api_token": "test-token", + "organization_id": "test-org-id", + "base_url": "https://custom.api.com", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + client, err := NewClient(connection, poolCfg, hystrixCfg) + require.NoError(t, err) + assert.Equal(t, "https://custom.api.com", client.config.BaseURL) + }) + + t.Run("MissingAPIToken", func(t *testing.T) { + connection := map[string]interface{}{ + "organization_id": "test-org-id", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + _, err := NewClient(connection, poolCfg, hystrixCfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "api_token") + }) + + t.Run("MissingOrgID", func(t *testing.T) { + connection := map[string]interface{}{ + "api_token": "test-token", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + _, err := NewClient(connection, poolCfg, hystrixCfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "organization_id") + }) +} + +func TestFetchAllUsers(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + usersResp := AstroUsersResponse{ + Users: []AstroUser{ + { + ID: "user-1", + Username: "user1@redhat.com", + FullName: "User One", + Status: "ACTIVE", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + { + ID: "user-2", + Username: "user2@redhat.com", + FullName: "User Two", + Status: "ACTIVE", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/v1/organizations/test-org-id/users") + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(usersResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + usersByID, usersByEmail, err := client.FetchAllUsers(ctx) + require.NoError(t, err) + assert.Len(t, usersByID, 2) + assert.Len(t, usersByEmail, 2) + + assert.Equal(t, "user-1", usersByID["user-1"].ID) + assert.Equal(t, "user1@redhat.com", usersByID["user-1"].Email) + assert.Equal(t, "User", usersByID["user-1"].FirstName) + assert.Equal(t, "One", usersByID["user-1"].LastName) + + assert.Equal(t, "user-2", usersByEmail["user2@redhat.com"].ID) + }) + + t.Run("EmptyResponse", func(t *testing.T) { + usersResp := AstroUsersResponse{Users: []AstroUser{}} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(usersResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + usersByID, usersByEmail, err := client.FetchAllUsers(ctx) + require.NoError(t, err) + assert.Len(t, usersByID, 0) + assert.Len(t, usersByEmail, 0) + }) +} + +func TestCreateUser(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + inviteResp := CreateInviteResponse{ + UserID: "new-user-id", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/invites") + + var req CreateInviteRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Equal(t, "newuser@redhat.com", req.InviteeEmail) + assert.Equal(t, DefaultOrganizationRole, req.Role) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(inviteResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + user := &structs.User{ + Email: "newuser@redhat.com", + UserName: "newuser@redhat.com", + } + + created, err := client.CreateUser(ctx, user) + require.NoError(t, err) + assert.Equal(t, "new-user-id", created.ID) + assert.Equal(t, "newuser@redhat.com", created.Email) + }) + + t.Run("MissingEmail", func(t *testing.T) { + client, server := createTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer server.Close() + + user := &structs.User{UserName: "testuser"} + _, err := client.CreateUser(ctx, user) + assert.Error(t, err) + assert.Contains(t, err.Error(), "email is required") + }) +} + +func TestDeleteUser(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/users/user-to-delete/roles") + + var req UpdateUserRoleRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Nil(t, req.OrganizationRole) + + w.WriteHeader(http.StatusOK) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.DeleteUser(ctx, "user-to-delete") + require.NoError(t, err) + }) + + t.Run("NotFound", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": "User not found"}`)) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.DeleteUser(ctx, "nonexistent-user") + assert.Error(t, err) + }) +} + +func TestFetchAllTeams(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + teamsResp := AstroTeamsResponse{ + Teams: []AstroTeam{ + { + ID: "team-1", + Name: "dataverse-aggregate-test", + Description: "Test team", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + { + ID: "team-2", + Name: "dataverse-aggregate-prod", + Description: "Prod team", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/teams") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(teamsResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + teams, err := client.FetchAllTeams(ctx) + require.NoError(t, err) + assert.Len(t, teams, 2) + + assert.Equal(t, "dataverse-aggregate-test", teams["team-1"].Name) + assert.Equal(t, "Test team", teams["team-1"].Description) + }) +} + +func TestCreateTeam(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + createdTeam := AstroTeam{ + ID: "new-team-id", + Name: "new-team", + Description: "New team description", + OrganizationRole: "ORGANIZATION_MEMBER", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/teams") + + var req CreateTeamRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Equal(t, "new-team", req.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(createdTeam) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + team := &structs.Team{ + Name: "new-team", + Description: "New team description", + } + + created, err := client.CreateTeam(ctx, team) + require.NoError(t, err) + assert.Equal(t, "new-team-id", created.ID) + assert.Equal(t, "new-team", created.Name) + }) +} + +func TestDeleteTeamByID(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Contains(t, r.URL.Path, "/teams/team-to-delete") + + w.WriteHeader(http.StatusNoContent) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.DeleteTeamByID(ctx, "team-to-delete") + require.NoError(t, err) + }) +} + +func TestFetchTeamMembersByTeamID(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + membersResp := AstroTeamMembersResponse{ + TeamMembers: []AstroTeamMember{ + { + UserID: "user-1", + Username: "user1@redhat.com", + FullName: "User One", + }, + { + UserID: "user-2", + Username: "user2@redhat.com", + FullName: "User Two", + }, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/teams/team-1/members") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(membersResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + members, err := client.FetchTeamMembersByTeamID(ctx, "team-1") + require.NoError(t, err) + assert.Len(t, members, 2) + + assert.Equal(t, "user-1", members["user-1"].ID) + assert.Equal(t, "user1@redhat.com", members["user-1"].Email) + }) +} + +func TestAddUserToTeam(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/teams/team-1/members") + + var req AddTeamMembersRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Contains(t, req.MemberIDs, "user-1") + assert.Contains(t, req.MemberIDs, "user-2") + + w.WriteHeader(http.StatusOK) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.AddUserToTeam(ctx, "team-1", []string{"user-1", "user-2"}) + require.NoError(t, err) + }) + + t.Run("EmptyUserList", func(t *testing.T) { + client, server := createTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("Should not make request for empty user list") + })) + defer server.Close() + + err := client.AddUserToTeam(ctx, "team-1", []string{}) + require.NoError(t, err) + }) +} + +func TestRemoveUserFromTeam(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + callCount++ + w.WriteHeader(http.StatusNoContent) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.RemoveUserFromTeam(ctx, "team-1", []string{"user-1", "user-2"}) + require.NoError(t, err) + assert.Equal(t, 2, callCount) // Should make 2 calls, one per user + }) + + t.Run("UserNotFoundIsOK", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.RemoveUserFromTeam(ctx, "team-1", []string{"nonexistent-user"}) + require.NoError(t, err) + }) +} + +func TestReconcileGroupParams(t *testing.T) { + ctx := context.Background() + + t.Run("NoOp", func(t *testing.T) { + client, server := createTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("Should not make any request") + })) + defer server.Close() + + params := structs.TeamParams{ + Property: "test", + Value: []string{"value1", "value2"}, + } + + err := client.ReconcileGroupParams(ctx, "team-1", params) + require.NoError(t, err) + }) +} + +func TestGetConfig(t *testing.T) { + client := &AstroClient{ + config: &AstroConfig{ + APIToken: "test-token", + OrganizationID: "test-org", + BaseURL: "https://api.test.com", + }, + } + + config := client.GetConfig() + assert.Equal(t, "test-token", config.APIToken) + assert.Equal(t, "test-org", config.OrganizationID) + assert.Equal(t, "https://api.test.com", config.BaseURL) +} + +// Helper functions for test configuration +func testPoolConfig() httpclient.ConnectionPoolConfig { + return httpclient.ConnectionPoolConfig{ + Timeout: 10000, + KeepAliveTimeout: 60000, + MaxIdleConnections: 10, + } +} + +func testHystrixConfig() httpclient.HystrixResiliencyConfig { + return httpclient.HystrixResiliencyConfig{ + MaxConcurrentRequests: 100, + RequestVolumeThreshold: 20, + CircuitBreakerSleepWindow: 5000, + ErrorPercentThreshold: 50, + CircuitBreakerTimeout: 10000, + } +} diff --git a/pkg/clients/astro/team_membership.go b/pkg/clients/astro/team_membership.go new file mode 100644 index 00000000..bd0494be --- /dev/null +++ b/pkg/clients/astro/team_membership.go @@ -0,0 +1,165 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/logger" + "github.com/sirupsen/logrus" +) + +// FetchTeamMembersByTeamID fetches team members for a given team ID with pagination support +func (c *AstroClient) FetchTeamMembersByTeamID(ctx context.Context, + teamID string) (map[string]*structs.User, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + log.Info("fetching team members by team ID") + + members := make(map[string]*structs.User) + baseEndpoint := fmt.Sprintf("%s/teams/%s/members", c.getOrganizationEndpoint(), teamID) + + err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { + var membersResp AstroTeamMembersResponse + if err := json.Unmarshal(resp, &membersResp); err != nil { + return 0, fmt.Errorf("failed to parse team members response: %w", err) + } + + for _, member := range membersResp.TeamMembers { + // Extract first and last name from full name if available + firstName := "" + lastName := "" + if member.FullName != "" { + parts := strings.SplitN(member.FullName, " ", 2) + firstName = parts[0] + if len(parts) > 1 { + lastName = parts[1] + } + } + + members[member.UserID] = &structs.User{ + ID: member.UserID, + UserName: strings.ToLower(member.Username), + Email: strings.ToLower(member.Username), // In Astro, username is the email + FirstName: firstName, + LastName: lastName, + DisplayName: member.FullName, + } + } + return len(membersResp.TeamMembers), nil + }) + + if err != nil { + log.WithError(err).Error("error fetching team members by team ID") + return nil, err + } + + log.WithField("member_count", len(members)).Info("found team members") + return members, nil +} + +// AddUserToTeam adds users to a team +func (c *AstroClient) AddUserToTeam(ctx context.Context, teamID string, userIDs []string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + "user_count": len(userIDs), + }) + log.Info("adding users to team") + + if len(userIDs) == 0 { + log.Info("no users to add") + return nil + } + + endpoint := fmt.Sprintf("%s/teams/%s/members", c.getOrganizationEndpoint(), teamID) + + payload := AddTeamMembersRequest{ + MemberIDs: userIDs, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error adding users to team") + return fmt.Errorf("failed to add users to team %s: %w", teamID, err) + } + + if status != http.StatusOK && status != http.StatusCreated && status != http.StatusNoContent { + return fmt.Errorf("failed to add users to team %s, status: %s, body: %s", + teamID, http.StatusText(status), string(resp)) + } + + log.Info("users added to team successfully") + return nil +} + +// RemoveUserFromTeam removes users from a team +func (c *AstroClient) RemoveUserFromTeam(ctx context.Context, teamID string, userIDs []string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + "user_count": len(userIDs), + }) + log.Info("removing users from team") + + if len(userIDs) == 0 { + log.Info("no users to remove") + return nil + } + + // Astro requires removing users one at a time + for _, userID := range userIDs { + endpoint := fmt.Sprintf("%s/teams/%s/members/%s", c.getOrganizationEndpoint(), teamID, userID) + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodDelete, nil) + if err != nil { + log.WithError(err).WithField("userID", userID).Error("error removing user from team") + return fmt.Errorf("failed to remove user %s from team %s: %w", userID, teamID, err) + } + + // 404 is acceptable - user might already be removed + if status != http.StatusOK && status != http.StatusNoContent && status != http.StatusNotFound { + return fmt.Errorf("failed to remove user %s from team %s, status: %s, body: %s", + userID, teamID, http.StatusText(status), string(resp)) + } + + log.WithField("userID", userID).Debug("user removed from team") + } + + log.Info("users removed from team successfully") + return nil +} + +// ReconcileGroupParams reconciles backend-specific parameters for a group/team. +// For Astro, this could be used to manage workspace/deployment role assignments in the future. +func (c *AstroClient) ReconcileGroupParams( + ctx context.Context, teamID string, groupParams structs.TeamParams) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + + log.Debug("ReconcileGroupParams called - no implementation needed for Astro at this time") + return nil +} diff --git a/pkg/clients/astro/teams.go b/pkg/clients/astro/teams.go new file mode 100644 index 00000000..e2ddd49e --- /dev/null +++ b/pkg/clients/astro/teams.go @@ -0,0 +1,181 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/logger" + "github.com/sirupsen/logrus" +) + +// astroTeamToStruct converts an AstroTeam to a structs.Team +func astroTeamToStruct(team AstroTeam) structs.Team { + return structs.Team{ + ID: team.ID, + Name: team.Name, + Description: team.Description, + Role: team.OrganizationRole, + } +} + +// FetchAllTeams fetches all teams from Astro using REST API with pagination +func (c *AstroClient) FetchAllTeams(ctx context.Context) (map[string]structs.Team, error) { + log := logger.Logger(ctx).WithField("service", "astro") + + log.Info("fetching all teams") + teams := make(map[string]structs.Team) + + baseEndpoint := c.getOrganizationEndpoint() + "/teams" + + err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { + var teamsResp AstroTeamsResponse + if err := json.Unmarshal(resp, &teamsResp); err != nil { + return 0, fmt.Errorf("failed to parse teams response: %w", err) + } + + for _, team := range teamsResp.Teams { + structTeam := astroTeamToStruct(team) + teams[team.ID] = structTeam + } + return len(teamsResp.Teams), nil + }) + + if err != nil { + log.WithError(err).Error("error fetching list of teams") + return nil, err + } + + log.WithField("total_teams_count", len(teams)).Info("found teams") + return teams, nil +} + +// FetchTeamDetails fetches details for a specific team +func (c *AstroClient) FetchTeamDetails(ctx context.Context, teamID string) (*structs.Team, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + + log.Info("fetching team details") + + endpoint := fmt.Sprintf("%s/teams/%s", c.getOrganizationEndpoint(), teamID) + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return nil, err + } + + if status == http.StatusNotFound { + return nil, fmt.Errorf("team with ID %s not found", teamID) + } + + if status != http.StatusOK { + return nil, fmt.Errorf("failed to fetch team %s: status %d, body: %s", teamID, status, string(resp)) + } + + var team AstroTeam + if err := json.Unmarshal(resp, &team); err != nil { + return nil, fmt.Errorf("failed to parse team response: %w", err) + } + + log.Info("successfully fetched team details") + result := astroTeamToStruct(team) + return &result, nil +} + +// CreateTeam creates a new team in Astro +func (c *AstroClient) CreateTeam(ctx context.Context, team *structs.Team) (*structs.Team, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamName": team.Name, + }) + + log.Info("creating team") + endpoint := c.getOrganizationEndpoint() + "/teams" + + payload := CreateTeamRequest{ + Name: team.Name, + Description: team.Description, + OrganizationRole: DefaultOrganizationRole, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error creating team") + return nil, err + } + + // Handle conflict - team might already exist + if status == http.StatusConflict { + log.Info("team already exists, fetching existing team") + // Try to find the team by name + teams, fetchErr := c.FetchAllTeams(ctx) + if fetchErr != nil { + return nil, fetchErr + } + for _, existingTeam := range teams { + if existingTeam.Name == team.Name { + return &existingTeam, nil + } + } + return nil, fmt.Errorf("team conflict but couldn't find existing team: %s", team.Name) + } + + if status != http.StatusOK && status != http.StatusCreated { + return nil, fmt.Errorf("failed to create team, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + var createdTeam AstroTeam + if err := json.Unmarshal(resp, &createdTeam); err != nil { + return nil, fmt.Errorf("failed to parse create team response: %w", err) + } + + log.WithField("teamID", createdTeam.ID).Info("team created successfully") + + result := astroTeamToStruct(createdTeam) + return &result, nil +} + +// DeleteTeamByID deletes a team from Astro +func (c *AstroClient) DeleteTeamByID(ctx context.Context, teamID string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + + log.Info("deleting team") + endpoint := fmt.Sprintf("%s/teams/%s", c.getOrganizationEndpoint(), teamID) + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodDelete, nil) + if err != nil { + log.WithError(err).Error("error deleting team") + return fmt.Errorf("failed to delete team: %w", err) + } + + if status != http.StatusOK && status != http.StatusNoContent { + return fmt.Errorf("failed to delete team, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + log.Info("team deleted successfully") + return nil +} diff --git a/pkg/clients/astro/types.go b/pkg/clients/astro/types.go new file mode 100644 index 00000000..9020ab32 --- /dev/null +++ b/pkg/clients/astro/types.go @@ -0,0 +1,147 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import "github.com/gojek/heimdall/v7" + +// AstroConfig holds the configuration for Astro client +type AstroConfig struct { + APIToken string + OrganizationID string + BaseURL string +} + +// AstroClient is the client for interacting with Astro REST API +type AstroClient struct { + config *AstroConfig + client heimdall.Doer +} + +// AstroUser represents a user object from Astro API response +type AstroUser struct { + ID string `json:"id"` + Username string `json:"username"` + Status string `json:"status,omitempty"` + FullName string `json:"fullName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + OrganizationRole string `json:"organizationRole,omitempty"` +} + +// AstroUsersResponse represents the response from list users API +type AstroUsersResponse struct { + Users []AstroUser `json:"users"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + TotalCount int `json:"totalCount,omitempty"` +} + +// AstroTeam represents a team object from Astro API response +type AstroTeam struct { + ID string `json:"id"` + Name string `json:"name"` + OrganizationID string `json:"organizationId,omitempty"` + OrganizationRole string `json:"organizationRole,omitempty"` + Description string `json:"description,omitempty"` + IsIdpManaged bool `json:"isIdpManaged,omitempty"` + WorkspaceRoles []WorkspaceRole `json:"workspaceRoles,omitempty"` + DeploymentRoles []DeploymentRole `json:"deploymentRoles,omitempty"` + RolesCount int `json:"rolesCount,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// WorkspaceRole represents a workspace role assignment +type WorkspaceRole struct { + WorkspaceID string `json:"workspaceId"` + Role string `json:"role"` +} + +// DeploymentRole represents a deployment role assignment +type DeploymentRole struct { + DeploymentID string `json:"deploymentId"` + Role string `json:"role"` +} + +// AstroTeamsResponse represents the response from list teams API +type AstroTeamsResponse struct { + Teams []AstroTeam `json:"teams"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + TotalCount int `json:"totalCount,omitempty"` +} + +// AstroTeamMember represents a team member from Astro API response +type AstroTeamMember struct { + UserID string `json:"userId"` + Username string `json:"username,omitempty"` + FullName string `json:"fullName,omitempty"` +} + +// AstroTeamMembersResponse represents the response from list team members API +type AstroTeamMembersResponse struct { + TeamMembers []AstroTeamMember `json:"teamMembers"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + TotalCount int `json:"totalCount,omitempty"` +} + +// CreateTeamRequest represents the request body for creating a team +type CreateTeamRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + MemberIDs []string `json:"memberIds,omitempty"` + OrganizationRole string `json:"organizationRole,omitempty"` +} + +// CreateInviteRequest represents the request body for inviting a user +type CreateInviteRequest struct { + InviteeEmail string `json:"inviteeEmail"` + Role string `json:"role"` +} + +// CreateInviteResponse represents the response from invite user API +type CreateInviteResponse struct { + UserID string `json:"userId"` + Invite struct { + ID string `json:"id"` + InviteeEmail string `json:"inviteeEmail"` + } `json:"invite,omitempty"` +} + +// AddTeamMembersRequest represents the request body for adding members to a team +type AddTeamMembersRequest struct { + MemberIDs []string `json:"memberIds"` +} + +// UpdateUserRoleRequest represents the request body for updating user role (used for deletion) +type UpdateUserRoleRequest struct { + OrganizationRole *string `json:"organizationRole"` +} + +// Astro API constants +const ( + // Default pagination limit for Astro API + DefaultPageLimit = 100 + + // Default organization role for new users and teams + DefaultOrganizationRole = "ORGANIZATION_MEMBER" + + // Default base URL for Astro API + DefaultBaseURL = "https://api.astronomer.io" +) diff --git a/pkg/clients/astro/users.go b/pkg/clients/astro/users.go new file mode 100644 index 00000000..6529761a --- /dev/null +++ b/pkg/clients/astro/users.go @@ -0,0 +1,213 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/logger" + "github.com/sirupsen/logrus" +) + +// astroUserToStruct converts an AstroUser to a structs.User +func astroUserToStruct(user AstroUser) *structs.User { + // Extract first and last name from full name if available + firstName := "" + lastName := "" + if user.FullName != "" { + parts := strings.SplitN(user.FullName, " ", 2) + firstName = parts[0] + if len(parts) > 1 { + lastName = parts[1] + } + } + + return &structs.User{ + ID: user.ID, + UserName: strings.ToLower(user.Username), + Email: strings.ToLower(user.Username), // In Astro, username is the email + FirstName: firstName, + LastName: lastName, + DisplayName: user.FullName, + Role: user.OrganizationRole, + } +} + +// FetchAllUsers fetches all users from Astro using REST API with pagination +// Returns 2 maps: 1st map keyed by ID, 2nd map keyed by email +func (c *AstroClient) FetchAllUsers(ctx context.Context) (map[string]*structs.User, + map[string]*structs.User, error) { + log := logger.Logger(ctx).WithField("service", "astro") + + log.Info("fetching all users") + resultByID := make(map[string]*structs.User) + resultByEmail := make(map[string]*structs.User) + + baseEndpoint := c.getOrganizationEndpoint() + "/users" + + err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { + var usersResp AstroUsersResponse + if err := json.Unmarshal(resp, &usersResp); err != nil { + return 0, fmt.Errorf("failed to parse users response: %w", err) + } + + for _, user := range usersResp.Users { + structUser := astroUserToStruct(user) + resultByID[structUser.ID] = structUser + if structUser.Email != "" { + resultByEmail[structUser.Email] = structUser + } + } + return len(usersResp.Users), nil + }) + + if err != nil { + log.WithError(err).Error("error fetching list of users") + return nil, nil, err + } + + log.WithField("total_user_count", len(resultByID)).Info("found users") + return resultByID, resultByEmail, nil +} + +// FetchUserDetails fetches details for a specific user using REST API +func (c *AstroClient) FetchUserDetails(ctx context.Context, userID string) (*structs.User, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "userID": userID, + }) + log.Info("fetching user details by ID") + + endpoint := fmt.Sprintf("%s/users/%s", c.getOrganizationEndpoint(), userID) + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return nil, err + } + + if status == http.StatusNotFound { + return nil, fmt.Errorf("user with ID %s not found", userID) + } + + if status != http.StatusOK { + return nil, fmt.Errorf("failed to fetch user %s: status %d, body: %s", userID, status, string(resp)) + } + + var user AstroUser + if err := json.Unmarshal(resp, &user); err != nil { + return nil, fmt.Errorf("failed to parse user response: %w", err) + } + + log.Info("successfully fetched user details") + return astroUserToStruct(user), nil +} + +// CreateUser creates a new user in Astro by sending an invitation +// In Astro, users are created via invitations +func (c *AstroClient) CreateUser(ctx context.Context, user *structs.User) (*structs.User, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "user": user.Email, + }) + + log.Info("creating user (sending invitation)") + endpoint := c.getOrganizationEndpoint() + "/invites" + + if user.Email == "" { + return nil, fmt.Errorf("email is required for Astro user creation") + } + + payload := CreateInviteRequest{ + InviteeEmail: user.Email, + Role: DefaultOrganizationRole, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error creating user invitation") + return nil, err + } + + // Handle conflict - user might already exist + if status == http.StatusConflict { + log.Info("user already exists, fetching existing user") + // Try to find the user in the existing users list + _, usersByEmail, fetchErr := c.FetchAllUsers(ctx) + if fetchErr != nil { + return nil, fetchErr + } + if existingUser, ok := usersByEmail[strings.ToLower(user.Email)]; ok { + return existingUser, nil + } + return nil, fmt.Errorf("user conflict but couldn't find existing user: %s", user.Email) + } + + if status != http.StatusOK && status != http.StatusCreated { + return nil, fmt.Errorf("failed to create user invitation, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + var inviteResp CreateInviteResponse + if err := json.Unmarshal(resp, &inviteResp); err != nil { + return nil, fmt.Errorf("failed to parse invite response: %w", err) + } + + log.WithField("userID", inviteResp.UserID).Info("user invitation sent successfully") + + // Return the created user with the ID from the response + return &structs.User{ + ID: inviteResp.UserID, + Email: user.Email, + UserName: user.Email, + Role: DefaultOrganizationRole, + }, nil +} + +// DeleteUser removes a user from Astro by setting their organization role to null +// Astro doesn't have a direct delete endpoint; instead, we remove the org-level role +func (c *AstroClient) DeleteUser(ctx context.Context, userID string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "userID": userID, + }) + + log.Info("deleting user (removing organization role)") + endpoint := fmt.Sprintf("%s/users/%s/roles", c.getOrganizationEndpoint(), userID) + + // Set organizationRole to null to remove the user + payload := UpdateUserRoleRequest{ + OrganizationRole: nil, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error deleting user") + return fmt.Errorf("failed to delete user: %w", err) + } + + if status != http.StatusOK && status != http.StatusNoContent { + return fmt.Errorf("failed to delete user, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + log.Info("user deleted successfully") + return nil +} diff --git a/pkg/clients/client.go b/pkg/clients/client.go index b011c62d..ad1844e2 100644 --- a/pkg/clients/client.go +++ b/pkg/clients/client.go @@ -21,6 +21,7 @@ import ( "errors" "strings" + "github.com/redhat-data-and-ai/usernaut/pkg/clients/astro" "github.com/redhat-data-and-ai/usernaut/pkg/clients/fivetran" "github.com/redhat-data-and-ai/usernaut/pkg/clients/gitlab" redhatrover "github.com/redhat-data-and-ai/usernaut/pkg/clients/redhat_rover" @@ -111,6 +112,14 @@ func New(backendName, backendType string, backends map[string]map[string]config. return nil, err } return gitlabClient, nil + case "astro": + appConfig, err := config.GetConfig() + if err != nil { + return nil, err + } + + return astro.NewClient(backend.Connection, + appConfig.HttpClient.ConnectionPoolConfig, appConfig.HttpClient.HystrixResiliencyConfig) default: // If no valid backend type is matched, return an error return nil, ErrInvalidBackend From c40e92429f2dfeb520492ee133ea82c80985562f Mon Sep 17 00:00:00 2001 From: Shubham Date: Wed, 13 May 2026 20:20:29 +0530 Subject: [PATCH 2/5] Add Astro backend for user and team management Signed-off-by: Shubham --- appconfig/default.yaml | 2 +- pkg/clients/astro/client.go | 10 ++++------ pkg/clients/astro/client_test.go | 8 ++++---- pkg/clients/astro/team_membership.go | 8 ++++---- pkg/clients/astro/teams.go | 10 +++++----- pkg/clients/astro/types.go | 4 ++-- pkg/clients/astro/users.go | 10 +++++----- 7 files changed, 25 insertions(+), 27 deletions(-) diff --git a/appconfig/default.yaml b/appconfig/default.yaml index e9ca8c4b..91d1f03d 100644 --- a/appconfig/default.yaml +++ b/appconfig/default.yaml @@ -31,7 +31,7 @@ pattern: output: "$1" astro: - - input: "(.*)" + - input: "^([^\\_]+)$" output: "$1" diff --git a/pkg/clients/astro/client.go b/pkg/clients/astro/client.go index efe31a5e..bd864838 100644 --- a/pkg/clients/astro/client.go +++ b/pkg/clients/astro/client.go @@ -1,5 +1,5 @@ /* -Copyright 2025. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -46,6 +46,9 @@ func NewClient(connection map[string]interface{}, poolCfg httpclient.ConnectionP baseURL = DefaultBaseURL } + // Construct full base URL with organization ID + baseURL = fmt.Sprintf("%s/v1/organizations/%s", baseURL, organizationID) + config := AstroConfig{ APIToken: apiToken, OrganizationID: organizationID, @@ -158,11 +161,6 @@ func (c *AstroClient) fetchAllWithPagination(ctx context.Context, return nil } -// getOrganizationEndpoint returns the base endpoint for organization-scoped resources -func (c *AstroClient) getOrganizationEndpoint() string { - return fmt.Sprintf("/v1/organizations/%s", c.config.OrganizationID) -} - // GetConfig returns the client configuration func (c *AstroClient) GetConfig() *AstroConfig { return c.config diff --git a/pkg/clients/astro/client_test.go b/pkg/clients/astro/client_test.go index 3c571735..1ba0f7ea 100644 --- a/pkg/clients/astro/client_test.go +++ b/pkg/clients/astro/client_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -38,7 +38,7 @@ func createTestClient(t *testing.T, handler http.Handler) (*AstroClient, *httpte config: &AstroConfig{ APIToken: "test-token", OrganizationID: "test-org-id", - BaseURL: server.URL, + BaseURL: server.URL + "/v1/organizations/test-org-id", }, client: server.Client(), } @@ -60,7 +60,7 @@ func TestNewClient(t *testing.T) { assert.NotNil(t, client) assert.Equal(t, "test-token", client.config.APIToken) assert.Equal(t, "test-org-id", client.config.OrganizationID) - assert.Equal(t, DefaultBaseURL, client.config.BaseURL) + assert.Equal(t, DefaultBaseURL+"/v1/organizations/test-org-id", client.config.BaseURL) }) t.Run("CustomBaseURL", func(t *testing.T) { @@ -74,7 +74,7 @@ func TestNewClient(t *testing.T) { client, err := NewClient(connection, poolCfg, hystrixCfg) require.NoError(t, err) - assert.Equal(t, "https://custom.api.com", client.config.BaseURL) + assert.Equal(t, "https://custom.api.com/v1/organizations/test-org-id", client.config.BaseURL) }) t.Run("MissingAPIToken", func(t *testing.T) { diff --git a/pkg/clients/astro/team_membership.go b/pkg/clients/astro/team_membership.go index bd0494be..f87f8a50 100644 --- a/pkg/clients/astro/team_membership.go +++ b/pkg/clients/astro/team_membership.go @@ -1,5 +1,5 @@ /* -Copyright 2025. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -38,7 +38,7 @@ func (c *AstroClient) FetchTeamMembersByTeamID(ctx context.Context, log.Info("fetching team members by team ID") members := make(map[string]*structs.User) - baseEndpoint := fmt.Sprintf("%s/teams/%s/members", c.getOrganizationEndpoint(), teamID) + baseEndpoint := fmt.Sprintf("/teams/%s/members", teamID) err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { var membersResp AstroTeamMembersResponse @@ -93,7 +93,7 @@ func (c *AstroClient) AddUserToTeam(ctx context.Context, teamID string, userIDs return nil } - endpoint := fmt.Sprintf("%s/teams/%s/members", c.getOrganizationEndpoint(), teamID) + endpoint := fmt.Sprintf("/teams/%s/members", teamID) payload := AddTeamMembersRequest{ MemberIDs: userIDs, @@ -130,7 +130,7 @@ func (c *AstroClient) RemoveUserFromTeam(ctx context.Context, teamID string, use // Astro requires removing users one at a time for _, userID := range userIDs { - endpoint := fmt.Sprintf("%s/teams/%s/members/%s", c.getOrganizationEndpoint(), teamID, userID) + endpoint := fmt.Sprintf("/teams/%s/members/%s", teamID, userID) resp, status, err := c.makeRequest(ctx, endpoint, http.MethodDelete, nil) if err != nil { diff --git a/pkg/clients/astro/teams.go b/pkg/clients/astro/teams.go index e2ddd49e..3318068d 100644 --- a/pkg/clients/astro/teams.go +++ b/pkg/clients/astro/teams.go @@ -1,5 +1,5 @@ /* -Copyright 2025. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -44,7 +44,7 @@ func (c *AstroClient) FetchAllTeams(ctx context.Context) (map[string]structs.Tea log.Info("fetching all teams") teams := make(map[string]structs.Team) - baseEndpoint := c.getOrganizationEndpoint() + "/teams" + baseEndpoint := "/teams" err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { var teamsResp AstroTeamsResponse @@ -77,7 +77,7 @@ func (c *AstroClient) FetchTeamDetails(ctx context.Context, teamID string) (*str log.Info("fetching team details") - endpoint := fmt.Sprintf("%s/teams/%s", c.getOrganizationEndpoint(), teamID) + endpoint := fmt.Sprintf("/teams/%s", teamID) resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) if err != nil { return nil, err @@ -109,7 +109,7 @@ func (c *AstroClient) CreateTeam(ctx context.Context, team *structs.Team) (*stru }) log.Info("creating team") - endpoint := c.getOrganizationEndpoint() + "/teams" + endpoint := "/teams" payload := CreateTeamRequest{ Name: team.Name, @@ -163,7 +163,7 @@ func (c *AstroClient) DeleteTeamByID(ctx context.Context, teamID string) error { }) log.Info("deleting team") - endpoint := fmt.Sprintf("%s/teams/%s", c.getOrganizationEndpoint(), teamID) + endpoint := fmt.Sprintf("/teams/%s", teamID) resp, status, err := c.makeRequest(ctx, endpoint, http.MethodDelete, nil) if err != nil { diff --git a/pkg/clients/astro/types.go b/pkg/clients/astro/types.go index 9020ab32..2a0d8128 100644 --- a/pkg/clients/astro/types.go +++ b/pkg/clients/astro/types.go @@ -1,5 +1,5 @@ /* -Copyright 2025. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -137,7 +137,7 @@ type UpdateUserRoleRequest struct { // Astro API constants const ( // Default pagination limit for Astro API - DefaultPageLimit = 100 + DefaultPageLimit = 1000 // Default organization role for new users and teams DefaultOrganizationRole = "ORGANIZATION_MEMBER" diff --git a/pkg/clients/astro/users.go b/pkg/clients/astro/users.go index 6529761a..98551ffa 100644 --- a/pkg/clients/astro/users.go +++ b/pkg/clients/astro/users.go @@ -1,5 +1,5 @@ /* -Copyright 2025. +Copyright 2026. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -62,7 +62,7 @@ func (c *AstroClient) FetchAllUsers(ctx context.Context) (map[string]*structs.Us resultByID := make(map[string]*structs.User) resultByEmail := make(map[string]*structs.User) - baseEndpoint := c.getOrganizationEndpoint() + "/users" + baseEndpoint := "/users" err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { var usersResp AstroUsersResponse @@ -97,7 +97,7 @@ func (c *AstroClient) FetchUserDetails(ctx context.Context, userID string) (*str }) log.Info("fetching user details by ID") - endpoint := fmt.Sprintf("%s/users/%s", c.getOrganizationEndpoint(), userID) + endpoint := fmt.Sprintf("/users/%s", userID) resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) if err != nil { return nil, err @@ -129,7 +129,7 @@ func (c *AstroClient) CreateUser(ctx context.Context, user *structs.User) (*stru }) log.Info("creating user (sending invitation)") - endpoint := c.getOrganizationEndpoint() + "/invites" + endpoint := "/invites" if user.Email == "" { return nil, fmt.Errorf("email is required for Astro user creation") @@ -190,7 +190,7 @@ func (c *AstroClient) DeleteUser(ctx context.Context, userID string) error { }) log.Info("deleting user (removing organization role)") - endpoint := fmt.Sprintf("%s/users/%s/roles", c.getOrganizationEndpoint(), userID) + endpoint := fmt.Sprintf("/users/%s/roles", userID) // Set organizationRole to null to remove the user payload := UpdateUserRoleRequest{ From f6935ad63de0067a6ea935c09ac206f120a74f3c Mon Sep 17 00:00:00 2001 From: Shubham Date: Thu, 25 Jun 2026 10:27:06 +0530 Subject: [PATCH 3/5] use pointer for astroUserToStruct parameter Signed-off-by: Shubham --- pkg/clients/astro/users.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/clients/astro/users.go b/pkg/clients/astro/users.go index 98551ffa..ae51e51e 100644 --- a/pkg/clients/astro/users.go +++ b/pkg/clients/astro/users.go @@ -29,7 +29,7 @@ import ( ) // astroUserToStruct converts an AstroUser to a structs.User -func astroUserToStruct(user AstroUser) *structs.User { +func astroUserToStruct(user *AstroUser) *structs.User { // Extract first and last name from full name if available firstName := "" lastName := "" @@ -70,8 +70,8 @@ func (c *AstroClient) FetchAllUsers(ctx context.Context) (map[string]*structs.Us return 0, fmt.Errorf("failed to parse users response: %w", err) } - for _, user := range usersResp.Users { - structUser := astroUserToStruct(user) + for i := range usersResp.Users { + structUser := astroUserToStruct(&usersResp.Users[i]) resultByID[structUser.ID] = structUser if structUser.Email != "" { resultByEmail[structUser.Email] = structUser @@ -117,7 +117,7 @@ func (c *AstroClient) FetchUserDetails(ctx context.Context, userID string) (*str } log.Info("successfully fetched user details") - return astroUserToStruct(user), nil + return astroUserToStruct(&user), nil } // CreateUser creates a new user in Astro by sending an invitation From be16c3de25a9ddc58241cb76e0951747fa99490b Mon Sep 17 00:00:00 2001 From: Shubham Date: Mon, 29 Jun 2026 17:42:22 +0530 Subject: [PATCH 4/5] Keep default team naming pattern for Astro backend Signed-off-by: Shubham --- appconfig/default.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/appconfig/default.yaml b/appconfig/default.yaml index 91d1f03d..fd81cacc 100644 --- a/appconfig/default.yaml +++ b/appconfig/default.yaml @@ -30,10 +30,6 @@ pattern: - input: "^([^\\_]+)$" output: "$1" - astro: - - input: "^([^\\_]+)$" - output: "$1" - app: name: "usernaut" From f1b5736b3761159154cb2f89d238300b5e478981 Mon Sep 17 00:00:00 2001 From: Shubham Date: Wed, 22 Jul 2026 12:57:00 +0530 Subject: [PATCH 5/5] Address Review Comments Signed-off-by: Shubham --- pkg/clients/astro/client.go | 5 +-- pkg/clients/astro/client_test.go | 49 ++++++++++++++++++----- pkg/clients/astro/team_membership.go | 11 +---- pkg/clients/astro/teams.go | 54 ++++++++++++++++++------- pkg/clients/astro/types.go | 60 +++++----------------------- pkg/clients/astro/users.go | 29 ++++++++------ 6 files changed, 110 insertions(+), 98 deletions(-) diff --git a/pkg/clients/astro/client.go b/pkg/clients/astro/client.go index bd864838..9204b20a 100644 --- a/pkg/clients/astro/client.go +++ b/pkg/clients/astro/client.go @@ -50,9 +50,8 @@ func NewClient(connection map[string]interface{}, poolCfg httpclient.ConnectionP baseURL = fmt.Sprintf("%s/v1/organizations/%s", baseURL, organizationID) config := AstroConfig{ - APIToken: apiToken, - OrganizationID: organizationID, - BaseURL: baseURL, + APIToken: apiToken, + BaseURL: baseURL, } client, err := httpclient.InitializeClient( diff --git a/pkg/clients/astro/client_test.go b/pkg/clients/astro/client_test.go index 1ba0f7ea..51525d70 100644 --- a/pkg/clients/astro/client_test.go +++ b/pkg/clients/astro/client_test.go @@ -36,9 +36,8 @@ func createTestClient(t *testing.T, handler http.Handler) (*AstroClient, *httpte client := &AstroClient{ config: &AstroConfig{ - APIToken: "test-token", - OrganizationID: "test-org-id", - BaseURL: server.URL + "/v1/organizations/test-org-id", + APIToken: "test-token", + BaseURL: server.URL + "/v1/organizations/test-org-id", }, client: server.Client(), } @@ -59,7 +58,6 @@ func TestNewClient(t *testing.T) { require.NoError(t, err) assert.NotNil(t, client) assert.Equal(t, "test-token", client.config.APIToken) - assert.Equal(t, "test-org-id", client.config.OrganizationID) assert.Equal(t, DefaultBaseURL+"/v1/organizations/test-org-id", client.config.BaseURL) }) @@ -112,14 +110,12 @@ func TestFetchAllUsers(t *testing.T) { ID: "user-1", Username: "user1@redhat.com", FullName: "User One", - Status: "ACTIVE", OrganizationRole: "ORGANIZATION_MEMBER", }, { ID: "user-2", Username: "user2@redhat.com", FullName: "User Two", - Status: "ACTIVE", OrganizationRole: "ORGANIZATION_MEMBER", }, }, @@ -329,6 +325,41 @@ func TestCreateTeam(t *testing.T) { assert.Equal(t, "new-team-id", created.ID) assert.Equal(t, "new-team", created.Name) }) + + t.Run("AlreadyExistsFetchesByName", func(t *testing.T) { + existingTeam := AstroTeam{ + ID: "existing-team-id", + Name: "existing-team", + Description: "Existing team", + OrganizationRole: "ORGANIZATION_MEMBER", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusBadRequest) + body := `{"message":"Invalid request: Team with name 'existing-team' already exists in the organization",` + + `"statusCode":400,"requestId":"test-req"}` + _, _ = w.Write([]byte(body)) + case r.Method == http.MethodGet: + assert.Equal(t, "existing-team", r.URL.Query().Get("names")) + _ = json.NewEncoder(w).Encode(AstroTeamsResponse{ + Teams: []AstroTeam{existingTeam}, + }) + default: + t.Fatalf("unexpected method %s", r.Method) + } + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + created, err := client.CreateTeam(ctx, &structs.Team{Name: "existing-team"}) + require.NoError(t, err) + assert.Equal(t, "existing-team-id", created.ID) + assert.Equal(t, "existing-team", created.Name) + }) } func TestDeleteTeamByID(t *testing.T) { @@ -477,15 +508,13 @@ func TestReconcileGroupParams(t *testing.T) { func TestGetConfig(t *testing.T) { client := &AstroClient{ config: &AstroConfig{ - APIToken: "test-token", - OrganizationID: "test-org", - BaseURL: "https://api.test.com", + APIToken: "test-token", + BaseURL: "https://api.test.com", }, } config := client.GetConfig() assert.Equal(t, "test-token", config.APIToken) - assert.Equal(t, "test-org", config.OrganizationID) assert.Equal(t, "https://api.test.com", config.BaseURL) } diff --git a/pkg/clients/astro/team_membership.go b/pkg/clients/astro/team_membership.go index f87f8a50..3cba17ea 100644 --- a/pkg/clients/astro/team_membership.go +++ b/pkg/clients/astro/team_membership.go @@ -47,16 +47,7 @@ func (c *AstroClient) FetchTeamMembersByTeamID(ctx context.Context, } for _, member := range membersResp.TeamMembers { - // Extract first and last name from full name if available - firstName := "" - lastName := "" - if member.FullName != "" { - parts := strings.SplitN(member.FullName, " ", 2) - firstName = parts[0] - if len(parts) > 1 { - lastName = parts[1] - } - } + firstName, lastName := splitFullName(member.FullName) members[member.UserID] = &structs.User{ ID: member.UserID, diff --git a/pkg/clients/astro/teams.go b/pkg/clients/astro/teams.go index 3318068d..cae19cea 100644 --- a/pkg/clients/astro/teams.go +++ b/pkg/clients/astro/teams.go @@ -21,6 +21,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" "github.com/redhat-data-and-ai/usernaut/pkg/logger" @@ -101,6 +103,39 @@ func (c *AstroClient) FetchTeamDetails(ctx context.Context, teamID string) (*str return &result, nil } +// fetchTeamByName looks up a team by name using the Astro names filter. +func (c *AstroClient) fetchTeamByName(ctx context.Context, name string) (*structs.Team, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamName": name, + }) + + endpoint := fmt.Sprintf("/teams?names=%s&limit=1", url.QueryEscape(name)) + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("failed to fetch team by name %s: status %s, body: %s", + name, http.StatusText(status), string(resp)) + } + + var teamsResp AstroTeamsResponse + if err := json.Unmarshal(resp, &teamsResp); err != nil { + return nil, fmt.Errorf("failed to parse teams response: %w", err) + } + + for _, team := range teamsResp.Teams { + if team.Name == name { + result := astroTeamToStruct(team) + log.WithField("teamID", result.ID).Info("found existing team by name") + return &result, nil + } + } + + return nil, fmt.Errorf("team not found by name: %s", name) +} + // CreateTeam creates a new team in Astro func (c *AstroClient) CreateTeam(ctx context.Context, team *structs.Team) (*structs.Team, error) { log := logger.Logger(ctx).WithFields(logrus.Fields{ @@ -123,20 +158,11 @@ func (c *AstroClient) CreateTeam(ctx context.Context, team *structs.Team) (*stru return nil, err } - // Handle conflict - team might already exist - if status == http.StatusConflict { - log.Info("team already exists, fetching existing team") - // Try to find the team by name - teams, fetchErr := c.FetchAllTeams(ctx) - if fetchErr != nil { - return nil, fetchErr - } - for _, existingTeam := range teams { - if existingTeam.Name == team.Name { - return &existingTeam, nil - } - } - return nil, fmt.Errorf("team conflict but couldn't find existing team: %s", team.Name) + // Astro returns 400 with "already exists" for duplicate team names. + if status == http.StatusConflict || + (status == http.StatusBadRequest && strings.Contains(strings.ToLower(string(resp)), "already exists")) { + log.Info("team already exists, fetching existing team by name") + return c.fetchTeamByName(ctx, team.Name) } if status != http.StatusOK && status != http.StatusCreated { diff --git a/pkg/clients/astro/types.go b/pkg/clients/astro/types.go index 2a0d8128..63eee6bd 100644 --- a/pkg/clients/astro/types.go +++ b/pkg/clients/astro/types.go @@ -20,9 +20,8 @@ import "github.com/gojek/heimdall/v7" // AstroConfig holds the configuration for Astro client type AstroConfig struct { - APIToken string - OrganizationID string - BaseURL string + APIToken string + BaseURL string } // AstroClient is the client for interacting with Astro REST API @@ -35,55 +34,26 @@ type AstroClient struct { type AstroUser struct { ID string `json:"id"` Username string `json:"username"` - Status string `json:"status,omitempty"` FullName string `json:"fullName,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` OrganizationRole string `json:"organizationRole,omitempty"` } // AstroUsersResponse represents the response from list users API type AstroUsersResponse struct { - Users []AstroUser `json:"users"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` - TotalCount int `json:"totalCount,omitempty"` + Users []AstroUser `json:"users"` } // AstroTeam represents a team object from Astro API response type AstroTeam struct { - ID string `json:"id"` - Name string `json:"name"` - OrganizationID string `json:"organizationId,omitempty"` - OrganizationRole string `json:"organizationRole,omitempty"` - Description string `json:"description,omitempty"` - IsIdpManaged bool `json:"isIdpManaged,omitempty"` - WorkspaceRoles []WorkspaceRole `json:"workspaceRoles,omitempty"` - DeploymentRoles []DeploymentRole `json:"deploymentRoles,omitempty"` - RolesCount int `json:"rolesCount,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` -} - -// WorkspaceRole represents a workspace role assignment -type WorkspaceRole struct { - WorkspaceID string `json:"workspaceId"` - Role string `json:"role"` -} - -// DeploymentRole represents a deployment role assignment -type DeploymentRole struct { - DeploymentID string `json:"deploymentId"` - Role string `json:"role"` + ID string `json:"id"` + Name string `json:"name"` + OrganizationRole string `json:"organizationRole,omitempty"` + Description string `json:"description,omitempty"` } // AstroTeamsResponse represents the response from list teams API type AstroTeamsResponse struct { - Teams []AstroTeam `json:"teams"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` - TotalCount int `json:"totalCount,omitempty"` + Teams []AstroTeam `json:"teams"` } // AstroTeamMember represents a team member from Astro API response @@ -96,17 +66,13 @@ type AstroTeamMember struct { // AstroTeamMembersResponse represents the response from list team members API type AstroTeamMembersResponse struct { TeamMembers []AstroTeamMember `json:"teamMembers"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` - TotalCount int `json:"totalCount,omitempty"` } // CreateTeamRequest represents the request body for creating a team type CreateTeamRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - MemberIDs []string `json:"memberIds,omitempty"` - OrganizationRole string `json:"organizationRole,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + OrganizationRole string `json:"organizationRole,omitempty"` } // CreateInviteRequest represents the request body for inviting a user @@ -118,10 +84,6 @@ type CreateInviteRequest struct { // CreateInviteResponse represents the response from invite user API type CreateInviteResponse struct { UserID string `json:"userId"` - Invite struct { - ID string `json:"id"` - InviteeEmail string `json:"inviteeEmail"` - } `json:"invite,omitempty"` } // AddTeamMembersRequest represents the request body for adding members to a team diff --git a/pkg/clients/astro/users.go b/pkg/clients/astro/users.go index ae51e51e..f0bcf69e 100644 --- a/pkg/clients/astro/users.go +++ b/pkg/clients/astro/users.go @@ -28,18 +28,22 @@ import ( "github.com/sirupsen/logrus" ) +// splitFullName splits a display name into first and last name on the first space. +func splitFullName(fullName string) (firstName, lastName string) { + if fullName == "" { + return "", "" + } + parts := strings.SplitN(fullName, " ", 2) + firstName = parts[0] + if len(parts) > 1 { + lastName = parts[1] + } + return firstName, lastName +} + // astroUserToStruct converts an AstroUser to a structs.User func astroUserToStruct(user *AstroUser) *structs.User { - // Extract first and last name from full name if available - firstName := "" - lastName := "" - if user.FullName != "" { - parts := strings.SplitN(user.FullName, " ", 2) - firstName = parts[0] - if len(parts) > 1 { - lastName = parts[1] - } - } + firstName, lastName := splitFullName(user.FullName) return &structs.User{ ID: user.ID, @@ -172,11 +176,12 @@ func (c *AstroClient) CreateUser(ctx context.Context, user *structs.User) (*stru log.WithField("userID", inviteResp.UserID).Info("user invitation sent successfully") + email := strings.ToLower(user.Email) // Return the created user with the ID from the response return &structs.User{ ID: inviteResp.UserID, - Email: user.Email, - UserName: user.Email, + Email: email, + UserName: email, Role: DefaultOrganizationRole, }, nil }