diff --git a/cache/cache.go b/cache/cache.go index 8e93f7f..65fa7af 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -53,6 +53,17 @@ func (c *Cache) OrganizationsByUser(sub string) []domain.Organization { return orgs } +func (c *Cache) AllOrganizations() []domain.Organization { + c.mu.RLock() + defer c.mu.RUnlock() + + orgs := make([]domain.Organization, 0, len(c.organizations)) + for _, org := range c.organizations { + orgs = append(orgs, org) + } + return orgs +} + func (c *Cache) ApiKeyByKey(key string) *domain.APIKey { c.mu.RLock() defer c.mu.RUnlock() @@ -100,6 +111,16 @@ func (c *Cache) Update(msg any, _ goamqp.Headers) (any, error) { c.organizations[m.ID.String()] = o c.addUser(m.Initiator, o) c.logger.With("org_id", m.ID.String(), "event", "OrganizationAdded").Debug("cache updated") + case *domain.UserAddedToOrganization: + org, exists := c.organizations[m.ID.String()] + if exists { + m.UpdateOrganization(&org) + c.organizations[m.ID.String()] = org + c.addUser(m.UserId, org) + c.logger.With("org_id", m.ID.String(), "user_id", m.UserId, "event", "UserAddedToOrganization").Debug("cache updated") + } else { + c.logger.With("org_id", m.ID.String(), "event", "UserAddedToOrganization").Warn("organization not found in cache") + } case *domain.APIKeyAdded: key := domain.APIKey{ Name: m.Name, @@ -117,6 +138,63 @@ func (c *Cache) Update(msg any, _ goamqp.Headers) (any, error) { org.APIKeys = append(org.APIKeys, key) c.organizations[m.OrganizationId] = org c.logger.With("org_id", m.OrganizationId, "key_name", m.Name, "event", "APIKeyAdded").Debug("cache updated") + case *domain.APIKeyRemoved: + orgId := m.ID.String() + org, exists := c.organizations[orgId] + if exists { + // Remove from organization's API keys list + for i, key := range org.APIKeys { + if key.Name == m.KeyName { + org.APIKeys = append(org.APIKeys[:i], org.APIKeys[i+1:]...) + break + } + } + c.organizations[orgId] = org + // Remove from apiKeys map + delete(c.apiKeys, apiKeyId(orgId, m.KeyName)) + c.logger.With("org_id", orgId, "key_name", m.KeyName, "event", "APIKeyRemoved").Debug("cache updated") + } else { + c.logger.With("org_id", orgId, "event", "APIKeyRemoved").Warn("organization not found in cache") + } + case *domain.OrganizationRemoved: + orgId := m.ID.String() + org, exists := c.organizations[orgId] + if exists { + // Remove all API keys for this organization + for _, key := range org.APIKeys { + delete(c.apiKeys, apiKeyId(orgId, key.Name)) + } + // Remove organization from all users + for userId, userOrgs := range c.users { + for i, userOrgId := range userOrgs { + if userOrgId == orgId { + c.users[userId] = append(userOrgs[:i], userOrgs[i+1:]...) + break + } + } + // If user has no more organizations, remove from map + if len(c.users[userId]) == 0 { + delete(c.users, userId) + } + } + // Remove services for this organization + if refs, exists := c.services[orgId]; exists { + for ref := range refs { + // Remove all subgraphs for this org/ref combination + for service := range refs[ref] { + delete(c.subGraphs, subGraphKey(orgId, ref, service)) + } + // Remove lastUpdate for this org/ref + delete(c.lastUpdate, refKey(orgId, ref)) + } + delete(c.services, orgId) + } + // Remove organization + delete(c.organizations, orgId) + c.logger.With("org_id", orgId, "event", "OrganizationRemoved").Debug("cache updated") + } else { + c.logger.With("org_id", orgId, "event", "OrganizationRemoved").Warn("organization not found in cache") + } case *domain.SubGraphUpdated: c.updateSubGraph(m.OrganizationId, m.Ref, m.ID.String(), m.Service, m.Time) c.logger.With("org_id", m.OrganizationId, "ref", m.Ref, "service", m.Service, "event", "SubGraphUpdated").Debug("cache updated") diff --git a/cache/cache_test.go b/cache/cache_test.go index b0d12f3..e4cde3d 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -445,3 +445,213 @@ func TestCache_ConcurrentReadsAndWrites(t *testing.T) { // Verify cache is in consistent state assert.GreaterOrEqual(t, len(c.organizations), numWriters) } + +func TestCache_Update_APIKeyRemoved(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + c := New(logger) + + orgID := uuid.New().String() + keyName := "test-key" + hashedKey := "hashed-key-value" + + // Add organization with API key + org := domain.Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString(orgID), + Name: "Test Org", + APIKeys: []domain.APIKey{ + { + Name: keyName, + OrganizationId: orgID, + Key: hashedKey, + Refs: []string{"main"}, + Read: true, + Publish: false, + }, + }, + } + c.organizations[orgID] = org + c.apiKeys[apiKeyId(orgID, keyName)] = org.APIKeys[0] + + // Verify key exists before removal + _, exists := c.apiKeys[apiKeyId(orgID, keyName)] + assert.True(t, exists) + + // Remove the API key + event := &domain.APIKeyRemoved{ + KeyName: keyName, + Initiator: "user-123", + } + event.ID = *eventsourced.IdFromString(orgID) + + _, err := c.Update(event, nil) + require.NoError(t, err) + + // Verify API key was removed from cache + _, exists = c.apiKeys[apiKeyId(orgID, keyName)] + assert.False(t, exists, "API key should be removed from cache") + + // Verify API key was removed from organization + updatedOrg := c.organizations[orgID] + assert.Len(t, updatedOrg.APIKeys, 0, "API key should be removed from organization") +} + +func TestCache_Update_APIKeyRemoved_MultipleKeys(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + c := New(logger) + + orgID := uuid.New().String() + + // Add organization with multiple API keys + org := domain.Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString(orgID), + Name: "Test Org", + APIKeys: []domain.APIKey{ + { + Name: "key1", + OrganizationId: orgID, + Key: "hash1", + }, + { + Name: "key2", + OrganizationId: orgID, + Key: "hash2", + }, + { + Name: "key3", + OrganizationId: orgID, + Key: "hash3", + }, + }, + } + c.organizations[orgID] = org + c.apiKeys[apiKeyId(orgID, "key1")] = org.APIKeys[0] + c.apiKeys[apiKeyId(orgID, "key2")] = org.APIKeys[1] + c.apiKeys[apiKeyId(orgID, "key3")] = org.APIKeys[2] + + // Remove the middle key + event := &domain.APIKeyRemoved{ + KeyName: "key2", + Initiator: "user-123", + } + event.ID = *eventsourced.IdFromString(orgID) + + _, err := c.Update(event, nil) + require.NoError(t, err) + + // Verify only key2 was removed + _, exists := c.apiKeys[apiKeyId(orgID, "key1")] + assert.True(t, exists, "key1 should still exist") + + _, exists = c.apiKeys[apiKeyId(orgID, "key2")] + assert.False(t, exists, "key2 should be removed") + + _, exists = c.apiKeys[apiKeyId(orgID, "key3")] + assert.True(t, exists, "key3 should still exist") + + // Verify organization has 2 keys remaining + updatedOrg := c.organizations[orgID] + assert.Len(t, updatedOrg.APIKeys, 2) + assert.Equal(t, "key1", updatedOrg.APIKeys[0].Name) + assert.Equal(t, "key3", updatedOrg.APIKeys[1].Name) +} + +func TestCache_Update_OrganizationRemoved(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + c := New(logger) + + orgID := uuid.New().String() + userSub := "user-123" + + // Add organization with API keys, users, and subgraphs + org := domain.Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString(orgID), + Name: "Test Org", + APIKeys: []domain.APIKey{ + { + Name: "key1", + OrganizationId: orgID, + Key: "hash1", + }, + }, + } + c.organizations[orgID] = org + c.apiKeys[apiKeyId(orgID, "key1")] = org.APIKeys[0] + c.users[userSub] = []string{orgID} + c.services[orgID] = map[string]map[string]struct{}{ + "main": { + "service1": {}, + }, + } + c.subGraphs[subGraphKey(orgID, "main", "service1")] = "subgraph-id" + c.lastUpdate[refKey(orgID, "main")] = "2024-01-01T12:00:00Z" + + // Remove the organization + event := &domain.OrganizationRemoved{ + Initiator: userSub, + } + event.ID = *eventsourced.IdFromString(orgID) + + _, err := c.Update(event, nil) + require.NoError(t, err) + + // Verify organization was removed + _, exists := c.organizations[orgID] + assert.False(t, exists, "Organization should be removed from cache") + + // Verify API keys were removed + _, exists = c.apiKeys[apiKeyId(orgID, "key1")] + assert.False(t, exists, "API keys should be removed from cache") + + // Verify user association was removed + userOrgs := c.users[userSub] + assert.NotContains(t, userOrgs, orgID, "User should not be associated with removed organization") + + // Verify services were removed + _, exists = c.services[orgID] + assert.False(t, exists, "Services should be removed from cache") + + // Verify subgraphs were removed + _, exists = c.subGraphs[subGraphKey(orgID, "main", "service1")] + assert.False(t, exists, "Subgraphs should be removed from cache") + + // Verify lastUpdate was removed + _, exists = c.lastUpdate[refKey(orgID, "main")] + assert.False(t, exists, "LastUpdate should be removed from cache") +} + +func TestCache_Update_OrganizationRemoved_MultipleUsers(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + c := New(logger) + + orgID := uuid.New().String() + user1 := "user-1" + user2 := "user-2" + otherOrgID := uuid.New().String() + + // Add organization + org := domain.Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString(orgID), + Name: "Test Org", + } + c.organizations[orgID] = org + + // Add users with multiple org associations + c.users[user1] = []string{orgID, otherOrgID} + c.users[user2] = []string{orgID} + + // Remove the organization + event := &domain.OrganizationRemoved{ + Initiator: user1, + } + event.ID = *eventsourced.IdFromString(orgID) + + _, err := c.Update(event, nil) + require.NoError(t, err) + + // Verify user1 still has otherOrgID but not removed orgID + assert.Len(t, c.users[user1], 1) + assert.Equal(t, otherOrgID, c.users[user1][0]) + + // Verify user2 has no organizations + assert.Len(t, c.users[user2], 0) +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 98b909a..f6d3249 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -92,7 +92,10 @@ func start(closeEvents chan error, logger *slog.Logger, connectToAmqpFunc func(u pg.WithEventTypes( &domain.SubGraphUpdated{}, &domain.OrganizationAdded{}, + &domain.UserAddedToOrganization{}, &domain.APIKeyAdded{}, + &domain.APIKeyRemoved{}, + &domain.OrganizationRemoved{}, ), ) if err != nil { @@ -127,10 +130,16 @@ func start(closeEvents chan error, logger *slog.Logger, connectToAmqpFunc func(u goamqp.EventStreamPublisher(publisher), goamqp.TransientEventStreamConsumer("SubGraph.Updated", serviceCache.Update, domain.SubGraphUpdated{}), goamqp.TransientEventStreamConsumer("Organization.Added", serviceCache.Update, domain.OrganizationAdded{}), + goamqp.TransientEventStreamConsumer("Organization.UserAdded", serviceCache.Update, domain.UserAddedToOrganization{}), goamqp.TransientEventStreamConsumer("Organization.APIKeyAdded", serviceCache.Update, domain.APIKeyAdded{}), + goamqp.TransientEventStreamConsumer("Organization.APIKeyRemoved", serviceCache.Update, domain.APIKeyRemoved{}), + goamqp.TransientEventStreamConsumer("Organization.Removed", serviceCache.Update, domain.OrganizationRemoved{}), goamqp.WithTypeMapping("SubGraph.Updated", domain.SubGraphUpdated{}), goamqp.WithTypeMapping("Organization.Added", domain.OrganizationAdded{}), + goamqp.WithTypeMapping("Organization.UserAdded", domain.UserAddedToOrganization{}), goamqp.WithTypeMapping("Organization.APIKeyAdded", domain.APIKeyAdded{}), + goamqp.WithTypeMapping("Organization.APIKeyRemoved", domain.APIKeyRemoved{}), + goamqp.WithTypeMapping("Organization.Removed", domain.OrganizationRemoved{}), } if err := conn.Start(rootCtx, setups...); err != nil { return fmt.Errorf("failed to setup AMQP: %v", err) diff --git a/domain/aggregates.go b/domain/aggregates.go index e7422ae..21c8222 100644 --- a/domain/aggregates.go +++ b/domain/aggregates.go @@ -23,6 +23,8 @@ func (o *Organization) Apply(event eventsourced.Event) error { switch e := event.(type) { case *OrganizationAdded: e.UpdateOrganization(o) + case *UserAddedToOrganization: + e.UpdateOrganization(o) case *APIKeyAdded: o.APIKeys = append(o.APIKeys, APIKey{ Name: e.Name, @@ -36,6 +38,10 @@ func (o *Organization) Apply(event eventsourced.Event) error { }) o.ChangedBy = e.Initiator o.ChangedAt = e.When() + case *APIKeyRemoved: + e.UpdateOrganization(o) + case *OrganizationRemoved: + e.UpdateOrganization(o) default: return fmt.Errorf("unexpected event type: %+v", event) } diff --git a/domain/commands.go b/domain/commands.go index 1037ab0..9a42a84 100644 --- a/domain/commands.go +++ b/domain/commands.go @@ -34,6 +34,37 @@ func (a AddOrganization) Event(context.Context) eventsourced.Event { var _ eventsourced.Command = AddOrganization{} +type AddUserToOrganization struct { + UserId string + Initiator string +} + +func (a AddUserToOrganization) Validate(_ context.Context, aggregate eventsourced.Aggregate) error { + if aggregate.Identity() == nil { + return fmt.Errorf("organization does not exist") + } + if len(a.UserId) == 0 { + return fmt.Errorf("userId is required") + } + // Check if user is already in the organization + org := aggregate.(*Organization) + for _, user := range org.Users { + if user == a.UserId { + return fmt.Errorf("user is already a member of this organization") + } + } + return nil +} + +func (a AddUserToOrganization) Event(context.Context) eventsourced.Event { + return &UserAddedToOrganization{ + UserId: a.UserId, + Initiator: a.Initiator, + } +} + +var _ eventsourced.Command = AddUserToOrganization{} + type AddAPIKey struct { Name string Key string @@ -79,6 +110,57 @@ func (a AddAPIKey) Event(context.Context) eventsourced.Event { var _ eventsourced.Command = AddAPIKey{} +type RemoveAPIKey struct { + KeyName string + Initiator string +} + +func (r RemoveAPIKey) Validate(_ context.Context, aggregate eventsourced.Aggregate) error { + if aggregate.Identity() == nil { + return fmt.Errorf("organization does not exist") + } + org := aggregate.(*Organization) + found := false + for _, k := range org.APIKeys { + if k.Name == r.KeyName { + found = true + break + } + } + if !found { + return fmt.Errorf("API key '%s' not found", r.KeyName) + } + return nil +} + +func (r RemoveAPIKey) Event(context.Context) eventsourced.Event { + return &APIKeyRemoved{ + KeyName: r.KeyName, + Initiator: r.Initiator, + } +} + +var _ eventsourced.Command = RemoveAPIKey{} + +type RemoveOrganization struct { + Initiator string +} + +func (r RemoveOrganization) Validate(_ context.Context, aggregate eventsourced.Aggregate) error { + if aggregate.Identity() == nil { + return fmt.Errorf("organization does not exist") + } + return nil +} + +func (r RemoveOrganization) Event(context.Context) eventsourced.Event { + return &OrganizationRemoved{ + Initiator: r.Initiator, + } +} + +var _ eventsourced.Command = RemoveOrganization{} + type UpdateSubGraph struct { OrganizationId string Ref string diff --git a/domain/commands_test.go b/domain/commands_test.go index 4fa063e..db60663 100644 --- a/domain/commands_test.go +++ b/domain/commands_test.go @@ -464,3 +464,114 @@ func TestUpdateSubGraph_Event(t *testing.T) { assert.Equal(t, "type Query { hello: String }", subGraphEvent.Sdl) assert.Equal(t, "user@example.com", subGraphEvent.Initiator) } + +// RemoveAPIKey tests + +func TestRemoveAPIKey_Validate_Success(t *testing.T) { + cmd := RemoveAPIKey{ + KeyName: "production-key", + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{ + { + Name: "production-key", + Key: "hashed-key", + }, + }, + } + + err := cmd.Validate(context.Background(), org) + assert.NoError(t, err) +} + +func TestRemoveAPIKey_Validate_OrganizationNotExists(t *testing.T) { + cmd := RemoveAPIKey{ + KeyName: "production-key", + Initiator: "user@example.com", + } + + org := &Organization{} // No identity means it doesn't exist + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + +func TestRemoveAPIKey_Validate_KeyNotFound(t *testing.T) { + cmd := RemoveAPIKey{ + KeyName: "non-existent-key", + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{ + { + Name: "production-key", + Key: "hashed-key", + }, + }, + } + + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Contains(t, err.Error(), "non-existent-key") +} + +func TestRemoveAPIKey_Event(t *testing.T) { + cmd := RemoveAPIKey{ + KeyName: "production-key", + Initiator: "user@example.com", + } + + event := cmd.Event(context.Background()) + require.NotNil(t, event) + + keyEvent, ok := event.(*APIKeyRemoved) + require.True(t, ok) + assert.Equal(t, "production-key", keyEvent.KeyName) + assert.Equal(t, "user@example.com", keyEvent.Initiator) +} + +// RemoveOrganization tests + +func TestRemoveOrganization_Validate_Success(t *testing.T) { + cmd := RemoveOrganization{ + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + Name: "Test Org", + } + + err := cmd.Validate(context.Background(), org) + assert.NoError(t, err) +} + +func TestRemoveOrganization_Validate_OrganizationNotExists(t *testing.T) { + cmd := RemoveOrganization{ + Initiator: "user@example.com", + } + + org := &Organization{} // No identity means it doesn't exist + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + +func TestRemoveOrganization_Event(t *testing.T) { + cmd := RemoveOrganization{ + Initiator: "user@example.com", + } + + event := cmd.Event(context.Background()) + require.NotNil(t, event) + + orgEvent, ok := event.(*OrganizationRemoved) + require.True(t, ok) + assert.Equal(t, "user@example.com", orgEvent.Initiator) +} diff --git a/domain/events.go b/domain/events.go index 227d61d..60a5d23 100644 --- a/domain/events.go +++ b/domain/events.go @@ -17,6 +17,24 @@ func (a *OrganizationAdded) UpdateOrganization(o *Organization) { o.ChangedAt = a.When() } +type UserAddedToOrganization struct { + eventsourced.BaseEvent + UserId string `json:"userId"` + Initiator string `json:"initiator"` +} + +func (a *UserAddedToOrganization) UpdateOrganization(o *Organization) { + // Check if user is already in the organization + for _, user := range o.Users { + if user == a.UserId { + return // User already exists, no need to add + } + } + o.Users = append(o.Users, a.UserId) + o.ChangedBy = a.Initiator + o.ChangedAt = a.When() +} + type APIKeyAdded struct { eventsourced.BaseEvent OrganizationId string `json:"organizationId"` @@ -34,6 +52,36 @@ func (a *APIKeyAdded) EnrichFromAggregate(aggregate eventsourced.Aggregate) { var _ eventsourced.EnrichableEvent = &APIKeyAdded{} +type APIKeyRemoved struct { + eventsourced.BaseEvent + KeyName string `json:"keyName"` + Initiator string `json:"initiator"` +} + +func (a *APIKeyRemoved) UpdateOrganization(o *Organization) { + // Remove the API key from the organization + for i, key := range o.APIKeys { + if key.Name == a.KeyName { + o.APIKeys = append(o.APIKeys[:i], o.APIKeys[i+1:]...) + break + } + } + o.ChangedBy = a.Initiator + o.ChangedAt = a.When() +} + +type OrganizationRemoved struct { + eventsourced.BaseEvent + Initiator string `json:"initiator"` +} + +func (a *OrganizationRemoved) UpdateOrganization(o *Organization) { + // Mark organization as removed by clearing critical fields + // The aggregate will still exist in the event store, but it's logically deleted + o.ChangedBy = a.Initiator + o.ChangedAt = a.When() +} + type SubGraphUpdated struct { eventsourced.BaseEvent OrganizationId string `json:"organizationId"` diff --git a/domain/events_test.go b/domain/events_test.go new file mode 100644 index 0000000..9d84df3 --- /dev/null +++ b/domain/events_test.go @@ -0,0 +1,254 @@ +package domain + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/unboundsoftware/eventsourced/eventsourced" +) + +func TestOrganizationAdded_UpdateOrganization(t *testing.T) { + event := &OrganizationAdded{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + Name: "Test Organization", + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + } + + event.UpdateOrganization(org) + + assert.Equal(t, "Test Organization", org.Name) + assert.Equal(t, []string{"user@example.com"}, org.Users) + assert.Equal(t, "user@example.com", org.CreatedBy) + assert.Equal(t, "user@example.com", org.ChangedBy) + assert.Equal(t, event.When(), org.CreatedAt) + assert.Equal(t, event.When(), org.ChangedAt) +} + +func TestUserAddedToOrganization_UpdateOrganization(t *testing.T) { + event := &UserAddedToOrganization{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + UserId: "new-user@example.com", + Initiator: "admin@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + Users: []string{"existing-user@example.com"}, + } + + event.UpdateOrganization(org) + + assert.Len(t, org.Users, 2) + assert.Contains(t, org.Users, "existing-user@example.com") + assert.Contains(t, org.Users, "new-user@example.com") + assert.Equal(t, "admin@example.com", org.ChangedBy) + assert.Equal(t, event.When(), org.ChangedAt) +} + +func TestUserAddedToOrganization_UpdateOrganization_DuplicateUser(t *testing.T) { + event := &UserAddedToOrganization{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + UserId: "existing-user@example.com", + Initiator: "admin@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + Users: []string{"existing-user@example.com"}, + ChangedBy: "previous-admin@example.com", + } + originalChangedBy := org.ChangedBy + originalChangedAt := org.ChangedAt + + event.UpdateOrganization(org) + + // User should not be added twice + assert.Len(t, org.Users, 1) + assert.Equal(t, "existing-user@example.com", org.Users[0]) + + // ChangedBy and ChangedAt should NOT be updated when user already exists (idempotent) + assert.Equal(t, originalChangedBy, org.ChangedBy) + assert.Equal(t, originalChangedAt, org.ChangedAt) +} + +func TestAPIKeyRemoved_UpdateOrganization(t *testing.T) { + event := &APIKeyRemoved{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + KeyName: "production-key", + Initiator: "admin@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{ + {Name: "dev-key", Key: "hashed-key-1"}, + {Name: "production-key", Key: "hashed-key-2"}, + {Name: "staging-key", Key: "hashed-key-3"}, + }, + } + + event.UpdateOrganization(org) + + assert.Len(t, org.APIKeys, 2) + assert.Equal(t, "dev-key", org.APIKeys[0].Name) + assert.Equal(t, "staging-key", org.APIKeys[1].Name) + assert.Equal(t, "admin@example.com", org.ChangedBy) + assert.Equal(t, event.When(), org.ChangedAt) +} + +func TestAPIKeyRemoved_UpdateOrganization_KeyNotFound(t *testing.T) { + event := &APIKeyRemoved{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + KeyName: "non-existent-key", + Initiator: "admin@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{ + {Name: "dev-key", Key: "hashed-key-1"}, + {Name: "production-key", Key: "hashed-key-2"}, + }, + } + + event.UpdateOrganization(org) + + // No keys should be removed + assert.Len(t, org.APIKeys, 2) + assert.Equal(t, "dev-key", org.APIKeys[0].Name) + assert.Equal(t, "production-key", org.APIKeys[1].Name) + + // But metadata should still be updated + assert.Equal(t, "admin@example.com", org.ChangedBy) + assert.Equal(t, event.When(), org.ChangedAt) +} + +func TestAPIKeyRemoved_UpdateOrganization_OnlyKey(t *testing.T) { + event := &APIKeyRemoved{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + KeyName: "only-key", + Initiator: "admin@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{ + {Name: "only-key", Key: "hashed-key"}, + }, + } + + event.UpdateOrganization(org) + + // All keys should be removed + assert.Len(t, org.APIKeys, 0) + assert.Equal(t, "admin@example.com", org.ChangedBy) + assert.Equal(t, event.When(), org.ChangedAt) +} + +func TestOrganizationRemoved_UpdateOrganization(t *testing.T) { + event := &OrganizationRemoved{ + BaseEvent: eventsourced.BaseEvent{ + EventTime: eventsourced.EventTime{ + Time: time.Now(), + }, + }, + Initiator: "admin@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + Name: "Test Organization", + Users: []string{"user1@example.com", "user2@example.com"}, + APIKeys: []APIKey{ + {Name: "key1", Key: "hashed-key-1"}, + }, + CreatedBy: "creator@example.com", + CreatedAt: time.Now().Add(-24 * time.Hour), + } + + event.UpdateOrganization(org) + + // Organization data remains (soft delete), but metadata is updated + assert.Equal(t, "Test Organization", org.Name) + assert.Len(t, org.Users, 2) + assert.Len(t, org.APIKeys, 1) + + // Metadata should be updated to reflect removal + assert.Equal(t, "admin@example.com", org.ChangedBy) + assert.Equal(t, event.When(), org.ChangedAt) +} + +func TestAPIKeyAdded_EnrichFromAggregate(t *testing.T) { + orgId := "org-123" + aggregate := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString(orgId), + } + + event := &APIKeyAdded{ + Name: "test-key", + Key: "hashed-key", + Refs: []string{"main"}, + Read: true, + Publish: false, + Initiator: "user@example.com", + } + + event.EnrichFromAggregate(aggregate) + + assert.Equal(t, orgId, event.OrganizationId) +} + +func TestSubGraphUpdated_Event(t *testing.T) { + // Verify SubGraphUpdated event structure + url := "http://service.example.com" + wsUrl := "ws://service.example.com" + + event := &SubGraphUpdated{ + OrganizationId: "org-123", + Ref: "main", + Service: "users-service", + Url: &url, + WSUrl: &wsUrl, + Sdl: "type Query { user: User }", + Initiator: "system", + } + + require.NotNil(t, event) + assert.Equal(t, "org-123", event.OrganizationId) + assert.Equal(t, "main", event.Ref) + assert.Equal(t, "users-service", event.Service) + assert.Equal(t, url, *event.Url) + assert.Equal(t, wsUrl, *event.WSUrl) + assert.Equal(t, "type Query { user: User }", event.Sdl) + assert.Equal(t, "system", event.Initiator) +} diff --git a/graph/generated/generated.go b/graph/generated/generated.go index 7ba0598..ea5adf5 100644 --- a/graph/generated/generated.go +++ b/graph/generated/generated.go @@ -61,9 +61,12 @@ type ComplexityRoot struct { } Mutation struct { - AddAPIKey func(childComplexity int, input *model.InputAPIKey) int - AddOrganization func(childComplexity int, name string) int - UpdateSubGraph func(childComplexity int, input model.InputSubGraph) int + AddAPIKey func(childComplexity int, input *model.InputAPIKey) int + AddOrganization func(childComplexity int, name string) int + AddUserToOrganization func(childComplexity int, organizationID string, userID string) int + RemoveAPIKey func(childComplexity int, organizationID string, keyName string) int + RemoveOrganization func(childComplexity int, organizationID string) int + UpdateSubGraph func(childComplexity int, input model.InputSubGraph) int } Organization struct { @@ -74,9 +77,10 @@ type ComplexityRoot struct { } Query struct { - LatestSchema func(childComplexity int, ref string) int - Organizations func(childComplexity int) int - Supergraph func(childComplexity int, ref string, isAfter *string) int + AllOrganizations func(childComplexity int) int + LatestSchema func(childComplexity int, ref string) int + Organizations func(childComplexity int) int + Supergraph func(childComplexity int, ref string, isAfter *string) int } SchemaUpdate struct { @@ -119,11 +123,15 @@ type ComplexityRoot struct { type MutationResolver interface { AddOrganization(ctx context.Context, name string) (*model.Organization, error) + AddUserToOrganization(ctx context.Context, organizationID string, userID string) (*model.Organization, error) AddAPIKey(ctx context.Context, input *model.InputAPIKey) (*model.APIKey, error) + RemoveAPIKey(ctx context.Context, organizationID string, keyName string) (*model.Organization, error) + RemoveOrganization(ctx context.Context, organizationID string) (bool, error) UpdateSubGraph(ctx context.Context, input model.InputSubGraph) (*model.SubGraph, error) } type QueryResolver interface { Organizations(ctx context.Context) ([]*model.Organization, error) + AllOrganizations(ctx context.Context) ([]*model.Organization, error) Supergraph(ctx context.Context, ref string, isAfter *string) (model.Supergraph, error) LatestSchema(ctx context.Context, ref string) (*model.SchemaUpdate, error) } @@ -215,6 +223,39 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Mutation.AddOrganization(childComplexity, args["name"].(string)), true + case "Mutation.addUserToOrganization": + if e.complexity.Mutation.AddUserToOrganization == nil { + break + } + + args, err := ec.field_Mutation_addUserToOrganization_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.AddUserToOrganization(childComplexity, args["organizationId"].(string), args["userId"].(string)), true + case "Mutation.removeAPIKey": + if e.complexity.Mutation.RemoveAPIKey == nil { + break + } + + args, err := ec.field_Mutation_removeAPIKey_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.RemoveAPIKey(childComplexity, args["organizationId"].(string), args["keyName"].(string)), true + case "Mutation.removeOrganization": + if e.complexity.Mutation.RemoveOrganization == nil { + break + } + + args, err := ec.field_Mutation_removeOrganization_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.RemoveOrganization(childComplexity, args["organizationId"].(string)), true case "Mutation.updateSubGraph": if e.complexity.Mutation.UpdateSubGraph == nil { break @@ -252,6 +293,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Organization.Users(childComplexity), true + case "Query.allOrganizations": + if e.complexity.Query.AllOrganizations == nil { + break + } + + return e.complexity.Query.AllOrganizations(childComplexity), true case "Query.latestSchema": if e.complexity.Query.LatestSchema == nil { break @@ -532,13 +579,17 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er var sources = []*ast.Source{ {Name: "../schema.graphqls", Input: `type Query { organizations: [Organization!]! @auth(user: true) - supergraph(ref: String!, isAfter: String): Supergraph! @auth(organization: true) - latestSchema(ref: String!): SchemaUpdate! @auth(organization: true) + allOrganizations: [Organization!]! @auth(user: true) + supergraph(ref: String!, isAfter: String): Supergraph! @auth(user: true, organization: true) + latestSchema(ref: String!): SchemaUpdate! @auth(user: true, organization: true) } type Mutation { addOrganization(name: String!): Organization! @auth(user: true) + addUserToOrganization(organizationId: ID!, userId: String!): Organization! @auth(user: true) addAPIKey(input: InputAPIKey): APIKey! @auth(user: true) + removeAPIKey(organizationId: ID!, keyName: String!): Organization! @auth(user: true) + removeOrganization(organizationId: ID!): Boolean! @auth(user: true) updateSubGraph(input: InputSubGraph!): SubGraph! @auth(organization: true) } @@ -663,6 +714,49 @@ func (ec *executionContext) field_Mutation_addOrganization_args(ctx context.Cont return args, nil } +func (ec *executionContext) field_Mutation_addUserToOrganization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "organizationId", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["organizationId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "userId", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["userId"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_removeAPIKey_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "organizationId", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["organizationId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "keyName", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["keyName"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_removeOrganization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "organizationId", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["organizationId"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_updateSubGraph_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1057,6 +1151,75 @@ func (ec *executionContext) fieldContext_Mutation_addOrganization(ctx context.Co return fc, nil } +func (ec *executionContext) _Mutation_addUserToOrganization(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_addUserToOrganization, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().AddUserToOrganization(ctx, fc.Args["organizationId"].(string), fc.Args["userId"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + user, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) + if err != nil { + var zeroVal *model.Organization + return zeroVal, err + } + if ec.directives.Auth == nil { + var zeroVal *model.Organization + return zeroVal, errors.New("directive auth is not implemented") + } + return ec.directives.Auth(ctx, nil, directive0, user, nil) + } + + next = directive1 + return next + }, + ec.marshalNOrganization2ᚖgitlabᚗcomᚋunboundsoftwareᚋschemasᚋgraphᚋmodelᚐOrganization, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_addUserToOrganization(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Organization_id(ctx, field) + case "name": + return ec.fieldContext_Organization_name(ctx, field) + case "users": + return ec.fieldContext_Organization_users(ctx, field) + case "apiKeys": + return ec.fieldContext_Organization_apiKeys(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_addUserToOrganization_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_addAPIKey(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1132,6 +1295,134 @@ func (ec *executionContext) fieldContext_Mutation_addAPIKey(ctx context.Context, return fc, nil } +func (ec *executionContext) _Mutation_removeAPIKey(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_removeAPIKey, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().RemoveAPIKey(ctx, fc.Args["organizationId"].(string), fc.Args["keyName"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + user, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) + if err != nil { + var zeroVal *model.Organization + return zeroVal, err + } + if ec.directives.Auth == nil { + var zeroVal *model.Organization + return zeroVal, errors.New("directive auth is not implemented") + } + return ec.directives.Auth(ctx, nil, directive0, user, nil) + } + + next = directive1 + return next + }, + ec.marshalNOrganization2ᚖgitlabᚗcomᚋunboundsoftwareᚋschemasᚋgraphᚋmodelᚐOrganization, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_removeAPIKey(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Organization_id(ctx, field) + case "name": + return ec.fieldContext_Organization_name(ctx, field) + case "users": + return ec.fieldContext_Organization_users(ctx, field) + case "apiKeys": + return ec.fieldContext_Organization_apiKeys(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_removeAPIKey_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_removeOrganization(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_removeOrganization, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().RemoveOrganization(ctx, fc.Args["organizationId"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + user, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) + if err != nil { + var zeroVal bool + return zeroVal, err + } + if ec.directives.Auth == nil { + var zeroVal bool + return zeroVal, errors.New("directive auth is not implemented") + } + return ec.directives.Auth(ctx, nil, directive0, user, nil) + } + + next = directive1 + return next + }, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_removeOrganization(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_removeOrganization_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_updateSubGraph(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1400,6 +1691,63 @@ func (ec *executionContext) fieldContext_Query_organizations(_ context.Context, return fc, nil } +func (ec *executionContext) _Query_allOrganizations(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_allOrganizations, + func(ctx context.Context) (any, error) { + return ec.resolvers.Query().AllOrganizations(ctx) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + user, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) + if err != nil { + var zeroVal []*model.Organization + return zeroVal, err + } + if ec.directives.Auth == nil { + var zeroVal []*model.Organization + return zeroVal, errors.New("directive auth is not implemented") + } + return ec.directives.Auth(ctx, nil, directive0, user, nil) + } + + next = directive1 + return next + }, + ec.marshalNOrganization2ᚕᚖgitlabᚗcomᚋunboundsoftwareᚋschemasᚋgraphᚋmodelᚐOrganizationᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_allOrganizations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Organization_id(ctx, field) + case "name": + return ec.fieldContext_Organization_name(ctx, field) + case "users": + return ec.fieldContext_Organization_users(ctx, field) + case "apiKeys": + return ec.fieldContext_Organization_apiKeys(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Query_supergraph(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1414,6 +1762,11 @@ func (ec *executionContext) _Query_supergraph(ctx context.Context, field graphql directive0 := next directive1 := func(ctx context.Context) (any, error) { + user, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) + if err != nil { + var zeroVal model.Supergraph + return zeroVal, err + } organization, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) if err != nil { var zeroVal model.Supergraph @@ -1423,7 +1776,7 @@ func (ec *executionContext) _Query_supergraph(ctx context.Context, field graphql var zeroVal model.Supergraph return zeroVal, errors.New("directive auth is not implemented") } - return ec.directives.Auth(ctx, nil, directive0, nil, organization) + return ec.directives.Auth(ctx, nil, directive0, user, organization) } next = directive1 @@ -1473,6 +1826,11 @@ func (ec *executionContext) _Query_latestSchema(ctx context.Context, field graph directive0 := next directive1 := func(ctx context.Context) (any, error) { + user, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) + if err != nil { + var zeroVal *model.SchemaUpdate + return zeroVal, err + } organization, err := ec.unmarshalOBoolean2ᚖbool(ctx, true) if err != nil { var zeroVal *model.SchemaUpdate @@ -1482,7 +1840,7 @@ func (ec *executionContext) _Query_latestSchema(ctx context.Context, field graph var zeroVal *model.SchemaUpdate return zeroVal, errors.New("directive auth is not implemented") } - return ec.directives.Auth(ctx, nil, directive0, nil, organization) + return ec.directives.Auth(ctx, nil, directive0, user, organization) } next = directive1 @@ -3938,6 +4296,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "addUserToOrganization": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_addUserToOrganization(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "addAPIKey": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_addAPIKey(ctx, field) @@ -3945,6 +4310,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "removeAPIKey": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_removeAPIKey(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "removeOrganization": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_removeOrganization(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "updateSubGraph": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_updateSubGraph(ctx, field) @@ -4069,6 +4448,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "allOrganizations": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_allOrganizations(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "supergraph": field := field diff --git a/graph/schema.graphqls b/graph/schema.graphqls index ad1df55..4356d2e 100644 --- a/graph/schema.graphqls +++ b/graph/schema.graphqls @@ -1,12 +1,16 @@ type Query { organizations: [Organization!]! @auth(user: true) - supergraph(ref: String!, isAfter: String): Supergraph! @auth(organization: true) - latestSchema(ref: String!): SchemaUpdate! @auth(organization: true) + allOrganizations: [Organization!]! @auth(user: true) + supergraph(ref: String!, isAfter: String): Supergraph! @auth(user: true, organization: true) + latestSchema(ref: String!): SchemaUpdate! @auth(user: true, organization: true) } type Mutation { addOrganization(name: String!): Organization! @auth(user: true) + addUserToOrganization(organizationId: ID!, userId: String!): Organization! @auth(user: true) addAPIKey(input: InputAPIKey): APIKey! @auth(user: true) + removeAPIKey(organizationId: ID!, keyName: String!): Organization! @auth(user: true) + removeOrganization(organizationId: ID!): Boolean! @auth(user: true) updateSubGraph(input: InputSubGraph!): SubGraph! @auth(organization: true) } diff --git a/graph/schema.resolvers.go b/graph/schema.resolvers.go index b426df5..d0df919 100644 --- a/graph/schema.resolvers.go +++ b/graph/schema.resolvers.go @@ -37,6 +37,24 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, name string) (*m return ToGqlOrganization(*org), nil } +// AddUserToOrganization is the resolver for the addUserToOrganization field. +func (r *mutationResolver) AddUserToOrganization(ctx context.Context, organizationID string, userID string) (*model.Organization, error) { + sub := middleware.UserFromContext(ctx) + org := &domain.Organization{BaseAggregate: eventsourced.BaseAggregateFromString(organizationID)} + h, err := r.handler(ctx, org) + if err != nil { + return nil, err + } + _, err = h.Handle(ctx, &domain.AddUserToOrganization{ + UserId: userID, + Initiator: sub, + }) + if err != nil { + return nil, err + } + return ToGqlOrganization(*org), nil +} + // AddAPIKey is the resolver for the addAPIKey field. func (r *mutationResolver) AddAPIKey(ctx context.Context, input *model.InputAPIKey) (*model.APIKey, error) { sub := middleware.UserFromContext(ctx) @@ -71,6 +89,41 @@ func (r *mutationResolver) AddAPIKey(ctx context.Context, input *model.InputAPIK }, nil } +// RemoveAPIKey is the resolver for the removeAPIKey field. +func (r *mutationResolver) RemoveAPIKey(ctx context.Context, organizationID string, keyName string) (*model.Organization, error) { + sub := middleware.UserFromContext(ctx) + org := &domain.Organization{BaseAggregate: eventsourced.BaseAggregateFromString(organizationID)} + h, err := r.handler(ctx, org) + if err != nil { + return nil, err + } + _, err = h.Handle(ctx, &domain.RemoveAPIKey{ + KeyName: keyName, + Initiator: sub, + }) + if err != nil { + return nil, err + } + return ToGqlOrganization(*org), nil +} + +// RemoveOrganization is the resolver for the removeOrganization field. +func (r *mutationResolver) RemoveOrganization(ctx context.Context, organizationID string) (bool, error) { + sub := middleware.UserFromContext(ctx) + org := &domain.Organization{BaseAggregate: eventsourced.BaseAggregateFromString(organizationID)} + h, err := r.handler(ctx, org) + if err != nil { + return false, err + } + _, err = h.Handle(ctx, &domain.RemoveOrganization{ + Initiator: sub, + }) + if err != nil { + return false, err + } + return true, nil +} + // UpdateSubGraph is the resolver for the updateSubGraph field. func (r *mutationResolver) UpdateSubGraph(ctx context.Context, input model.InputSubGraph) (*model.SubGraph, error) { orgId := middleware.OrganizationFromContext(ctx) @@ -183,13 +236,49 @@ func (r *queryResolver) Organizations(ctx context.Context) ([]*model.Organizatio return ToGqlOrganizations(orgs), nil } +// AllOrganizations is the resolver for the allOrganizations field. +func (r *queryResolver) AllOrganizations(ctx context.Context) ([]*model.Organization, error) { + // Check if user has admin role + if !middleware.UserHasRole(ctx, "admin") { + return nil, fmt.Errorf("unauthorized: admin role required") + } + + orgs := r.Cache.AllOrganizations() + return ToGqlOrganizations(orgs), nil +} + // Supergraph is the resolver for the supergraph field. func (r *queryResolver) Supergraph(ctx context.Context, ref string, isAfter *string) (model.Supergraph, error) { orgId := middleware.OrganizationFromContext(ctx) - _, err := r.apiKeyCanAccessRef(ctx, ref, false) - if err != nil { - return nil, err + userId := middleware.UserFromContext(ctx) + + r.Logger.Info("Supergraph query", + "ref", ref, + "orgId", orgId, + "userId", userId, + ) + + // If authenticated with API key (organization), check access + if orgId != "" { + _, err := r.apiKeyCanAccessRef(ctx, ref, false) + if err != nil { + r.Logger.Error("API key cannot access ref", "error", err, "ref", ref) + return nil, err + } + } else if userId != "" { + // For user authentication, check if user has access to ref through their organizations + userOrgs := r.Cache.OrganizationsByUser(userId) + if len(userOrgs) == 0 { + r.Logger.Error("User has no organizations", "userId", userId) + return nil, fmt.Errorf("user has no access to any organizations") + } + // Use the first organization's ID for querying + orgId = userOrgs[0].ID.String() + r.Logger.Info("Using organization from user context", "orgId", orgId) + } else { + return nil, fmt.Errorf("no authentication provided") } + after := "" if isAfter != nil { after = *isAfter @@ -241,16 +330,34 @@ func (r *queryResolver) Supergraph(ctx context.Context, ref string, isAfter *str // LatestSchema is the resolver for the latestSchema field. func (r *queryResolver) LatestSchema(ctx context.Context, ref string) (*model.SchemaUpdate, error) { orgId := middleware.OrganizationFromContext(ctx) + userId := middleware.UserFromContext(ctx) r.Logger.Info("LatestSchema query", "ref", ref, "orgId", orgId, + "userId", userId, ) - _, err := r.apiKeyCanAccessRef(ctx, ref, false) - if err != nil { - r.Logger.Error("API key cannot access ref", "error", err, "ref", ref) - return nil, err + // If authenticated with API key (organization), check access + if orgId != "" { + _, err := r.apiKeyCanAccessRef(ctx, ref, false) + if err != nil { + r.Logger.Error("API key cannot access ref", "error", err, "ref", ref) + return nil, err + } + } else if userId != "" { + // For user authentication, check if user has access to ref through their organizations + userOrgs := r.Cache.OrganizationsByUser(userId) + if len(userOrgs) == 0 { + r.Logger.Error("User has no organizations", "userId", userId) + return nil, fmt.Errorf("user has no access to any organizations") + } + // Use the first organization's ID for querying + // In a real-world scenario, you might want to check which org has access to this ref + orgId = userOrgs[0].ID.String() + r.Logger.Info("Using organization from user context", "orgId", orgId) + } else { + return nil, fmt.Errorf("no authentication provided") } // Get current services and schema diff --git a/middleware/auth.go b/middleware/auth.go index 8b57007..bac65ec 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -67,6 +67,37 @@ func UserFromContext(ctx context.Context) string { return "" } +func UserHasRole(ctx context.Context, role string) bool { + token, err := TokenFromContext(ctx) + if err != nil || token == nil { + return false + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return false + } + + // Check the custom roles claim + rolesInterface, ok := claims["https://unbound.se/roles"] + if !ok { + return false + } + + roles, ok := rolesInterface.([]interface{}) + if !ok { + return false + } + + for _, r := range roles { + if roleStr, ok := r.(string); ok && roleStr == role { + return true + } + } + + return false +} + func OrganizationFromContext(ctx context.Context) string { if value := ctx.Value(OrganizationKey); value != nil { if u, ok := value.(domain.Organization); ok { @@ -77,15 +108,42 @@ func OrganizationFromContext(ctx context.Context) string { } func (m *AuthMiddleware) Directive(ctx context.Context, _ interface{}, next graphql.Resolver, user *bool, organization *bool) (res interface{}, err error) { - if user != nil && *user { - if u := UserFromContext(ctx); u == "" { + userRequired := user != nil && *user + orgRequired := organization != nil && *organization + + u := UserFromContext(ctx) + orgId := OrganizationFromContext(ctx) + + fmt.Printf("[Auth Directive] userRequired=%v, orgRequired=%v, hasUser=%v, hasOrg=%v\n", + userRequired, orgRequired, u != "", orgId != "") + + // If both are required, it means EITHER is acceptable (OR logic) + if userRequired && orgRequired { + if u == "" && orgId == "" { + fmt.Printf("[Auth Directive] REJECTED: Neither user nor organization available\n") + return nil, fmt.Errorf("authentication required: provide either user token or organization API key") + } + fmt.Printf("[Auth Directive] ACCEPTED: Has user=%v OR organization=%v\n", u != "", orgId != "") + return next(ctx) + } + + // Only user required + if userRequired { + if u == "" { + fmt.Printf("[Auth Directive] REJECTED: No user available\n") return nil, fmt.Errorf("no user available in request") } + fmt.Printf("[Auth Directive] ACCEPTED: User authenticated\n") } - if organization != nil && *organization { - if orgId := OrganizationFromContext(ctx); orgId == "" { + + // Only organization required + if orgRequired { + if orgId == "" { + fmt.Printf("[Auth Directive] REJECTED: No organization available\n") return nil, fmt.Errorf("no organization available in request") } + fmt.Printf("[Auth Directive] ACCEPTED: Organization authenticated\n") } + return next(ctx) } diff --git a/middleware/auth_test.go b/middleware/auth_test.go index 3265e35..7f2e3ee 100644 --- a/middleware/auth_test.go +++ b/middleware/auth_test.go @@ -427,7 +427,10 @@ func TestAuthMiddleware_Directive_RequiresBoth(t *testing.T) { Name: "Test Org", } - // Test with both present + // When both user and organization are marked as acceptable, + // the directive uses OR logic - either one is sufficient + + // Test with both present - should succeed ctx := context.WithValue(context.Background(), UserKey, "user-123") ctx = context.WithValue(ctx, OrganizationKey, org) _, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { @@ -435,19 +438,27 @@ func TestAuthMiddleware_Directive_RequiresBoth(t *testing.T) { }, &requireUser, &requireOrg) assert.NoError(t, err) - // Test with only user + // Test with only user - should succeed (OR logic) ctx = context.WithValue(context.Background(), UserKey, "user-123") _, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { return "success", nil }, &requireUser, &requireOrg) - assert.Error(t, err) + assert.NoError(t, err) - // Test with only organization + // Test with only organization - should succeed (OR logic) ctx = context.WithValue(context.Background(), OrganizationKey, org) _, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { return "success", nil }, &requireUser, &requireOrg) + assert.NoError(t, err) + + // Test with neither - should fail + ctx = context.Background() + _, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { + return "success", nil + }, &requireUser, &requireOrg) assert.Error(t, err) + assert.Contains(t, err.Error(), "authentication required") } func TestAuthMiddleware_Directive_NoRequirements(t *testing.T) { @@ -462,3 +473,92 @@ func TestAuthMiddleware_Directive_NoRequirements(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "success", result) } + +func TestUserHasRole_WithValidRole(t *testing.T) { + // Create token with roles claim + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "user-123", + "https://unbound.se/roles": []interface{}{"admin", "user"}, + }) + + ctx := context.WithValue(context.Background(), mw.ContextKey{}, token) + + // Test for existing role + hasRole := UserHasRole(ctx, "admin") + assert.True(t, hasRole) + + hasRole = UserHasRole(ctx, "user") + assert.True(t, hasRole) +} + +func TestUserHasRole_WithoutRole(t *testing.T) { + // Create token with roles claim + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "user-123", + "https://unbound.se/roles": []interface{}{"user"}, + }) + + ctx := context.WithValue(context.Background(), mw.ContextKey{}, token) + + // Test for non-existing role + hasRole := UserHasRole(ctx, "admin") + assert.False(t, hasRole) +} + +func TestUserHasRole_WithoutRolesClaim(t *testing.T) { + // Create token without roles claim + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "user-123", + }) + + ctx := context.WithValue(context.Background(), mw.ContextKey{}, token) + + // Test should return false when roles claim is missing + hasRole := UserHasRole(ctx, "admin") + assert.False(t, hasRole) +} + +func TestUserHasRole_WithoutToken(t *testing.T) { + ctx := context.Background() + + // Test should return false when no token in context + hasRole := UserHasRole(ctx, "admin") + assert.False(t, hasRole) +} + +func TestUserHasRole_WithInvalidTokenType(t *testing.T) { + // Put invalid token type in context + ctx := context.WithValue(context.Background(), mw.ContextKey{}, "not-a-token") + + // Test should return false when token type is invalid + hasRole := UserHasRole(ctx, "admin") + assert.False(t, hasRole) +} + +func TestUserHasRole_WithInvalidRolesType(t *testing.T) { + // Create token with invalid roles type + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "user-123", + "https://unbound.se/roles": "not-an-array", + }) + + ctx := context.WithValue(context.Background(), mw.ContextKey{}, token) + + // Test should return false when roles type is invalid + hasRole := UserHasRole(ctx, "admin") + assert.False(t, hasRole) +} + +func TestUserHasRole_WithInvalidRoleElementType(t *testing.T) { + // Create token with invalid role element types + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "user-123", + "https://unbound.se/roles": []interface{}{123, 456}, // Numbers instead of strings + }) + + ctx := context.WithValue(context.Background(), mw.ContextKey{}, token) + + // Test should return false when role elements are not strings + hasRole := UserHasRole(ctx, "admin") + assert.False(t, hasRole) +} diff --git a/sdlmerge/sdlmerge.go b/sdlmerge/sdlmerge.go index 44d6751..ab8ee7b 100644 --- a/sdlmerge/sdlmerge.go +++ b/sdlmerge/sdlmerge.go @@ -1,6 +1,7 @@ package sdlmerge import ( + "bytes" "fmt" "strings" @@ -61,12 +62,13 @@ func MergeSDLs(SDLs ...string) (string, error) { return "", fmt.Errorf("merge ast: %w", err) } - out, err := astprinter.PrintString(&doc) - if err != nil { + // Format with indentation for better readability + buf := &bytes.Buffer{} + if err := astprinter.PrintIndent(&doc, []byte(" "), buf); err != nil { return "", fmt.Errorf("stringify schema: %w", err) } - return out, nil + return buf.String(), nil } func validateSubgraphs(subgraphs []string) error {