From e6da56a6bab23b6b1d6938be790b318f0e499ae3 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Fri, 7 Nov 2025 14:28:51 +1100 Subject: [PATCH 1/2] feat: add service to service access --- cli/internal/simulation/simulation.go | 12 +++++ cli/pkg/schema/access.go | 2 + cli/pkg/schema/service.go | 6 +++ cli/pkg/schema/validate.go | 21 +++++++++ engines/terraform/resource_handler.go | 64 +++++++++++++++++++++++++-- engines/terraform/suga.go | 1 + 6 files changed, 102 insertions(+), 4 deletions(-) diff --git a/cli/internal/simulation/simulation.go b/cli/internal/simulation/simulation.go index 19e79724..604a2e7d 100644 --- a/cli/internal/simulation/simulation.go +++ b/cli/internal/simulation/simulation.go @@ -274,6 +274,7 @@ func (s *SimulationServer) CopyDir(dst, src string) error { func (s *SimulationServer) startServices(output io.Writer) (<-chan service.ServiceEvent, error) { serviceIntents := s.appSpec.ServiceIntents + servicePorts := make(map[string]netx.ReservedPort) eventChans := []<-chan service.ServiceEvent{} for serviceName, serviceIntent := range serviceIntents { @@ -281,6 +282,7 @@ func (s *SimulationServer) startServices(output io.Writer) (<-chan service.Servi if err != nil { return nil, err } + servicePorts[serviceName] = port // Clone the service intent to add database connection strings intentCopy := *serviceIntent @@ -304,6 +306,16 @@ func (s *SimulationServer) startServices(output io.Writer) (<-chan service.Servi } } + // Inject URLs for services this service depends on + if access, ok := intentCopy.GetAccess(); ok { + for targetServiceName := range access { + if targetPort, exists := servicePorts[targetServiceName]; exists { + envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName)) + intentCopy.Env[envVarName] = fmt.Sprintf("http://localhost:%d", targetPort) + } + } + } + simulatedService, eventChan, err := service.NewServiceSimulation(serviceName, intentCopy, port, s.apiPort) if err != nil { return nil, err diff --git a/cli/pkg/schema/access.go b/cli/pkg/schema/access.go index a503d2b3..54be8046 100644 --- a/cli/pkg/schema/access.go +++ b/cli/pkg/schema/access.go @@ -7,6 +7,7 @@ type ResourceType string const ( Database ResourceType = "database" Bucket ResourceType = "bucket" + Service ResourceType = "service" ) const allAccess = "all" @@ -14,6 +15,7 @@ const allAccess = "all" var validActions = map[ResourceType][]string{ Database: {"query", "mutate"}, Bucket: {"read", "write", "delete"}, + Service: {"invoke"}, } func GetValidActions(resourceType ResourceType) []string { diff --git a/cli/pkg/schema/service.go b/cli/pkg/schema/service.go index 2dbb61b4..8243258a 100644 --- a/cli/pkg/schema/service.go +++ b/cli/pkg/schema/service.go @@ -11,12 +11,18 @@ type ServiceIntent struct { Dev *Dev `json:"dev,omitempty" yaml:"dev,omitempty"` Schedules []*Schedule `json:"schedules,omitempty" yaml:"schedules,omitempty"` + + Access map[string][]string `json:"access,omitempty" yaml:"access,omitempty"` } func (s *ServiceIntent) GetType() string { return "service" } +func (s *ServiceIntent) GetAccess() (map[string][]string, bool) { + return s.Access, true +} + type Schedule struct { Cron string `json:"cron" yaml:"cron" jsonschema:"required"` Path string `json:"path" yaml:"path" jsonschema:"required"` diff --git a/cli/pkg/schema/validate.go b/cli/pkg/schema/validate.go index 6f21a92a..274a967a 100644 --- a/cli/pkg/schema/validate.go +++ b/cli/pkg/schema/validate.go @@ -99,6 +99,27 @@ func (a *Application) checkAccessPermissions() []gojsonschema.ResultError { } } + for name, intent := range a.ServiceIntents { + if access, ok := intent.GetAccess(); ok { + for targetServiceName, actions := range access { + // Validate actions + invalidActions, ok := ValidateActions(actions, Service) + if !ok { + key := fmt.Sprintf("services.%s.access.%s", name, targetServiceName) + err := fmt.Sprintf("Invalid service %s: %s. Valid actions are: %s", pluralise("action", len(invalidActions)), strings.Join(invalidActions, ", "), strings.Join(GetValidActions(Service), ", ")) + violations = append(violations, newValidationError(key, err)) + } + + // Validate that the target service exists + if _, exists := a.ServiceIntents[targetServiceName]; !exists { + key := fmt.Sprintf("services.%s.access.%s", name, targetServiceName) + err := fmt.Sprintf("Service %s requires access to non-existent service %s", name, targetServiceName) + violations = append(violations, newValidationError(key, err)) + } + } + } + } + return violations } diff --git a/engines/terraform/resource_handler.go b/engines/terraform/resource_handler.go index 2e38b032..f48608a6 100644 --- a/engines/terraform/resource_handler.go +++ b/engines/terraform/resource_handler.go @@ -93,7 +93,44 @@ func (td *TerraformDeployment) processServiceIdentities(appSpec *app_spec_schema return serviceInputs, nil } +func (td *TerraformDeployment) collectServiceAccessors(appSpec *app_spec_schema.Application) map[string]map[string]interface{} { + serviceAccessors := make(map[string]map[string]interface{}) + + for targetServiceName := range appSpec.ServiceIntents { + accessors := map[string]interface{}{} + + for sourceServiceName, sourceServiceIntent := range appSpec.ServiceIntents { + if access, ok := sourceServiceIntent.GetAccess(); ok { + if actions, needsAccess := access[targetServiceName]; needsAccess { + expandedActions := app_spec_schema.ExpandActions(actions, app_spec_schema.Service) + idMap := td.serviceIdentities[sourceServiceName] + + accessors[sourceServiceName] = map[string]interface{}{ + "actions": jsii.Strings(expandedActions...), + "identities": idMap, + } + } + } + } + + if len(accessors) > 0 { + serviceAccessors[targetServiceName] = accessors + } + } + + return serviceAccessors +} + func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema.Application, serviceInputs map[string]*SugaServiceVariables, serviceEnvs map[string][]interface{}) error { + serviceAccessors := td.collectServiceAccessors(appSpec) + + // Track original env values before modification + originalEnvs := make(map[string]interface{}) + for intentName := range appSpec.ServiceIntents { + sugaVar := serviceInputs[intentName] + originalEnvs[intentName] = sugaVar.Env + } + for intentName, serviceIntent := range appSpec.ServiceIntents { spec, err := td.engine.platform.GetResourceBlueprint(serviceIntent.GetType(), serviceIntent.GetSubType()) if err != nil { @@ -105,11 +142,10 @@ func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema. } sugaVar := serviceInputs[intentName] - origEnv := sugaVar.Env - mergedEnv := serviceEnvs[intentName] - allEnv := append(mergedEnv, origEnv) - sugaVar.Env = cdktf.Fn_Merge(&allEnv) + if accessors, ok := serviceAccessors[intentName]; ok { + sugaVar.Services = accessors + } td.createVariablesForIntent(intentName, spec) @@ -121,6 +157,26 @@ func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema. }) } + // Add service to service urls + for intentName, serviceIntent := range appSpec.ServiceIntents { + if access, ok := serviceIntent.GetAccess(); ok { + for targetServiceName := range access { + if targetResource, ok := td.terraformResources[targetServiceName]; ok { + envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName)) + httpEndpoint := targetResource.Get(jsii.String("suga.http_endpoint")) + serviceEnvs[intentName] = append(serviceEnvs[intentName], map[string]interface{}{ + envVarName: httpEndpoint, + }) + } + } + } + + sugaVar := serviceInputs[intentName] + mergedEnv := serviceEnvs[intentName] + allEnv := append(mergedEnv, originalEnvs[intentName]) + sugaVar.Env = cdktf.Fn_Merge(&allEnv) + } + return nil } diff --git a/engines/terraform/suga.go b/engines/terraform/suga.go index 8f5ccb90..63d70a51 100644 --- a/engines/terraform/suga.go +++ b/engines/terraform/suga.go @@ -9,6 +9,7 @@ type SugaServiceVariables struct { ImageId *string `json:"image_id"` Env interface{} `json:"env"` Identities *map[string]interface{} `json:"identities"` + Services map[string]interface{} `json:"services,omitempty"` Schedules *map[string]SugaServiceSchedule `json:"schedules,omitempty"` StackId *string `json:"stack_id"` } From 95cb411a4c8f7e30f90073335f796c6f0ab00bd3 Mon Sep 17 00:00:00 2001 From: Ryan Cartwright Date: Mon, 10 Nov 2025 11:19:47 +1100 Subject: [PATCH 2/2] change from source service to target service having access key --- cli/internal/simulation/simulation.go | 29 ++++-- cli/pkg/schema/schema_test.go | 126 ++++++++++++++++++++++++++ cli/pkg/schema/validate.go | 12 +-- engines/terraform/resource_handler.go | 54 ++++++----- 4 files changed, 184 insertions(+), 37 deletions(-) diff --git a/cli/internal/simulation/simulation.go b/cli/internal/simulation/simulation.go index 604a2e7d..c518b86f 100644 --- a/cli/internal/simulation/simulation.go +++ b/cli/internal/simulation/simulation.go @@ -277,12 +277,24 @@ func (s *SimulationServer) startServices(output io.Writer) (<-chan service.Servi servicePorts := make(map[string]netx.ReservedPort) eventChans := []<-chan service.ServiceEvent{} - for serviceName, serviceIntent := range serviceIntents { + // Helper function for lazy port allocation + getOrAllocatePort := func(serviceName string) (netx.ReservedPort, error) { + if port, exists := servicePorts[serviceName]; exists { + return port, nil + } port, err := netx.GetNextPort() if err != nil { - return nil, err + return 0, err } servicePorts[serviceName] = port + return port, nil + } + + for serviceName, serviceIntent := range serviceIntents { + port, err := getOrAllocatePort(serviceName) + if err != nil { + return nil, err + } // Clone the service intent to add database connection strings intentCopy := *serviceIntent @@ -306,10 +318,15 @@ func (s *SimulationServer) startServices(output io.Writer) (<-chan service.Servi } } - // Inject URLs for services this service depends on - if access, ok := intentCopy.GetAccess(); ok { - for targetServiceName := range access { - if targetPort, exists := servicePorts[targetServiceName]; exists { + // Inject URLs for services this service can access + // Check which services grant access to this service and lazily allocate their ports + for targetServiceName, targetServiceIntent := range serviceIntents { + if access, ok := targetServiceIntent.GetAccess(); ok { + if _, hasAccess := access[serviceName]; hasAccess { + targetPort, err := getOrAllocatePort(targetServiceName) + if err != nil { + return nil, err + } envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName)) intentCopy.Env[envVarName] = fmt.Sprintf("http://localhost:%d", targetPort) } diff --git a/cli/pkg/schema/schema_test.go b/cli/pkg/schema/schema_test.go index c54517c8..5b63f196 100644 --- a/cli/pkg/schema/schema_test.go +++ b/cli/pkg/schema/schema_test.go @@ -597,3 +597,129 @@ func TestApplication_IsValid_WithSubtypes(t *testing.T) { violations = app.IsValid(WithRequireSubtypes()) assert.Len(t, violations, 0, "Expected no violations with RequireSubtypes option, got: %v", violations) } + +func TestApplication_IsValid_ServiceAccess_Valid(t *testing.T) { + app := &Application{ + Name: "test-app", + Target: "team/platform@1", + ServiceIntents: map[string]*ServiceIntent{ + "api": { + Container: Container{ + Docker: &Docker{Dockerfile: "Dockerfile"}, + }, + }, + "user_service": { + Container: Container{ + Docker: &Docker{Dockerfile: "Dockerfile"}, + }, + Access: map[string][]string{ + "api": {"invoke"}, // user_service grants api access to invoke it + }, + }, + }, + } + + violations := app.IsValid() + assert.Len(t, violations, 0, "Expected no violations for valid service access, got: %v", violations) +} + +func TestApplication_IsValid_ServiceAccess_InvalidAction(t *testing.T) { + app := &Application{ + Name: "test-app", + Target: "team/platform@1", + ServiceIntents: map[string]*ServiceIntent{ + "api": { + Container: Container{ + Docker: &Docker{Dockerfile: "Dockerfile"}, + }, + }, + "user_service": { + Container: Container{ + Docker: &Docker{Dockerfile: "Dockerfile"}, + }, + Access: map[string][]string{ + "api": {"read", "write"}, // Invalid actions for service + }, + }, + }, + } + + violations := app.IsValid() + assert.NotEmpty(t, violations, "Expected violations for invalid service actions") + + errString := FormatValidationErrors(GetSchemaValidationErrors(violations)) + assert.Contains(t, errString, "user_service:") + assert.Contains(t, errString, "Invalid service actions: read, write") + assert.Contains(t, errString, "Valid actions are: invoke") +} + +func TestApplication_IsValid_ServiceAccess_NonExistentAccessor(t *testing.T) { + app := &Application{ + Name: "test-app", + Target: "team/platform@1", + ServiceIntents: map[string]*ServiceIntent{ + "user_service": { + Container: Container{ + Docker: &Docker{Dockerfile: "Dockerfile"}, + }, + Access: map[string][]string{ + "non_existent": {"invoke"}, // Accessor service doesn't exist + }, + }, + }, + } + + violations := app.IsValid() + assert.NotEmpty(t, violations, "Expected violations for non-existent accessor service") + + errString := FormatValidationErrors(GetSchemaValidationErrors(violations)) + assert.Contains(t, errString, "user_service:") + assert.Contains(t, errString, "grants access to non-existent service non_existent") +} + +func TestApplicationFromYaml_ServiceAccess_TargetBased(t *testing.T) { + yaml := ` +name: test-app +description: A test application with service access +target: team/platform@1 +services: + api: + container: + docker: + dockerfile: Dockerfile + user_service: + container: + docker: + dockerfile: Dockerfile + access: + api: + - invoke + payment_service: + container: + docker: + dockerfile: Dockerfile + access: + api: + - invoke + user_service: + - invoke +` + + app, result, err := ApplicationFromYaml(yaml) + assert.NoError(t, err) + assert.True(t, result.Valid(), "Expected valid result, got validation errors: %v", result.Errors()) + + violations := app.IsValid() + assert.Len(t, violations, 0, "Expected no violations for valid target-based service access, got: %v", violations) + + // Verify the access configuration + userService := app.ServiceIntents["user_service"] + assert.NotNil(t, userService.Access) + assert.Contains(t, userService.Access, "api") + assert.Equal(t, []string{"invoke"}, userService.Access["api"]) + + paymentService := app.ServiceIntents["payment_service"] + assert.NotNil(t, paymentService.Access) + assert.Contains(t, paymentService.Access, "api") + assert.Contains(t, paymentService.Access, "user_service") +} diff --git a/cli/pkg/schema/validate.go b/cli/pkg/schema/validate.go index 274a967a..283899fb 100644 --- a/cli/pkg/schema/validate.go +++ b/cli/pkg/schema/validate.go @@ -101,19 +101,19 @@ func (a *Application) checkAccessPermissions() []gojsonschema.ResultError { for name, intent := range a.ServiceIntents { if access, ok := intent.GetAccess(); ok { - for targetServiceName, actions := range access { + for accessorServiceName, actions := range access { // Validate actions invalidActions, ok := ValidateActions(actions, Service) if !ok { - key := fmt.Sprintf("services.%s.access.%s", name, targetServiceName) + key := fmt.Sprintf("services.%s.access.%s", name, accessorServiceName) err := fmt.Sprintf("Invalid service %s: %s. Valid actions are: %s", pluralise("action", len(invalidActions)), strings.Join(invalidActions, ", "), strings.Join(GetValidActions(Service), ", ")) violations = append(violations, newValidationError(key, err)) } - // Validate that the target service exists - if _, exists := a.ServiceIntents[targetServiceName]; !exists { - key := fmt.Sprintf("services.%s.access.%s", name, targetServiceName) - err := fmt.Sprintf("Service %s requires access to non-existent service %s", name, targetServiceName) + // Validate that the accessor service exists + if _, exists := a.ServiceIntents[accessorServiceName]; !exists { + key := fmt.Sprintf("services.%s.access.%s", name, accessorServiceName) + err := fmt.Sprintf("Service %s grants access to non-existent service %s", name, accessorServiceName) violations = append(violations, newValidationError(key, err)) } } diff --git a/engines/terraform/resource_handler.go b/engines/terraform/resource_handler.go index f48608a6..7f6606ea 100644 --- a/engines/terraform/resource_handler.go +++ b/engines/terraform/resource_handler.go @@ -96,25 +96,23 @@ func (td *TerraformDeployment) processServiceIdentities(appSpec *app_spec_schema func (td *TerraformDeployment) collectServiceAccessors(appSpec *app_spec_schema.Application) map[string]map[string]interface{} { serviceAccessors := make(map[string]map[string]interface{}) - for targetServiceName := range appSpec.ServiceIntents { - accessors := map[string]interface{}{} - - for sourceServiceName, sourceServiceIntent := range appSpec.ServiceIntents { - if access, ok := sourceServiceIntent.GetAccess(); ok { - if actions, needsAccess := access[targetServiceName]; needsAccess { - expandedActions := app_spec_schema.ExpandActions(actions, app_spec_schema.Service) - idMap := td.serviceIdentities[sourceServiceName] - - accessors[sourceServiceName] = map[string]interface{}{ - "actions": jsii.Strings(expandedActions...), - "identities": idMap, - } + for targetServiceName, targetServiceIntent := range appSpec.ServiceIntents { + if access, ok := targetServiceIntent.GetAccess(); ok { + accessors := map[string]interface{}{} + + for accessorServiceName, actions := range access { + expandedActions := app_spec_schema.ExpandActions(actions, app_spec_schema.Service) + idMap := td.serviceIdentities[accessorServiceName] + + accessors[accessorServiceName] = map[string]interface{}{ + "actions": jsii.Strings(expandedActions...), + "identities": idMap, } } - } - if len(accessors) > 0 { - serviceAccessors[targetServiceName] = accessors + if len(accessors) > 0 { + serviceAccessors[targetServiceName] = accessors + } } } @@ -158,19 +156,25 @@ func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema. } // Add service to service urls - for intentName, serviceIntent := range appSpec.ServiceIntents { - if access, ok := serviceIntent.GetAccess(); ok { - for targetServiceName := range access { - if targetResource, ok := td.terraformResources[targetServiceName]; ok { - envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName)) - httpEndpoint := targetResource.Get(jsii.String("suga.http_endpoint")) - serviceEnvs[intentName] = append(serviceEnvs[intentName], map[string]interface{}{ - envVarName: httpEndpoint, - }) + // Build reverse index: for each accessor service, find which targets grant it access + for accessorServiceName := range appSpec.ServiceIntents { + for targetServiceName, targetServiceIntent := range appSpec.ServiceIntents { + if access, ok := targetServiceIntent.GetAccess(); ok { + if _, hasAccess := access[accessorServiceName]; hasAccess { + if targetResource, ok := td.terraformResources[targetServiceName]; ok { + envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName)) + httpEndpoint := targetResource.Get(jsii.String("suga.http_endpoint")) + serviceEnvs[accessorServiceName] = append(serviceEnvs[accessorServiceName], map[string]interface{}{ + envVarName: httpEndpoint, + }) + } } } } + } + // Merge environment variables for all services + for intentName := range appSpec.ServiceIntents { sugaVar := serviceInputs[intentName] mergedEnv := serviceEnvs[intentName] allEnv := append(mergedEnv, originalEnvs[intentName])