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.
This commit is contained in:
2025-11-22 18:37:07 +01:00
parent 335a9f3b54
commit ffcf41b85a
14 changed files with 1500 additions and 30 deletions
+78
View File
@@ -53,6 +53,17 @@ func (c *Cache) OrganizationsByUser(sub string) []domain.Organization {
return orgs 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 { func (c *Cache) ApiKeyByKey(key string) *domain.APIKey {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() 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.organizations[m.ID.String()] = o
c.addUser(m.Initiator, o) c.addUser(m.Initiator, o)
c.logger.With("org_id", m.ID.String(), "event", "OrganizationAdded").Debug("cache updated") 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: case *domain.APIKeyAdded:
key := domain.APIKey{ key := domain.APIKey{
Name: m.Name, Name: m.Name,
@@ -117,6 +138,63 @@ func (c *Cache) Update(msg any, _ goamqp.Headers) (any, error) {
org.APIKeys = append(org.APIKeys, key) org.APIKeys = append(org.APIKeys, key)
c.organizations[m.OrganizationId] = org c.organizations[m.OrganizationId] = org
c.logger.With("org_id", m.OrganizationId, "key_name", m.Name, "event", "APIKeyAdded").Debug("cache updated") 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: case *domain.SubGraphUpdated:
c.updateSubGraph(m.OrganizationId, m.Ref, m.ID.String(), m.Service, m.Time) 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") c.logger.With("org_id", m.OrganizationId, "ref", m.Ref, "service", m.Service, "event", "SubGraphUpdated").Debug("cache updated")
+210
View File
@@ -445,3 +445,213 @@ func TestCache_ConcurrentReadsAndWrites(t *testing.T) {
// Verify cache is in consistent state // Verify cache is in consistent state
assert.GreaterOrEqual(t, len(c.organizations), numWriters) 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)
}
+9
View File
@@ -92,7 +92,10 @@ func start(closeEvents chan error, logger *slog.Logger, connectToAmqpFunc func(u
pg.WithEventTypes( pg.WithEventTypes(
&domain.SubGraphUpdated{}, &domain.SubGraphUpdated{},
&domain.OrganizationAdded{}, &domain.OrganizationAdded{},
&domain.UserAddedToOrganization{},
&domain.APIKeyAdded{}, &domain.APIKeyAdded{},
&domain.APIKeyRemoved{},
&domain.OrganizationRemoved{},
), ),
) )
if err != nil { if err != nil {
@@ -127,10 +130,16 @@ func start(closeEvents chan error, logger *slog.Logger, connectToAmqpFunc func(u
goamqp.EventStreamPublisher(publisher), goamqp.EventStreamPublisher(publisher),
goamqp.TransientEventStreamConsumer("SubGraph.Updated", serviceCache.Update, domain.SubGraphUpdated{}), goamqp.TransientEventStreamConsumer("SubGraph.Updated", serviceCache.Update, domain.SubGraphUpdated{}),
goamqp.TransientEventStreamConsumer("Organization.Added", serviceCache.Update, domain.OrganizationAdded{}), 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.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("SubGraph.Updated", domain.SubGraphUpdated{}),
goamqp.WithTypeMapping("Organization.Added", domain.OrganizationAdded{}), goamqp.WithTypeMapping("Organization.Added", domain.OrganizationAdded{}),
goamqp.WithTypeMapping("Organization.UserAdded", domain.UserAddedToOrganization{}),
goamqp.WithTypeMapping("Organization.APIKeyAdded", domain.APIKeyAdded{}), 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 { if err := conn.Start(rootCtx, setups...); err != nil {
return fmt.Errorf("failed to setup AMQP: %v", err) return fmt.Errorf("failed to setup AMQP: %v", err)
+6
View File
@@ -23,6 +23,8 @@ func (o *Organization) Apply(event eventsourced.Event) error {
switch e := event.(type) { switch e := event.(type) {
case *OrganizationAdded: case *OrganizationAdded:
e.UpdateOrganization(o) e.UpdateOrganization(o)
case *UserAddedToOrganization:
e.UpdateOrganization(o)
case *APIKeyAdded: case *APIKeyAdded:
o.APIKeys = append(o.APIKeys, APIKey{ o.APIKeys = append(o.APIKeys, APIKey{
Name: e.Name, Name: e.Name,
@@ -36,6 +38,10 @@ func (o *Organization) Apply(event eventsourced.Event) error {
}) })
o.ChangedBy = e.Initiator o.ChangedBy = e.Initiator
o.ChangedAt = e.When() o.ChangedAt = e.When()
case *APIKeyRemoved:
e.UpdateOrganization(o)
case *OrganizationRemoved:
e.UpdateOrganization(o)
default: default:
return fmt.Errorf("unexpected event type: %+v", event) return fmt.Errorf("unexpected event type: %+v", event)
} }
+82
View File
@@ -34,6 +34,37 @@ func (a AddOrganization) Event(context.Context) eventsourced.Event {
var _ eventsourced.Command = AddOrganization{} 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 { type AddAPIKey struct {
Name string Name string
Key string Key string
@@ -79,6 +110,57 @@ func (a AddAPIKey) Event(context.Context) eventsourced.Event {
var _ eventsourced.Command = AddAPIKey{} 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 { type UpdateSubGraph struct {
OrganizationId string OrganizationId string
Ref string Ref string
+111
View File
@@ -464,3 +464,114 @@ func TestUpdateSubGraph_Event(t *testing.T) {
assert.Equal(t, "type Query { hello: String }", subGraphEvent.Sdl) assert.Equal(t, "type Query { hello: String }", subGraphEvent.Sdl)
assert.Equal(t, "user@example.com", subGraphEvent.Initiator) 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)
}
+48
View File
@@ -17,6 +17,24 @@ func (a *OrganizationAdded) UpdateOrganization(o *Organization) {
o.ChangedAt = a.When() 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 { type APIKeyAdded struct {
eventsourced.BaseEvent eventsourced.BaseEvent
OrganizationId string `json:"organizationId"` OrganizationId string `json:"organizationId"`
@@ -34,6 +52,36 @@ func (a *APIKeyAdded) EnrichFromAggregate(aggregate eventsourced.Aggregate) {
var _ eventsourced.EnrichableEvent = &APIKeyAdded{} 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 { type SubGraphUpdated struct {
eventsourced.BaseEvent eventsourced.BaseEvent
OrganizationId string `json:"organizationId"` OrganizationId string `json:"organizationId"`
+254
View File
@@ -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)
}
+411 -10
View File
@@ -61,9 +61,12 @@ type ComplexityRoot struct {
} }
Mutation struct { Mutation struct {
AddAPIKey func(childComplexity int, input *model.InputAPIKey) int AddAPIKey func(childComplexity int, input *model.InputAPIKey) int
AddOrganization func(childComplexity int, name string) int AddOrganization func(childComplexity int, name string) int
UpdateSubGraph func(childComplexity int, input model.InputSubGraph) 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 { Organization struct {
@@ -74,9 +77,10 @@ type ComplexityRoot struct {
} }
Query struct { Query struct {
LatestSchema func(childComplexity int, ref string) int AllOrganizations func(childComplexity int) int
Organizations func(childComplexity int) int LatestSchema func(childComplexity int, ref string) int
Supergraph func(childComplexity int, ref string, isAfter *string) int Organizations func(childComplexity int) int
Supergraph func(childComplexity int, ref string, isAfter *string) int
} }
SchemaUpdate struct { SchemaUpdate struct {
@@ -119,11 +123,15 @@ type ComplexityRoot struct {
type MutationResolver interface { type MutationResolver interface {
AddOrganization(ctx context.Context, name string) (*model.Organization, error) 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) 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) UpdateSubGraph(ctx context.Context, input model.InputSubGraph) (*model.SubGraph, error)
} }
type QueryResolver interface { type QueryResolver interface {
Organizations(ctx context.Context) ([]*model.Organization, error) 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) Supergraph(ctx context.Context, ref string, isAfter *string) (model.Supergraph, error)
LatestSchema(ctx context.Context, ref string) (*model.SchemaUpdate, 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 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": case "Mutation.updateSubGraph":
if e.complexity.Mutation.UpdateSubGraph == nil { if e.complexity.Mutation.UpdateSubGraph == nil {
break break
@@ -252,6 +293,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Organization.Users(childComplexity), true 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": case "Query.latestSchema":
if e.complexity.Query.LatestSchema == nil { if e.complexity.Query.LatestSchema == nil {
break break
@@ -532,13 +579,17 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er
var sources = []*ast.Source{ var sources = []*ast.Source{
{Name: "../schema.graphqls", Input: `type Query { {Name: "../schema.graphqls", Input: `type Query {
organizations: [Organization!]! @auth(user: true) organizations: [Organization!]! @auth(user: true)
supergraph(ref: String!, isAfter: String): Supergraph! @auth(organization: true) allOrganizations: [Organization!]! @auth(user: true)
latestSchema(ref: String!): SchemaUpdate! @auth(organization: true) supergraph(ref: String!, isAfter: String): Supergraph! @auth(user: true, organization: true)
latestSchema(ref: String!): SchemaUpdate! @auth(user: true, organization: true)
} }
type Mutation { type Mutation {
addOrganization(name: String!): Organization! @auth(user: true) addOrganization(name: String!): Organization! @auth(user: true)
addUserToOrganization(organizationId: ID!, userId: String!): Organization! @auth(user: true)
addAPIKey(input: InputAPIKey): APIKey! @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) updateSubGraph(input: InputSubGraph!): SubGraph! @auth(organization: true)
} }
@@ -663,6 +714,49 @@ func (ec *executionContext) field_Mutation_addOrganization_args(ctx context.Cont
return args, nil 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) { func (ec *executionContext) field_Mutation_updateSubGraph_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error var err error
args := map[string]any{} args := map[string]any{}
@@ -1057,6 +1151,75 @@ func (ec *executionContext) fieldContext_Mutation_addOrganization(ctx context.Co
return fc, nil 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) { func (ec *executionContext) _Mutation_addAPIKey(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -1132,6 +1295,134 @@ func (ec *executionContext) fieldContext_Mutation_addAPIKey(ctx context.Context,
return fc, nil 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) { func (ec *executionContext) _Mutation_updateSubGraph(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -1400,6 +1691,63 @@ func (ec *executionContext) fieldContext_Query_organizations(_ context.Context,
return fc, nil 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) { func (ec *executionContext) _Query_supergraph(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -1414,6 +1762,11 @@ func (ec *executionContext) _Query_supergraph(ctx context.Context, field graphql
directive0 := next directive0 := next
directive1 := func(ctx context.Context) (any, error) { 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) organization, err := ec.unmarshalOBoolean2ᚖbool(ctx, true)
if err != nil { if err != nil {
var zeroVal model.Supergraph var zeroVal model.Supergraph
@@ -1423,7 +1776,7 @@ func (ec *executionContext) _Query_supergraph(ctx context.Context, field graphql
var zeroVal model.Supergraph var zeroVal model.Supergraph
return zeroVal, errors.New("directive auth is not implemented") 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 next = directive1
@@ -1473,6 +1826,11 @@ func (ec *executionContext) _Query_latestSchema(ctx context.Context, field graph
directive0 := next directive0 := next
directive1 := func(ctx context.Context) (any, error) { 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) organization, err := ec.unmarshalOBoolean2ᚖbool(ctx, true)
if err != nil { if err != nil {
var zeroVal *model.SchemaUpdate var zeroVal *model.SchemaUpdate
@@ -1482,7 +1840,7 @@ func (ec *executionContext) _Query_latestSchema(ctx context.Context, field graph
var zeroVal *model.SchemaUpdate var zeroVal *model.SchemaUpdate
return zeroVal, errors.New("directive auth is not implemented") 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 next = directive1
@@ -3938,6 +4296,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ 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": case "addAPIKey":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_addAPIKey(ctx, field) 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 { if out.Values[i] == graphql.Null {
out.Invalids++ 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": case "updateSubGraph":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_updateSubGraph(ctx, field) 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) }) 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) }) out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "supergraph": case "supergraph":
field := field field := field
+6 -2
View File
@@ -1,12 +1,16 @@
type Query { type Query {
organizations: [Organization!]! @auth(user: true) organizations: [Organization!]! @auth(user: true)
supergraph(ref: String!, isAfter: String): Supergraph! @auth(organization: true) allOrganizations: [Organization!]! @auth(user: true)
latestSchema(ref: String!): SchemaUpdate! @auth(organization: true) supergraph(ref: String!, isAfter: String): Supergraph! @auth(user: true, organization: true)
latestSchema(ref: String!): SchemaUpdate! @auth(user: true, organization: true)
} }
type Mutation { type Mutation {
addOrganization(name: String!): Organization! @auth(user: true) addOrganization(name: String!): Organization! @auth(user: true)
addUserToOrganization(organizationId: ID!, userId: String!): Organization! @auth(user: true)
addAPIKey(input: InputAPIKey): APIKey! @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) updateSubGraph(input: InputSubGraph!): SubGraph! @auth(organization: true)
} }
+114 -7
View File
@@ -37,6 +37,24 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, name string) (*m
return ToGqlOrganization(*org), nil 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. // AddAPIKey is the resolver for the addAPIKey field.
func (r *mutationResolver) AddAPIKey(ctx context.Context, input *model.InputAPIKey) (*model.APIKey, error) { func (r *mutationResolver) AddAPIKey(ctx context.Context, input *model.InputAPIKey) (*model.APIKey, error) {
sub := middleware.UserFromContext(ctx) sub := middleware.UserFromContext(ctx)
@@ -71,6 +89,41 @@ func (r *mutationResolver) AddAPIKey(ctx context.Context, input *model.InputAPIK
}, nil }, 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. // UpdateSubGraph is the resolver for the updateSubGraph field.
func (r *mutationResolver) UpdateSubGraph(ctx context.Context, input model.InputSubGraph) (*model.SubGraph, error) { func (r *mutationResolver) UpdateSubGraph(ctx context.Context, input model.InputSubGraph) (*model.SubGraph, error) {
orgId := middleware.OrganizationFromContext(ctx) orgId := middleware.OrganizationFromContext(ctx)
@@ -183,13 +236,49 @@ func (r *queryResolver) Organizations(ctx context.Context) ([]*model.Organizatio
return ToGqlOrganizations(orgs), nil 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. // Supergraph is the resolver for the supergraph field.
func (r *queryResolver) Supergraph(ctx context.Context, ref string, isAfter *string) (model.Supergraph, error) { func (r *queryResolver) Supergraph(ctx context.Context, ref string, isAfter *string) (model.Supergraph, error) {
orgId := middleware.OrganizationFromContext(ctx) orgId := middleware.OrganizationFromContext(ctx)
_, err := r.apiKeyCanAccessRef(ctx, ref, false) userId := middleware.UserFromContext(ctx)
if err != nil {
return nil, err 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 := "" after := ""
if isAfter != nil { if isAfter != nil {
after = *isAfter 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. // LatestSchema is the resolver for the latestSchema field.
func (r *queryResolver) LatestSchema(ctx context.Context, ref string) (*model.SchemaUpdate, error) { func (r *queryResolver) LatestSchema(ctx context.Context, ref string) (*model.SchemaUpdate, error) {
orgId := middleware.OrganizationFromContext(ctx) orgId := middleware.OrganizationFromContext(ctx)
userId := middleware.UserFromContext(ctx)
r.Logger.Info("LatestSchema query", r.Logger.Info("LatestSchema query",
"ref", ref, "ref", ref,
"orgId", orgId, "orgId", orgId,
"userId", userId,
) )
_, err := r.apiKeyCanAccessRef(ctx, ref, false) // If authenticated with API key (organization), check access
if err != nil { if orgId != "" {
r.Logger.Error("API key cannot access ref", "error", err, "ref", ref) _, err := r.apiKeyCanAccessRef(ctx, ref, false)
return nil, err 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 // Get current services and schema
+62 -4
View File
@@ -67,6 +67,37 @@ func UserFromContext(ctx context.Context) string {
return "" 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 { func OrganizationFromContext(ctx context.Context) string {
if value := ctx.Value(OrganizationKey); value != nil { if value := ctx.Value(OrganizationKey); value != nil {
if u, ok := value.(domain.Organization); ok { 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) { func (m *AuthMiddleware) Directive(ctx context.Context, _ interface{}, next graphql.Resolver, user *bool, organization *bool) (res interface{}, err error) {
if user != nil && *user { userRequired := user != nil && *user
if u := UserFromContext(ctx); u == "" { 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") 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") return nil, fmt.Errorf("no organization available in request")
} }
fmt.Printf("[Auth Directive] ACCEPTED: Organization authenticated\n")
} }
return next(ctx) return next(ctx)
} }
+104 -4
View File
@@ -427,7 +427,10 @@ func TestAuthMiddleware_Directive_RequiresBoth(t *testing.T) {
Name: "Test Org", 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(context.Background(), UserKey, "user-123")
ctx = context.WithValue(ctx, OrganizationKey, org) ctx = context.WithValue(ctx, OrganizationKey, org)
_, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { _, err := authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
@@ -435,19 +438,27 @@ func TestAuthMiddleware_Directive_RequiresBoth(t *testing.T) {
}, &requireUser, &requireOrg) }, &requireUser, &requireOrg)
assert.NoError(t, err) assert.NoError(t, err)
// Test with only user // Test with only user - should succeed (OR logic)
ctx = context.WithValue(context.Background(), UserKey, "user-123") ctx = context.WithValue(context.Background(), UserKey, "user-123")
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { _, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
return "success", nil return "success", nil
}, &requireUser, &requireOrg) }, &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) ctx = context.WithValue(context.Background(), OrganizationKey, org)
_, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) { _, err = authMiddleware.Directive(ctx, nil, func(ctx context.Context) (interface{}, error) {
return "success", nil return "success", nil
}, &requireUser, &requireOrg) }, &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.Error(t, err)
assert.Contains(t, err.Error(), "authentication required")
} }
func TestAuthMiddleware_Directive_NoRequirements(t *testing.T) { func TestAuthMiddleware_Directive_NoRequirements(t *testing.T) {
@@ -462,3 +473,92 @@ func TestAuthMiddleware_Directive_NoRequirements(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "success", result) 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)
}
+5 -3
View File
@@ -1,6 +1,7 @@
package sdlmerge package sdlmerge
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
@@ -61,12 +62,13 @@ func MergeSDLs(SDLs ...string) (string, error) {
return "", fmt.Errorf("merge ast: %w", err) return "", fmt.Errorf("merge ast: %w", err)
} }
out, err := astprinter.PrintString(&doc) // Format with indentation for better readability
if err != nil { buf := &bytes.Buffer{}
if err := astprinter.PrintIndent(&doc, []byte(" "), buf); err != nil {
return "", fmt.Errorf("stringify schema: %w", err) return "", fmt.Errorf("stringify schema: %w", err)
} }
return out, nil return buf.String(), nil
} }
func validateSubgraphs(subgraphs []string) error { func validateSubgraphs(subgraphs []string) error {