From ffcf41b85a271f85a9fd2809dcd748237f4f55c5 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Sat, 22 Nov 2025 18:37:07 +0100 Subject: [PATCH] feat: add commands for managing organizations and users Introduce `AddUserToOrganization`, `RemoveAPIKey`, and `RemoveOrganization` commands to enhance organization management. Implement validation for user addition and API key removal. Update GraphQL schema to support new mutations and add caching for the new events, ensuring that organizations and their relationships are accurately represented in the cache. --- cache/cache.go | 78 +++++++ cache/cache_test.go | 210 +++++++++++++++++ cmd/service/service.go | 9 + domain/aggregates.go | 6 + domain/commands.go | 82 +++++++ domain/commands_test.go | 111 +++++++++ domain/events.go | 48 ++++ domain/events_test.go | 254 +++++++++++++++++++++ graph/generated/generated.go | 421 ++++++++++++++++++++++++++++++++++- graph/schema.graphqls | 8 +- graph/schema.resolvers.go | 121 +++++++++- middleware/auth.go | 66 +++++- middleware/auth_test.go | 108 ++++++++- sdlmerge/sdlmerge.go | 8 +- 14 files changed, 1500 insertions(+), 30 deletions(-) create mode 100644 domain/events_test.go 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 {