From c69bbdb7b612e3b9a48f817dc18c675607ba2bb7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 15:01:31 +0000 Subject: [PATCH] Enhance add-access-rule command UX with intuitive name-based flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit improves the user experience for the add-access-rule command by replacing the positional GUID-based SELECTOR argument with intuitive flags that accept human-readable names and support cross-space/org resolution. Changes: **Command Interface:** - Remove positional SELECTOR argument (breaking change, acceptable for unreleased feature) - Add new flags: --source-app, --source-space, --source-org, --source-any, --selector - Support hierarchical name resolution: - --source-app APP_NAME (looks in current space) - --source-app APP_NAME --source-space SPACE (cross-space in current org) - --source-app APP_NAME --source-space SPACE --source-org ORG (cross-org) - --source-space SPACE (space-level rule) - --source-org ORG (org-level rule) - --source-any (allow any authenticated app) - --selector SELECTOR (raw GUID-based selector for advanced users) - Validate exactly one primary source is specified - Display verbose output showing resolved selector for transparency **Terminology Update:** - Rename all "target" terminology to "source" throughout codebase - Access rules specify the source (who can access), not the target - Update AccessRuleWithRoute.TargetName → SourceName - Update resolveAccessRuleTarget() → resolveAccessRuleSource() - Update access-rules list command table header: "target" → "source" **Error Handling:** - Provide helpful error messages when app not found in current space - Suggest using --source-space and --source-org flags for cross-space/org access - Follow CF CLI patterns from add-network-policy command **Testing:** - Add 17 comprehensive test cases for add-access-rule command - Update 19 actor tests to use new SourceName field - All tests passing (36/36) **Domain Integration:** - Add enforce_access_rules support to create-shared-domain and create-private-domain - Add --enforce-access-rules and --access-rules-scope flags - Update domain resource with new fields Examples: # Simple case - app in current space cf add-access-rule allow-frontend apps.identity --source-app frontend-app --hostname backend # Cross-space access cf add-access-rule allow-other apps.identity --source-app api-client --source-space other-space --hostname backend # Cross-org access cf add-access-rule allow-prod apps.identity --source-app client --source-space prod-space --source-org prod-org --hostname api # Space-level rule cf add-access-rule allow-monitoring apps.identity --source-space monitoring --hostname api # Org-level rule cf add-access-rule allow-platform apps.identity --source-org platform --hostname shared-api # Any authenticated app cf add-access-rule allow-all apps.identity --source-any --hostname public-api Related to: https://github.com/cloudfoundry/community/pull/1438 --- .../access_rule_not_found_error.go | 11 + actor/v7action/access_rule.go | 383 +++++++++ actor/v7action/access_rule_test.go | 755 ++++++++++++++++++ actor/v7action/cloud_controller_client.go | 3 + actor/v7action/domain.go | 29 +- actor/v7action/domain_test.go | 4 +- .../fake_cloud_controller_client.go | 251 ++++++ api/cloudcontroller/ccv3/access_rule.go | 59 ++ .../ccv3/included_resources.go | 2 + .../ccv3/internal/api_routes.go | 8 + api/cloudcontroller/ccv3/query.go | 2 + command/common/command_list_v7.go | 3 + command/common/internal/help_all_display.go | 1 + command/flag/arguments.go | 10 + command/v7/access_rules_command.go | 103 +++ command/v7/access_rules_command_test.go | 356 +++++++++ command/v7/actor.go | 8 +- command/v7/add_access_rule_command.go | 284 +++++++ command/v7/add_access_rule_command_test.go | 453 +++++++++++ command/v7/create_private_domain_command.go | 35 +- .../v7/create_private_domain_command_test.go | 2 +- command/v7/create_shared_domain_command.go | 42 +- .../v7/create_shared_domain_command_test.go | 2 +- command/v7/remove_access_rule_command.go | 71 ++ command/v7/v7fakes/fake_actor.go | 384 ++++++++- resources/access_rule_resource.go | 84 ++ resources/access_rule_resource_test.go | 69 ++ resources/domain_resource.go | 32 +- 28 files changed, 3388 insertions(+), 58 deletions(-) create mode 100644 actor/actionerror/access_rule_not_found_error.go create mode 100644 actor/v7action/access_rule.go create mode 100644 actor/v7action/access_rule_test.go create mode 100644 api/cloudcontroller/ccv3/access_rule.go create mode 100644 command/v7/access_rules_command.go create mode 100644 command/v7/access_rules_command_test.go create mode 100644 command/v7/add_access_rule_command.go create mode 100644 command/v7/add_access_rule_command_test.go create mode 100644 command/v7/remove_access_rule_command.go create mode 100644 resources/access_rule_resource.go create mode 100644 resources/access_rule_resource_test.go diff --git a/actor/actionerror/access_rule_not_found_error.go b/actor/actionerror/access_rule_not_found_error.go new file mode 100644 index 00000000000..bf5710421e5 --- /dev/null +++ b/actor/actionerror/access_rule_not_found_error.go @@ -0,0 +1,11 @@ +package actionerror + +import "fmt" + +type AccessRuleNotFoundError struct { + Name string +} + +func (e AccessRuleNotFoundError) Error() string { + return fmt.Sprintf("Access rule '%s' not found.", e.Name) +} diff --git a/actor/v7action/access_rule.go b/actor/v7action/access_rule.go new file mode 100644 index 00000000000..e74bd789497 --- /dev/null +++ b/actor/v7action/access_rule.go @@ -0,0 +1,383 @@ +package v7action + +import ( + "code.cloudfoundry.org/cli/v9/actor/actionerror" + "code.cloudfoundry.org/cli/v9/api/cloudcontroller/ccv3" + "code.cloudfoundry.org/cli/v9/resources" +) + +func (actor Actor) AddAccessRule(ruleName, domainName, selector, hostname, path string) (Warnings, error) { + allWarnings := Warnings{} + + // Get the domain to ensure it exists and supports access rules + domain, warnings, err := actor.GetDomainByName(domainName) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return allWarnings, err + } + + // Find the route + routes, routeWarnings, err := actor.GetRoutesByDomain(domain.GUID, hostname, path) + allWarnings = append(allWarnings, routeWarnings...) + if err != nil { + return allWarnings, err + } + + if len(routes) == 0 { + return allWarnings, actionerror.RouteNotFoundError{ + Host: hostname, + DomainName: domainName, + Path: path, + } + } + + route := routes[0] + + // Create the access rule + accessRule := resources.AccessRule{ + Name: ruleName, + Selector: selector, + RouteGUID: route.GUID, + } + + _, apiWarnings, err := actor.CloudControllerClient.CreateAccessRule(accessRule) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + + return allWarnings, err +} + +func (actor Actor) GetAccessRulesByRoute(domainName, hostname, path string) ([]resources.AccessRule, Warnings, error) { + allWarnings := Warnings{} + + // Get the domain + domain, warnings, err := actor.GetDomainByName(domainName) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return nil, allWarnings, err + } + + // Find the route + routes, routeWarnings, err := actor.GetRoutesByDomain(domain.GUID, hostname, path) + allWarnings = append(allWarnings, routeWarnings...) + if err != nil { + return nil, allWarnings, err + } + + if len(routes) == 0 { + return nil, allWarnings, actionerror.RouteNotFoundError{ + Host: hostname, + DomainName: domainName, + Path: path, + } + } + + route := routes[0] + + // Get access rules for this route + accessRules, _, apiWarnings, err := actor.CloudControllerClient.GetAccessRules( + ccv3.Query{Key: ccv3.RouteGUIDFilter, Values: []string{route.GUID}}, + ) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + + var rules []resources.AccessRule + for _, rule := range accessRules { + rules = append(rules, resources.AccessRule(rule)) + } + + return rules, allWarnings, err +} + +func (actor Actor) DeleteAccessRule(ruleName, domainName, hostname, path string) (Warnings, error) { + allWarnings := Warnings{} + + // Get the domain + domain, warnings, err := actor.GetDomainByName(domainName) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return allWarnings, err + } + + // Find the route + routes, routeWarnings, err := actor.GetRoutesByDomain(domain.GUID, hostname, path) + allWarnings = append(allWarnings, routeWarnings...) + if err != nil { + return allWarnings, err + } + + if len(routes) == 0 { + return allWarnings, actionerror.RouteNotFoundError{ + Host: hostname, + DomainName: domainName, + Path: path, + } + } + + route := routes[0] + + // Get access rules for this route to find the one with matching name + accessRules, _, apiWarnings, err := actor.CloudControllerClient.GetAccessRules( + ccv3.Query{Key: ccv3.RouteGUIDFilter, Values: []string{route.GUID}}, + ) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + if err != nil { + return allWarnings, err + } + + // Find the rule with matching name + var ruleGUID string + for _, rule := range accessRules { + if rule.Name == ruleName { + ruleGUID = rule.GUID + break + } + } + + if ruleGUID == "" { + return allWarnings, actionerror.AccessRuleNotFoundError{Name: ruleName} + } + + // Delete the access rule + _, deleteWarnings, err := actor.CloudControllerClient.DeleteAccessRule(ruleGUID) + allWarnings = append(allWarnings, Warnings(deleteWarnings)...) + + return allWarnings, err +} + +// GetRoutesByDomain gets routes for a domain with optional hostname and path filters +func (actor Actor) GetRoutesByDomain(domainGUID, hostname, path string) ([]resources.Route, Warnings, error) { + queries := []ccv3.Query{ + {Key: ccv3.DomainGUIDFilter, Values: []string{domainGUID}}, + } + + if hostname != "" { + queries = append(queries, ccv3.Query{Key: ccv3.HostsFilter, Values: []string{hostname}}) + } + + if path != "" { + queries = append(queries, ccv3.Query{Key: ccv3.PathsFilter, Values: []string{path}}) + } + + ccv3Routes, warnings, err := actor.CloudControllerClient.GetRoutes(queries...) + if err != nil { + return nil, Warnings(warnings), err + } + + var routes []resources.Route + for _, route := range ccv3Routes { + routes = append(routes, resources.Route(route)) + } + + return routes, Warnings(warnings), nil +} + +// AccessRuleWithRoute combines an access rule with its associated route information +type AccessRuleWithRoute struct { + resources.AccessRule + Route resources.Route + DomainName string + ScopeType string // "app", "space", "org", or "any" + SourceName string // Resolved source name (app/space/org) or empty string +} + +// GetAccessRulesForSpace gets all access rules for routes in a space with optional filters +func (actor Actor) GetAccessRulesForSpace( + spaceGUID string, + domainName string, + hostname string, + path string, + labelSelector string, +) ([]AccessRuleWithRoute, Warnings, error) { + allWarnings := Warnings{} + + // Build query for access rules filtered by space, with included routes + queries := []ccv3.Query{ + {Key: ccv3.SpaceGUIDFilter, Values: []string{spaceGUID}}, + {Key: ccv3.Include, Values: []string{"route"}}, + } + + // Add label selector if provided + if labelSelector != "" { + queries = append(queries, ccv3.Query{Key: ccv3.LabelSelectorFilter, Values: []string{labelSelector}}) + } + + // Fetch access rules directly by space GUID with included routes (single API call) + accessRules, includedResources, apiWarnings, err := actor.CloudControllerClient.GetAccessRules(queries...) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + if err != nil { + return nil, allWarnings, err + } + + if len(accessRules) == 0 { + // No access rules found - return empty list, not an error + return []AccessRuleWithRoute{}, allWarnings, nil + } + + // Build route lookup map from included resources + routeByGUID := make(map[string]resources.Route) + for _, route := range includedResources.Routes { + routeByGUID[route.GUID] = route + } + + // Apply optional filters to the included routes + if domainName != "" { + domain, warnings, err := actor.GetDomainByName(domainName) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return nil, allWarnings, err + } + // Filter routes by domain GUID + filteredRoutes := make(map[string]resources.Route) + for guid, route := range routeByGUID { + if route.DomainGUID == domain.GUID { + filteredRoutes[guid] = route + } + } + routeByGUID = filteredRoutes + } + + if hostname != "" { + // Filter routes by hostname + filteredRoutes := make(map[string]resources.Route) + for guid, route := range routeByGUID { + if route.Host == hostname { + filteredRoutes[guid] = route + } + } + routeByGUID = filteredRoutes + } + + if path != "" { + // Filter routes by path + filteredRoutes := make(map[string]resources.Route) + for guid, route := range routeByGUID { + if route.Path == path { + filteredRoutes[guid] = route + } + } + routeByGUID = filteredRoutes + } + + // Build domain name cache + domainCache := make(map[string]string) + for _, route := range routeByGUID { + if _, exists := domainCache[route.DomainGUID]; !exists { + domain, warnings, err := actor.GetDomain(route.DomainGUID) + allWarnings = append(allWarnings, warnings...) + if err != nil { + // If we can't get the domain, use the GUID as fallback + domainCache[route.DomainGUID] = route.DomainGUID + } else { + domainCache[route.DomainGUID] = domain.Name + } + } + } + + // Build results with route information and resolved sources + // Only include access rules whose routes match the filters + var results []AccessRuleWithRoute + for _, rule := range accessRules { + route, exists := routeByGUID[rule.RouteGUID] + if !exists { + // Skip rules for routes that don't match the optional filters + continue + } + + scopeType, sourceName, warnings, err := actor.resolveAccessRuleSource(rule.Selector) + allWarnings = append(allWarnings, warnings...) + if err != nil { + // If we can't resolve the source, sourceName is already empty string + // scopeType is still set correctly + } + + results = append(results, AccessRuleWithRoute{ + AccessRule: resources.AccessRule(rule), + Route: route, + DomainName: domainCache[route.DomainGUID], + ScopeType: scopeType, + SourceName: sourceName, + }) + } + + return results, allWarnings, nil +} + +// resolveAccessRuleSource resolves a selector to scope type and human-readable source name +func (actor Actor) resolveAccessRuleSource(selector string) (scopeType string, sourceName string, warnings Warnings, err error) { + allWarnings := Warnings{} + + // Parse selector format: cf:app:, cf:space:, cf:org:, or cf:any + if selector == "cf:any" { + return "any", "", nil, nil + } + + // Split selector into parts + // Expected format: cf:type:guid + const prefix = "cf:" + if len(selector) < len(prefix) { + return "unknown", "", nil, nil + } + + selectorBody := selector[len(prefix):] + parts := splitSelector(selectorBody) + if len(parts) < 2 { + return "unknown", "", nil, nil + } + + selectorType := parts[0] + guid := parts[1] + + switch selectorType { + case "app": + apps, apiWarnings, err := actor.CloudControllerClient.GetApplications( + ccv3.Query{Key: ccv3.GUIDFilter, Values: []string{guid}}, + ) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + if err != nil || len(apps) == 0 { + return "app", "", allWarnings, err + } + return "app", apps[0].Name, allWarnings, nil + + case "space": + spaces, _, apiWarnings, err := actor.CloudControllerClient.GetSpaces( + ccv3.Query{Key: ccv3.GUIDFilter, Values: []string{guid}}, + ) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + if err != nil || len(spaces) == 0 { + return "space", "", allWarnings, err + } + return "space", spaces[0].Name, allWarnings, nil + + case "org": + orgs, apiWarnings, err := actor.CloudControllerClient.GetOrganizations( + ccv3.Query{Key: ccv3.GUIDFilter, Values: []string{guid}}, + ) + allWarnings = append(allWarnings, Warnings(apiWarnings)...) + if err != nil || len(orgs) == 0 { + return "org", "", allWarnings, err + } + return "org", orgs[0].Name, allWarnings, nil + + default: + return "unknown", "", nil, nil + } +} + +// splitSelector splits a selector body by colon, handling the case where +// the selector might be "type:guid" format +func splitSelector(s string) []string { + var parts []string + current := "" + for _, char := range s { + if char == ':' && len(parts) == 0 { + // First colon - split here + parts = append(parts, current) + current = "" + } else { + current += string(char) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} diff --git a/actor/v7action/access_rule_test.go b/actor/v7action/access_rule_test.go new file mode 100644 index 00000000000..64c4fc52343 --- /dev/null +++ b/actor/v7action/access_rule_test.go @@ -0,0 +1,755 @@ +package v7action_test + +import ( + "errors" + + "code.cloudfoundry.org/cli/v9/actor/actionerror" + . "code.cloudfoundry.org/cli/v9/actor/v7action" + "code.cloudfoundry.org/cli/v9/actor/v7action/v7actionfakes" + "code.cloudfoundry.org/cli/v9/api/cloudcontroller/ccv3" + "code.cloudfoundry.org/cli/v9/resources" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Access Rule Actions", func() { + var ( + actor *Actor + fakeCloudControllerClient *v7actionfakes.FakeCloudControllerClient + ) + + BeforeEach(func() { + actor, fakeCloudControllerClient, _, _, _, _, _ = NewTestActor() + }) + + Describe("GetAccessRulesForSpace", func() { + var ( + spaceGUID string + domainName string + hostname string + path string + labelSelector string + + results []AccessRuleWithRoute + warnings Warnings + executeErr error + ) + + BeforeEach(func() { + spaceGUID = "space-guid-1" + domainName = "" + hostname = "" + path = "" + labelSelector = "" + }) + + JustBeforeEach(func() { + results, warnings, executeErr = actor.GetAccessRulesForSpace( + spaceGUID, + domainName, + hostname, + path, + labelSelector, + ) + }) + + When("getting access rules succeeds with multiple rules", func() { + BeforeEach(func() { + // Mock GetAccessRules call with included routes + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + { + GUID: "rule-guid-1", + Name: "rule-1", + Selector: "cf:app:app-guid-1", + RouteGUID: "route-guid-1", + }, + { + GUID: "rule-guid-2", + Name: "rule-2", + Selector: "cf:any", + RouteGUID: "route-guid-2", + }, + }, + ccv3.IncludedResources{ + Routes: []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "app1", + Path: "/path1", + }, + { + GUID: "route-guid-2", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-2", + Host: "app2", + Path: "", + }, + }, + }, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + + // Mock GetDomain calls for domain name resolution + fakeCloudControllerClient.GetDomainStub = func(guid string) (resources.Domain, ccv3.Warnings, error) { + switch guid { + case "domain-guid-1": + return resources.Domain{GUID: "domain-guid-1", Name: "example.com"}, ccv3.Warnings{"get-domain-warning-1"}, nil + case "domain-guid-2": + return resources.Domain{GUID: "domain-guid-2", Name: "test.com"}, ccv3.Warnings{"get-domain-warning-2"}, nil + default: + return resources.Domain{}, nil, errors.New("domain not found") + } + } + + // Mock GetApplications for app name resolution + fakeCloudControllerClient.GetApplicationsReturns( + []resources.Application{ + {GUID: "app-guid-1", Name: "my-app"}, + }, + ccv3.Warnings{"get-app-warning"}, + nil, + ) + }) + + It("returns access rules with route and domain information", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(warnings).To(ConsistOf( + "get-access-rules-warning", + "get-domain-warning-1", + "get-domain-warning-2", + "get-app-warning", + )) + + Expect(results).To(HaveLen(2)) + + // First rule + Expect(results[0].GUID).To(Equal("rule-guid-1")) + Expect(results[0].Name).To(Equal("rule-1")) + Expect(results[0].Selector).To(Equal("cf:app:app-guid-1")) + Expect(results[0].Route.GUID).To(Equal("route-guid-1")) + Expect(results[0].Route.Host).To(Equal("app1")) + Expect(results[0].Route.Path).To(Equal("/path1")) + Expect(results[0].DomainName).To(Equal("example.com")) + Expect(results[0].ScopeType).To(Equal("app")) + Expect(results[0].SourceName).To(Equal("my-app")) + + // Second rule + Expect(results[1].GUID).To(Equal("rule-guid-2")) + Expect(results[1].Name).To(Equal("rule-2")) + Expect(results[1].Selector).To(Equal("cf:any")) + Expect(results[1].Route.GUID).To(Equal("route-guid-2")) + Expect(results[1].Route.Host).To(Equal("app2")) + Expect(results[1].DomainName).To(Equal("test.com")) + Expect(results[1].ScopeType).To(Equal("any")) + Expect(results[1].SourceName).To(Equal("")) + }) + + It("calls GetAccessRules with space GUID and include route filters", func() { + Expect(fakeCloudControllerClient.GetAccessRulesCallCount()).To(Equal(1)) + queries := fakeCloudControllerClient.GetAccessRulesArgsForCall(0) + Expect(queries).To(ContainElement(ccv3.Query{ + Key: ccv3.SpaceGUIDFilter, + Values: []string{"space-guid-1"}, + })) + Expect(queries).To(ContainElement(ccv3.Query{ + Key: ccv3.Include, + Values: []string{"route"}, + })) + }) + + It("does not call GetRoutes separately", func() { + Expect(fakeCloudControllerClient.GetRoutesCallCount()).To(Equal(0)) + }) + }) + + When("domain name filter is provided", func() { + BeforeEach(func() { + domainName = "example.com" + + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + { + GUID: "rule-guid-1", + Name: "rule-1", + Selector: "cf:any", + RouteGUID: "route-guid-1", + }, + }, + ccv3.IncludedResources{ + Routes: []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "app1", + }, + }, + }, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + + fakeCloudControllerClient.GetDomainReturns( + resources.Domain{GUID: "domain-guid-1", Name: "example.com"}, + ccv3.Warnings{"get-domain-warning"}, + nil, + ) + }) + + It("filters routes by domain GUID", func() { + Expect(executeErr).ToNot(HaveOccurred()) + // Routes are filtered in-memory from included resources + Expect(fakeCloudControllerClient.GetRoutesCallCount()).To(Equal(0)) + }) + }) + + When("hostname filter is provided", func() { + BeforeEach(func() { + hostname = "myapp" + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{}, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + }) + + It("adds hostname filter to route query", func() { + Expect(executeErr).ToNot(HaveOccurred()) + // GetRoutes should not be called since routes come from included resources + Expect(fakeCloudControllerClient.GetRoutesCallCount()).To(Equal(0)) + }) + }) + + When("path filter is provided", func() { + BeforeEach(func() { + path = "/api" + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{}, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + }) + + It("adds path filter to route query", func() { + Expect(executeErr).ToNot(HaveOccurred()) + // GetRoutes should not be called if no access rules are found + Expect(fakeCloudControllerClient.GetRoutesCallCount()).To(Equal(0)) + }) + }) + + When("label selector filter is provided", func() { + BeforeEach(func() { + labelSelector = "env=production" + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{}, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + }) + + It("adds label selector filter to access rules query", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(fakeCloudControllerClient.GetAccessRulesCallCount()).To(Equal(1)) + queries := fakeCloudControllerClient.GetAccessRulesArgsForCall(0) + + Expect(queries).To(ContainElement(ccv3.Query{ + Key: ccv3.LabelSelectorFilter, + Values: []string{"env=production"}, + })) + }) + }) + + When("no access rules are found in the space", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{}, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + }) + + It("returns an empty list without error", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + Expect(warnings).To(ConsistOf("get-access-rules-warning")) + }) + + It("does not call GetRoutes", func() { + Expect(fakeCloudControllerClient.GetRoutesCallCount()).To(Equal(0)) + }) + }) + + When("getting access rules fails", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetAccessRulesReturns( + nil, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + errors.New("api error"), + ) + }) + + It("returns the error and warnings", func() { + Expect(executeErr).To(MatchError("api error")) + Expect(warnings).To(ConsistOf("get-access-rules-warning")) + Expect(results).To(BeNil()) + }) + }) + + When("getting domain by name fails", func() { + BeforeEach(func() { + domainName = "invalid-domain.com" + + // Mock GetAccessRules to return at least one access rule + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + {GUID: "access-rule-guid-1", RouteGUID: "route-guid-1"}, + }, + ccv3.IncludedResources{ + Routes: []resources.Route{ + {GUID: "route-guid-1", DomainGUID: "domain-guid-1"}, + }, + }, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + + fakeCloudControllerClient.GetDomainsReturns( + nil, + ccv3.Warnings{"get-domains-warning"}, + actionerror.DomainNotFoundError{Name: "invalid-domain.com"}, + ) + }) + + It("returns the error and warnings", func() { + Expect(executeErr).To(MatchError(actionerror.DomainNotFoundError{Name: "invalid-domain.com"})) + Expect(warnings).To(ConsistOf("get-access-rules-warning", "get-domains-warning")) + Expect(results).To(BeNil()) + }) + }) + + When("resolving domain name fails", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + { + GUID: "rule-guid-1", + Name: "rule-1", + Selector: "cf:any", + RouteGUID: "route-guid-1", + }, + }, + ccv3.IncludedResources{ + Routes: []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "app1", + }, + }, + }, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + + // Domain lookup fails + fakeCloudControllerClient.GetDomainReturns( + resources.Domain{}, + ccv3.Warnings{"get-domain-warning"}, + errors.New("domain lookup error"), + ) + }) + + It("uses the domain GUID as fallback and continues", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].DomainName).To(Equal("domain-guid-1")) + Expect(warnings).To(ContainElement("get-domain-warning")) + }) + }) + + When("resolving target name fails", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + { + GUID: "rule-guid-1", + Name: "rule-1", + Selector: "cf:app:app-guid-1", + RouteGUID: "route-guid-1", + }, + }, + ccv3.IncludedResources{ + Routes: []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "app1", + }, + }, + }, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + + fakeCloudControllerClient.GetDomainReturns( + resources.Domain{GUID: "domain-guid-1", Name: "example.com"}, + ccv3.Warnings{"get-domain-warning"}, + nil, + ) + + // App lookup fails + fakeCloudControllerClient.GetApplicationsReturns( + nil, + ccv3.Warnings{"get-app-warning"}, + errors.New("app lookup error"), + ) + }) + + It("leaves source name blank and populates scope type", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ScopeType).To(Equal("app")) + Expect(results[0].SourceName).To(Equal("")) + Expect(warnings).To(ContainElement("get-app-warning")) + }) + }) + }) + + // Note: resolveAccessRuleTarget and splitSelector are unexported methods + // and are tested indirectly through GetAccessRulesForSpace above. + + Describe("GetAccessRulesByRoute", func() { + var ( + domainName string + hostname string + path string + + rules []resources.AccessRule + warnings Warnings + executeErr error + ) + + BeforeEach(func() { + domainName = "example.com" + hostname = "myapp" + path = "" + }) + + JustBeforeEach(func() { + rules, warnings, executeErr = actor.GetAccessRulesByRoute(domainName, hostname, path) + }) + + When("the route exists with access rules", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetRoutesReturns( + []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "myapp", + }, + }, + ccv3.Warnings{"get-routes-warning"}, + nil, + ) + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + {GUID: "rule-guid-1", Name: "rule-1", Selector: "cf:any"}, + {GUID: "rule-guid-2", Name: "rule-2", Selector: "cf:app:app-guid-1"}, + }, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + }) + + It("returns the access rules", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(2)) + Expect(rules[0].Name).To(Equal("rule-1")) + Expect(rules[1].Name).To(Equal("rule-2")) + Expect(warnings).To(ConsistOf( + "get-domains-warning", + "get-routes-warning", + "get-access-rules-warning", + )) + }) + }) + + When("the route does not exist", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetRoutesReturns( + []resources.Route{}, + ccv3.Warnings{"get-routes-warning"}, + nil, + ) + }) + + It("returns a RouteNotFoundError", func() { + Expect(executeErr).To(MatchError(actionerror.RouteNotFoundError{ + Host: "myapp", + DomainName: "example.com", + Path: "", + })) + Expect(warnings).To(ConsistOf("get-domains-warning", "get-routes-warning")) + }) + }) + }) + + Describe("AddAccessRule", func() { + var ( + ruleName string + domainName string + selector string + hostname string + path string + + warnings Warnings + executeErr error + ) + + BeforeEach(func() { + ruleName = "my-rule" + domainName = "example.com" + selector = "cf:app:app-guid-1" + hostname = "myapp" + path = "" + }) + + JustBeforeEach(func() { + warnings, executeErr = actor.AddAccessRule(ruleName, domainName, selector, hostname, path) + }) + + When("creating the access rule succeeds", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetRoutesReturns( + []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "myapp", + }, + }, + ccv3.Warnings{"get-routes-warning"}, + nil, + ) + + fakeCloudControllerClient.CreateAccessRuleReturns( + resources.AccessRule{GUID: "rule-guid-1", Name: "my-rule"}, + ccv3.Warnings{"create-rule-warning"}, + nil, + ) + }) + + It("creates the access rule and returns warnings", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(warnings).To(ConsistOf( + "get-domains-warning", + "get-routes-warning", + "create-rule-warning", + )) + + Expect(fakeCloudControllerClient.CreateAccessRuleCallCount()).To(Equal(1)) + rule := fakeCloudControllerClient.CreateAccessRuleArgsForCall(0) + Expect(rule.Name).To(Equal("my-rule")) + Expect(rule.Selector).To(Equal("cf:app:app-guid-1")) + Expect(rule.RouteGUID).To(Equal("route-guid-1")) + }) + }) + + When("the route does not exist", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetRoutesReturns( + []resources.Route{}, + ccv3.Warnings{"get-routes-warning"}, + nil, + ) + }) + + It("returns a RouteNotFoundError", func() { + Expect(executeErr).To(MatchError(actionerror.RouteNotFoundError{ + Host: "myapp", + DomainName: "example.com", + Path: "", + })) + }) + }) + }) + + Describe("DeleteAccessRule", func() { + var ( + ruleName string + domainName string + hostname string + path string + + warnings Warnings + executeErr error + ) + + BeforeEach(func() { + ruleName = "my-rule" + domainName = "example.com" + hostname = "myapp" + path = "" + }) + + JustBeforeEach(func() { + warnings, executeErr = actor.DeleteAccessRule(ruleName, domainName, hostname, path) + }) + + When("the access rule exists", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetRoutesReturns( + []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "myapp", + }, + }, + ccv3.Warnings{"get-routes-warning"}, + nil, + ) + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + {GUID: "rule-guid-1", Name: "my-rule", Selector: "cf:any"}, + }, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + + fakeCloudControllerClient.DeleteAccessRuleReturns( + ccv3.JobURL(""), + ccv3.Warnings{"delete-rule-warning"}, + nil, + ) + }) + + It("deletes the access rule and returns warnings", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(warnings).To(ConsistOf( + "get-domains-warning", + "get-routes-warning", + "get-access-rules-warning", + "delete-rule-warning", + )) + + Expect(fakeCloudControllerClient.DeleteAccessRuleCallCount()).To(Equal(1)) + guid := fakeCloudControllerClient.DeleteAccessRuleArgsForCall(0) + Expect(guid).To(Equal("rule-guid-1")) + }) + }) + + When("the access rule does not exist", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetDomainsReturns( + []resources.Domain{ + {GUID: "domain-guid-1", Name: "example.com"}, + }, + ccv3.Warnings{"get-domains-warning"}, + nil, + ) + + fakeCloudControllerClient.GetRoutesReturns( + []resources.Route{ + { + GUID: "route-guid-1", + SpaceGUID: "space-guid-1", + DomainGUID: "domain-guid-1", + Host: "myapp", + }, + }, + ccv3.Warnings{"get-routes-warning"}, + nil, + ) + + fakeCloudControllerClient.GetAccessRulesReturns( + []resources.AccessRule{ + {GUID: "rule-guid-other", Name: "other-rule", Selector: "cf:any"}, + }, + ccv3.IncludedResources{}, + ccv3.Warnings{"get-access-rules-warning"}, + nil, + ) + }) + + It("returns an AccessRuleNotFoundError", func() { + Expect(executeErr).To(MatchError(actionerror.AccessRuleNotFoundError{Name: "my-rule"})) + Expect(warnings).To(ConsistOf( + "get-domains-warning", + "get-routes-warning", + "get-access-rules-warning", + )) + }) + }) + }) +}) diff --git a/actor/v7action/cloud_controller_client.go b/actor/v7action/cloud_controller_client.go index c0dc6b8c641..223f7344a2f 100644 --- a/actor/v7action/cloud_controller_client.go +++ b/actor/v7action/cloud_controller_client.go @@ -20,6 +20,7 @@ type CloudControllerClient interface { CancelDeployment(deploymentGUID string) (ccv3.Warnings, error) ContinueDeployment(deploymentGUID string) (ccv3.Warnings, error) CopyPackage(sourcePackageGUID string, targetAppGUID string) (resources.Package, ccv3.Warnings, error) + CreateAccessRule(accessRule resources.AccessRule) (resources.AccessRule, ccv3.Warnings, error) CreateApplication(app resources.Application) (resources.Application, ccv3.Warnings, error) CreateApplicationDeployment(dep resources.Deployment) (string, ccv3.Warnings, error) CreateApplicationProcessScale(appGUID string, process resources.Process) (resources.Process, ccv3.Warnings, error) @@ -42,6 +43,7 @@ type CloudControllerClient interface { CreateSpace(space resources.Space) (resources.Space, ccv3.Warnings, error) CreateSpaceQuota(spaceQuota resources.SpaceQuota) (resources.SpaceQuota, ccv3.Warnings, error) CreateUser(userGUID string) (resources.User, ccv3.Warnings, error) + DeleteAccessRule(guid string) (ccv3.JobURL, ccv3.Warnings, error) DeleteApplication(guid string) (ccv3.JobURL, ccv3.Warnings, error) DeleteApplicationProcessInstance(appGUID string, processType string, instanceIndex int) (ccv3.Warnings, error) DeleteBuildpack(buildpackGUID string) (ccv3.JobURL, ccv3.Warnings, error) @@ -63,6 +65,7 @@ type CloudControllerClient interface { DeleteUser(userGUID string) (ccv3.JobURL, ccv3.Warnings, error) DownloadDroplet(dropletGUID string) ([]byte, ccv3.Warnings, error) EntitleIsolationSegmentToOrganizations(isoGUID string, orgGUIDs []string) (resources.RelationshipList, ccv3.Warnings, error) + GetAccessRules(query ...ccv3.Query) ([]resources.AccessRule, ccv3.IncludedResources, ccv3.Warnings, error) GetApplicationByNameAndSpace(appName string, spaceGUID string) (resources.Application, ccv3.Warnings, error) GetApplicationDropletCurrent(appGUID string) (resources.Droplet, ccv3.Warnings, error) GetApplicationEnvironment(appGUID string) (ccv3.Environment, ccv3.Warnings, error) diff --git a/actor/v7action/domain.go b/actor/v7action/domain.go index 21cabe6abc5..cf65ca2bfa3 100644 --- a/actor/v7action/domain.go +++ b/actor/v7action/domain.go @@ -24,7 +24,7 @@ func (actor Actor) CheckRoute(domainName string, hostname string, path string, p return matches, allWarnings, err } -func (actor Actor) CreateSharedDomain(domainName string, internal bool, routerGroupName string) (Warnings, error) { +func (actor Actor) CreateSharedDomain(domainName string, internal bool, routerGroupName string, enforceAccessRules bool, accessRulesScope string) (Warnings, error) { allWarnings := Warnings{} routerGroupGUID := "" @@ -37,17 +37,25 @@ func (actor Actor) CreateSharedDomain(domainName string, internal bool, routerGr routerGroupGUID = routerGroup.GUID } - _, warnings, err := actor.CloudControllerClient.CreateDomain(resources.Domain{ + domain := resources.Domain{ Name: domainName, Internal: types.NullBool{IsSet: true, Value: internal}, RouterGroup: routerGroupGUID, - }) + } + + // Set enforce_access_rules if specified + if enforceAccessRules { + domain.EnforceAccessRules = types.NullBool{IsSet: true, Value: true} + domain.AccessRulesScope = accessRulesScope + } + + _, warnings, err := actor.CloudControllerClient.CreateDomain(domain) allWarnings = append(allWarnings, Warnings(warnings)...) return allWarnings, err } -func (actor Actor) CreatePrivateDomain(domainName string, orgName string) (Warnings, error) { +func (actor Actor) CreatePrivateDomain(domainName string, orgName string, enforceAccessRules bool, accessRulesScope string) (Warnings, error) { allWarnings := Warnings{} organization, warnings, err := actor.GetOrganizationByName(orgName) allWarnings = append(allWarnings, warnings...) @@ -55,10 +63,19 @@ func (actor Actor) CreatePrivateDomain(domainName string, orgName string) (Warni if err != nil { return allWarnings, err } - _, apiWarnings, err := actor.CloudControllerClient.CreateDomain(resources.Domain{ + + domain := resources.Domain{ Name: domainName, OrganizationGUID: organization.GUID, - }) + } + + // Set enforce_access_rules if specified + if enforceAccessRules { + domain.EnforceAccessRules = types.NullBool{IsSet: true, Value: true} + domain.AccessRulesScope = accessRulesScope + } + + _, apiWarnings, err := actor.CloudControllerClient.CreateDomain(domain) actorWarnings := Warnings(apiWarnings) allWarnings = append(allWarnings, actorWarnings...) diff --git a/actor/v7action/domain_test.go b/actor/v7action/domain_test.go index 68050c6cefb..db30783af06 100644 --- a/actor/v7action/domain_test.go +++ b/actor/v7action/domain_test.go @@ -118,7 +118,7 @@ var _ = Describe("Domain Actions", func() { ) JustBeforeEach(func() { - warnings, executeErr = actor.CreateSharedDomain("the-domain-name", true, routerGroup) + warnings, executeErr = actor.CreateSharedDomain("the-domain-name", true, routerGroup, false, "") }) BeforeEach(func() { @@ -191,7 +191,7 @@ var _ = Describe("Domain Actions", func() { }) It("delegates to the cloud controller client", func() { - warnings, executeErr := actor.CreatePrivateDomain("private-domain-name", "org-name") + warnings, executeErr := actor.CreatePrivateDomain("private-domain-name", "org-name", false, "") Expect(executeErr).To(MatchError("create-error")) Expect(warnings).To(ConsistOf("get-orgs-warning", "create-warning-1", "create-warning-2")) diff --git a/actor/v7action/v7actionfakes/fake_cloud_controller_client.go b/actor/v7action/v7actionfakes/fake_cloud_controller_client.go index dbdca54b57b..27c4b830379 100644 --- a/actor/v7action/v7actionfakes/fake_cloud_controller_client.go +++ b/actor/v7action/v7actionfakes/fake_cloud_controller_client.go @@ -106,6 +106,21 @@ type FakeCloudControllerClient struct { result2 ccv3.Warnings result3 error } + CreateAccessRuleStub func(resources.AccessRule) (resources.AccessRule, ccv3.Warnings, error) + createAccessRuleMutex sync.RWMutex + createAccessRuleArgsForCall []struct { + arg1 resources.AccessRule + } + createAccessRuleReturns struct { + result1 resources.AccessRule + result2 ccv3.Warnings + result3 error + } + createAccessRuleReturnsOnCall map[int]struct { + result1 resources.AccessRule + result2 ccv3.Warnings + result3 error + } CreateApplicationStub func(resources.Application) (resources.Application, ccv3.Warnings, error) createApplicationMutex sync.RWMutex createApplicationArgsForCall []struct { @@ -438,6 +453,21 @@ type FakeCloudControllerClient struct { result2 ccv3.Warnings result3 error } + DeleteAccessRuleStub func(string) (ccv3.JobURL, ccv3.Warnings, error) + deleteAccessRuleMutex sync.RWMutex + deleteAccessRuleArgsForCall []struct { + arg1 string + } + deleteAccessRuleReturns struct { + result1 ccv3.JobURL + result2 ccv3.Warnings + result3 error + } + deleteAccessRuleReturnsOnCall map[int]struct { + result1 ccv3.JobURL + result2 ccv3.Warnings + result3 error + } DeleteApplicationStub func(string) (ccv3.JobURL, ccv3.Warnings, error) deleteApplicationMutex sync.RWMutex deleteApplicationArgsForCall []struct { @@ -766,6 +796,23 @@ type FakeCloudControllerClient struct { result2 ccv3.Warnings result3 error } + GetAccessRulesStub func(...ccv3.Query) ([]resources.AccessRule, ccv3.IncludedResources, ccv3.Warnings, error) + getAccessRulesMutex sync.RWMutex + getAccessRulesArgsForCall []struct { + arg1 []ccv3.Query + } + getAccessRulesReturns struct { + result1 []resources.AccessRule + result2 ccv3.IncludedResources + result3 ccv3.Warnings + result4 error + } + getAccessRulesReturnsOnCall map[int]struct { + result1 []resources.AccessRule + result2 ccv3.IncludedResources + result3 ccv3.Warnings + result4 error + } GetAppFeatureStub func(string, string) (resources.ApplicationFeature, ccv3.Warnings, error) getAppFeatureMutex sync.RWMutex getAppFeatureArgsForCall []struct { @@ -3247,6 +3294,73 @@ func (fake *FakeCloudControllerClient) CopyPackageReturnsOnCall(i int, result1 r }{result1, result2, result3} } +func (fake *FakeCloudControllerClient) CreateAccessRule(arg1 resources.AccessRule) (resources.AccessRule, ccv3.Warnings, error) { + fake.createAccessRuleMutex.Lock() + ret, specificReturn := fake.createAccessRuleReturnsOnCall[len(fake.createAccessRuleArgsForCall)] + fake.createAccessRuleArgsForCall = append(fake.createAccessRuleArgsForCall, struct { + arg1 resources.AccessRule + }{arg1}) + stub := fake.CreateAccessRuleStub + fakeReturns := fake.createAccessRuleReturns + fake.recordInvocation("CreateAccessRule", []interface{}{arg1}) + fake.createAccessRuleMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeCloudControllerClient) CreateAccessRuleCallCount() int { + fake.createAccessRuleMutex.RLock() + defer fake.createAccessRuleMutex.RUnlock() + return len(fake.createAccessRuleArgsForCall) +} + +func (fake *FakeCloudControllerClient) CreateAccessRuleCalls(stub func(resources.AccessRule) (resources.AccessRule, ccv3.Warnings, error)) { + fake.createAccessRuleMutex.Lock() + defer fake.createAccessRuleMutex.Unlock() + fake.CreateAccessRuleStub = stub +} + +func (fake *FakeCloudControllerClient) CreateAccessRuleArgsForCall(i int) resources.AccessRule { + fake.createAccessRuleMutex.RLock() + defer fake.createAccessRuleMutex.RUnlock() + argsForCall := fake.createAccessRuleArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeCloudControllerClient) CreateAccessRuleReturns(result1 resources.AccessRule, result2 ccv3.Warnings, result3 error) { + fake.createAccessRuleMutex.Lock() + defer fake.createAccessRuleMutex.Unlock() + fake.CreateAccessRuleStub = nil + fake.createAccessRuleReturns = struct { + result1 resources.AccessRule + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeCloudControllerClient) CreateAccessRuleReturnsOnCall(i int, result1 resources.AccessRule, result2 ccv3.Warnings, result3 error) { + fake.createAccessRuleMutex.Lock() + defer fake.createAccessRuleMutex.Unlock() + fake.CreateAccessRuleStub = nil + if fake.createAccessRuleReturnsOnCall == nil { + fake.createAccessRuleReturnsOnCall = make(map[int]struct { + result1 resources.AccessRule + result2 ccv3.Warnings + result3 error + }) + } + fake.createAccessRuleReturnsOnCall[i] = struct { + result1 resources.AccessRule + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeCloudControllerClient) CreateApplication(arg1 resources.Application) (resources.Application, ccv3.Warnings, error) { fake.createApplicationMutex.Lock() ret, specificReturn := fake.createApplicationReturnsOnCall[len(fake.createApplicationArgsForCall)] @@ -4723,6 +4837,73 @@ func (fake *FakeCloudControllerClient) CreateUserReturnsOnCall(i int, result1 re }{result1, result2, result3} } +func (fake *FakeCloudControllerClient) DeleteAccessRule(arg1 string) (ccv3.JobURL, ccv3.Warnings, error) { + fake.deleteAccessRuleMutex.Lock() + ret, specificReturn := fake.deleteAccessRuleReturnsOnCall[len(fake.deleteAccessRuleArgsForCall)] + fake.deleteAccessRuleArgsForCall = append(fake.deleteAccessRuleArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.DeleteAccessRuleStub + fakeReturns := fake.deleteAccessRuleReturns + fake.recordInvocation("DeleteAccessRule", []interface{}{arg1}) + fake.deleteAccessRuleMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeCloudControllerClient) DeleteAccessRuleCallCount() int { + fake.deleteAccessRuleMutex.RLock() + defer fake.deleteAccessRuleMutex.RUnlock() + return len(fake.deleteAccessRuleArgsForCall) +} + +func (fake *FakeCloudControllerClient) DeleteAccessRuleCalls(stub func(string) (ccv3.JobURL, ccv3.Warnings, error)) { + fake.deleteAccessRuleMutex.Lock() + defer fake.deleteAccessRuleMutex.Unlock() + fake.DeleteAccessRuleStub = stub +} + +func (fake *FakeCloudControllerClient) DeleteAccessRuleArgsForCall(i int) string { + fake.deleteAccessRuleMutex.RLock() + defer fake.deleteAccessRuleMutex.RUnlock() + argsForCall := fake.deleteAccessRuleArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeCloudControllerClient) DeleteAccessRuleReturns(result1 ccv3.JobURL, result2 ccv3.Warnings, result3 error) { + fake.deleteAccessRuleMutex.Lock() + defer fake.deleteAccessRuleMutex.Unlock() + fake.DeleteAccessRuleStub = nil + fake.deleteAccessRuleReturns = struct { + result1 ccv3.JobURL + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeCloudControllerClient) DeleteAccessRuleReturnsOnCall(i int, result1 ccv3.JobURL, result2 ccv3.Warnings, result3 error) { + fake.deleteAccessRuleMutex.Lock() + defer fake.deleteAccessRuleMutex.Unlock() + fake.DeleteAccessRuleStub = nil + if fake.deleteAccessRuleReturnsOnCall == nil { + fake.deleteAccessRuleReturnsOnCall = make(map[int]struct { + result1 ccv3.JobURL + result2 ccv3.Warnings + result3 error + }) + } + fake.deleteAccessRuleReturnsOnCall[i] = struct { + result1 ccv3.JobURL + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeCloudControllerClient) DeleteApplication(arg1 string) (ccv3.JobURL, ccv3.Warnings, error) { fake.deleteApplicationMutex.Lock() ret, specificReturn := fake.deleteApplicationReturnsOnCall[len(fake.deleteApplicationArgsForCall)] @@ -6196,6 +6377,76 @@ func (fake *FakeCloudControllerClient) EntitleIsolationSegmentToOrganizationsRet }{result1, result2, result3} } +func (fake *FakeCloudControllerClient) GetAccessRules(arg1 ...ccv3.Query) ([]resources.AccessRule, ccv3.IncludedResources, ccv3.Warnings, error) { + fake.getAccessRulesMutex.Lock() + ret, specificReturn := fake.getAccessRulesReturnsOnCall[len(fake.getAccessRulesArgsForCall)] + fake.getAccessRulesArgsForCall = append(fake.getAccessRulesArgsForCall, struct { + arg1 []ccv3.Query + }{arg1}) + stub := fake.GetAccessRulesStub + fakeReturns := fake.getAccessRulesReturns + fake.recordInvocation("GetAccessRules", []interface{}{arg1}) + fake.getAccessRulesMutex.Unlock() + if stub != nil { + return stub(arg1...) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3, ret.result4 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4 +} + +func (fake *FakeCloudControllerClient) GetAccessRulesCallCount() int { + fake.getAccessRulesMutex.RLock() + defer fake.getAccessRulesMutex.RUnlock() + return len(fake.getAccessRulesArgsForCall) +} + +func (fake *FakeCloudControllerClient) GetAccessRulesCalls(stub func(...ccv3.Query) ([]resources.AccessRule, ccv3.IncludedResources, ccv3.Warnings, error)) { + fake.getAccessRulesMutex.Lock() + defer fake.getAccessRulesMutex.Unlock() + fake.GetAccessRulesStub = stub +} + +func (fake *FakeCloudControllerClient) GetAccessRulesArgsForCall(i int) []ccv3.Query { + fake.getAccessRulesMutex.RLock() + defer fake.getAccessRulesMutex.RUnlock() + argsForCall := fake.getAccessRulesArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeCloudControllerClient) GetAccessRulesReturns(result1 []resources.AccessRule, result2 ccv3.IncludedResources, result3 ccv3.Warnings, result4 error) { + fake.getAccessRulesMutex.Lock() + defer fake.getAccessRulesMutex.Unlock() + fake.GetAccessRulesStub = nil + fake.getAccessRulesReturns = struct { + result1 []resources.AccessRule + result2 ccv3.IncludedResources + result3 ccv3.Warnings + result4 error + }{result1, result2, result3, result4} +} + +func (fake *FakeCloudControllerClient) GetAccessRulesReturnsOnCall(i int, result1 []resources.AccessRule, result2 ccv3.IncludedResources, result3 ccv3.Warnings, result4 error) { + fake.getAccessRulesMutex.Lock() + defer fake.getAccessRulesMutex.Unlock() + fake.GetAccessRulesStub = nil + if fake.getAccessRulesReturnsOnCall == nil { + fake.getAccessRulesReturnsOnCall = make(map[int]struct { + result1 []resources.AccessRule + result2 ccv3.IncludedResources + result3 ccv3.Warnings + result4 error + }) + } + fake.getAccessRulesReturnsOnCall[i] = struct { + result1 []resources.AccessRule + result2 ccv3.IncludedResources + result3 ccv3.Warnings + result4 error + }{result1, result2, result3, result4} +} + func (fake *FakeCloudControllerClient) GetAppFeature(arg1 string, arg2 string) (resources.ApplicationFeature, ccv3.Warnings, error) { fake.getAppFeatureMutex.Lock() ret, specificReturn := fake.getAppFeatureReturnsOnCall[len(fake.getAppFeatureArgsForCall)] diff --git a/api/cloudcontroller/ccv3/access_rule.go b/api/cloudcontroller/ccv3/access_rule.go new file mode 100644 index 00000000000..2c391af8d18 --- /dev/null +++ b/api/cloudcontroller/ccv3/access_rule.go @@ -0,0 +1,59 @@ +package ccv3 + +import ( + "code.cloudfoundry.org/cli/v9/api/cloudcontroller/ccv3/internal" + "code.cloudfoundry.org/cli/v9/resources" +) + +// CreateAccessRule creates an access rule for a route +func (client *Client) CreateAccessRule(accessRule resources.AccessRule) (resources.AccessRule, Warnings, error) { + var responseBody resources.AccessRule + + _, warnings, err := client.MakeRequest(RequestParams{ + RequestName: internal.PostAccessRuleRequest, + RequestBody: accessRule, + ResponseBody: &responseBody, + }) + + return responseBody, warnings, err +} + +// GetAccessRules lists access rules +func (client *Client) GetAccessRules(query ...Query) ([]resources.AccessRule, IncludedResources, Warnings, error) { + var accessRules []resources.AccessRule + + includedResources, warnings, err := client.MakeListRequest(RequestParams{ + RequestName: internal.GetAccessRulesRequest, + Query: query, + ResponseBody: resources.AccessRule{}, + AppendToList: func(item interface{}) error { + accessRules = append(accessRules, item.(resources.AccessRule)) + return nil + }, + }) + + return accessRules, includedResources, warnings, err +} + +// GetAccessRule gets a single access rule by GUID +func (client *Client) GetAccessRule(guid string) (resources.AccessRule, Warnings, error) { + var responseBody resources.AccessRule + + _, warnings, err := client.MakeRequest(RequestParams{ + RequestName: internal.GetAccessRuleRequest, + URIParams: internal.Params{"access_rule_guid": guid}, + ResponseBody: &responseBody, + }) + + return responseBody, warnings, err +} + +// DeleteAccessRule deletes an access rule +func (client *Client) DeleteAccessRule(guid string) (JobURL, Warnings, error) { + jobURLString, warnings, err := client.MakeRequest(RequestParams{ + RequestName: internal.DeleteAccessRuleRequest, + URIParams: internal.Params{"access_rule_guid": guid}, + }) + + return JobURL(jobURLString), warnings, err +} diff --git a/api/cloudcontroller/ccv3/included_resources.go b/api/cloudcontroller/ccv3/included_resources.go index 6827248eae1..89af94c2540 100644 --- a/api/cloudcontroller/ccv3/included_resources.go +++ b/api/cloudcontroller/ccv3/included_resources.go @@ -11,6 +11,7 @@ type IncludedResources struct { ServiceBrokers []resources.ServiceBroker `json:"service_brokers,omitempty"` ServicePlans []resources.ServicePlan `json:"service_plans,omitempty"` Apps []resources.Application `json:"apps,omitempty"` + Routes []resources.Route `json:"routes,omitempty"` } func (i *IncludedResources) Merge(resources IncludedResources) { @@ -22,4 +23,5 @@ func (i *IncludedResources) Merge(resources IncludedResources) { i.ServiceInstances = append(i.ServiceInstances, resources.ServiceInstances...) i.ServiceOfferings = append(i.ServiceOfferings, resources.ServiceOfferings...) i.ServicePlans = append(i.ServicePlans, resources.ServicePlans...) + i.Routes = append(i.Routes, resources.Routes...) } diff --git a/api/cloudcontroller/ccv3/internal/api_routes.go b/api/cloudcontroller/ccv3/internal/api_routes.go index d1f7ffd9fe6..40605b74a50 100644 --- a/api/cloudcontroller/ccv3/internal/api_routes.go +++ b/api/cloudcontroller/ccv3/internal/api_routes.go @@ -9,6 +9,7 @@ import "net/http" // If the request returns a single entity by GUID, use the singular (for example // /v3/organizations/:organization_guid is GetOrganization). const ( + DeleteAccessRuleRequest = "DeleteAccessRuleRequest" DeleteApplicationProcessInstanceRequest = "DeleteApplicationProcessInstance" DeleteApplicationRequest = "DeleteApplication" DeleteBuildpackRequest = "DeleteBuildpack" @@ -35,6 +36,8 @@ const ( DeleteSpaceRequest = "DeleteSpace" DeleteSpaceQuotaFromSpaceRequest = "DeleteSpaceQuotaFromSpace" DeleteUserRequest = "DeleteUser" + GetAccessRuleRequest = "GetAccessRuleRequest" + GetAccessRulesRequest = "GetAccessRulesRequest" GetApplicationDropletCurrentRequest = "GetApplicationDropletCurrent" GetApplicationEnvRequest = "GetApplicationEnv" GetApplicationFeaturesRequest = "GetApplicationFeatures" @@ -134,6 +137,7 @@ const ( PatchSpaceQuotaRequest = "PatchSpaceQuota" PatchStackRequest = "PatchStack" PatchMoveRouteRequest = "PatchMoveRouteRequest" + PostAccessRuleRequest = "PostAccessRuleRequest" PostApplicationActionApplyManifest = "PostApplicationActionApplyM" PostApplicationActionRestartRequest = "PostApplicationActionRestart" PostApplicationActionStartRequest = "PostApplicationActionStart" @@ -186,6 +190,10 @@ const ( // APIRoutes is a list of routes used by the router to construct request URLs. var APIRoutes = map[string]Route{ + GetAccessRulesRequest: {Path: "/v3/access_rules", Method: http.MethodGet}, + PostAccessRuleRequest: {Path: "/v3/access_rules", Method: http.MethodPost}, + GetAccessRuleRequest: {Path: "/v3/access_rules/:access_rule_guid", Method: http.MethodGet}, + DeleteAccessRuleRequest: {Path: "/v3/access_rules/:access_rule_guid", Method: http.MethodDelete}, GetApplicationsRequest: {Path: "/v3/apps", Method: http.MethodGet}, PostApplicationRequest: {Path: "/v3/apps", Method: http.MethodPost}, DeleteApplicationRequest: {Path: "/v3/apps/:app_guid", Method: http.MethodDelete}, diff --git a/api/cloudcontroller/ccv3/query.go b/api/cloudcontroller/ccv3/query.go index 99b04abad9b..27bcf995712 100644 --- a/api/cloudcontroller/ccv3/query.go +++ b/api/cloudcontroller/ccv3/query.go @@ -29,6 +29,8 @@ const ( SequenceIDFilter QueryKey = "sequence_ids" // RouteGUIDFilter is a query parameter for listing objects by Route GUID. RouteGUIDFilter QueryKey = "route_guids" + // SelectorsFilter is a query parameter for listing access rules by selector. + SelectorsFilter QueryKey = "selectors" // ServiceInstanceGUIDFilter is a query parameter for listing objects by Service Instance GUID. ServiceInstanceGUIDFilter QueryKey = "service_instance_guids" // SpaceGUIDFilter is a query parameter for listing objects by Space GUID. diff --git a/command/common/command_list_v7.go b/command/common/command_list_v7.go index fcc833a94d3..3177549afa1 100644 --- a/command/common/command_list_v7.go +++ b/command/common/command_list_v7.go @@ -15,6 +15,8 @@ type commandList struct { V3Push v7.PushCommand `command:"v3-push" description:"Push a new app or sync changes to an existing app" hidden:"true"` + AccessRules v7.AccessRulesCommand `command:"access-rules" description:"List all access rules in the target space"` + AddAccessRule v7.AddAccessRuleCommand `command:"add-access-rule" description:"Add an access rule to allow specific apps, spaces, or orgs to access a route"` API v7.APICommand `command:"api" description:"Set or view target api url"` AddNetworkPolicy v7.AddNetworkPolicyCommand `command:"add-network-policy" description:"Create policy to allow direct network traffic from one app to another"` AddPluginRepo plugin.AddPluginRepoCommand `command:"add-plugin-repo" description:"Add a new plugin repository"` @@ -113,6 +115,7 @@ type commandList struct { PurgeServiceOffering v7.PurgeServiceOfferingCommand `command:"purge-service-offering" description:"Recursively remove a service offering and child objects from Cloud Foundry database without making requests to a service broker"` Push v7.PushCommand `command:"push" alias:"p" description:"Push a new app or sync changes to an existing app"` RemoveNetworkPolicy v7.RemoveNetworkPolicyCommand `command:"remove-network-policy" description:"Remove network traffic policy of an app"` + RemoveAccessRule v7.RemoveAccessRuleCommand `command:"remove-access-rule" description:"Remove an access rule from a route"` RemovePluginRepo plugin.RemovePluginRepoCommand `command:"remove-plugin-repo" description:"Remove a plugin repository"` Rename v7.RenameCommand `command:"rename" description:"Rename an app"` RenameOrg v7.RenameOrgCommand `command:"rename-org" description:"Rename an org"` diff --git a/command/common/internal/help_all_display.go b/command/common/internal/help_all_display.go index 8166edc0ba2..923264d910b 100644 --- a/command/common/internal/help_all_display.go +++ b/command/common/internal/help_all_display.go @@ -72,6 +72,7 @@ var HelpCategoryList = []HelpCategory{ {"update-destination"}, {"share-route", "unshare-route"}, {"move-route"}, + {"access-rules", "add-access-rule", "remove-access-rule"}, }, }, { diff --git a/command/flag/arguments.go b/command/flag/arguments.go index 9e3d60263f8..84195b1d4e6 100644 --- a/command/flag/arguments.go +++ b/command/flag/arguments.go @@ -413,3 +413,13 @@ type TaskArgs struct { AppName string `positional-arg-name:"APP_NAME" required:"true" description:"The application name"` TaskID int `positional-arg-name:"TASK_ID" required:"true" description:"The Task ID for the application"` } + +type AddAccessRuleArgs struct { + RuleName string `positional-arg-name:"RULE_NAME" required:"true" description:"The access rule name"` + Domain string `positional-arg-name:"DOMAIN" required:"true" description:"The domain name"` +} + +type RemoveAccessRuleArgs struct { + RuleName string `positional-arg-name:"RULE_NAME" required:"true" description:"The access rule name"` + Domain string `positional-arg-name:"DOMAIN" required:"true" description:"The domain name"` +} diff --git a/command/v7/access_rules_command.go b/command/v7/access_rules_command.go new file mode 100644 index 00000000000..0b29c17e4d9 --- /dev/null +++ b/command/v7/access_rules_command.go @@ -0,0 +1,103 @@ +package v7 + +import ( + "fmt" + + "code.cloudfoundry.org/cli/v9/resources" + "code.cloudfoundry.org/cli/v9/util/ui" +) + +type AccessRulesCommand struct { + BaseCommand + + Domain string `long:"domain" description:"Filter by domain name"` + Hostname string `long:"hostname" description:"Filter by hostname"` + Path string `long:"path" description:"Filter by path"` + Labels string `long:"labels" description:"Selector to filter access rules by labels"` + + usage interface{} `usage:"CF_NAME access-rules [--domain DOMAIN] [--hostname HOSTNAME] [--path PATH] [--labels SELECTOR]\n\nEXAMPLES:\n cf access-rules\n cf access-rules --domain apps.identity\n cf access-rules --domain apps.identity --hostname backend\n cf access-rules --labels env=prod"` + relatedCommands interface{} `related_commands:"add-access-rule, remove-access-rule, routes"` +} + +func (cmd AccessRulesCommand) Execute(args []string) error { + // Check target (org + space required) + err := cmd.SharedActor.CheckTarget(true, true) + if err != nil { + return err + } + + // Get current user + user, err := cmd.Actor.GetCurrentUser() + if err != nil { + return err + } + + // Display contextual header + cmd.UI.DisplayTextWithFlavor( + "Getting access rules in org {{.OrgName}} / space {{.SpaceName}} as {{.Username}}...", + map[string]interface{}{ + "OrgName": cmd.Config.TargetedOrganization().Name, + "SpaceName": cmd.Config.TargetedSpace().Name, + "Username": user.Name, + }) + cmd.UI.DisplayNewline() + + // Fetch access rules for space with filters + rulesWithRoutes, warnings, err := cmd.Actor.GetAccessRulesForSpace( + cmd.Config.TargetedSpace().GUID, + cmd.Domain, + cmd.Hostname, + cmd.Path, + cmd.Labels, + ) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + // Handle empty results + if len(rulesWithRoutes) == 0 { + cmd.UI.DisplayText("No access rules found.") + return nil + } + + // Build table data + table := [][]string{ + { + cmd.UI.TranslateText("name"), + cmd.UI.TranslateText("route"), + cmd.UI.TranslateText("selector"), + cmd.UI.TranslateText("scope"), + cmd.UI.TranslateText("source"), + }, + } + + for _, ruleWithRoute := range rulesWithRoutes { + table = append(table, []string{ + ruleWithRoute.Name, + formatRoute(ruleWithRoute.Route, ruleWithRoute.DomainName), + ruleWithRoute.Selector, + ruleWithRoute.ScopeType, + ruleWithRoute.SourceName, + }) + } + + // Display table + cmd.UI.DisplayTableWithHeader("", table, ui.DefaultTableSpacePadding) + + return nil +} + +// formatRoute formats a route as hostname.domain/path +func formatRoute(route resources.Route, domainName string) string { + var formatted string + if route.Host != "" { + formatted = fmt.Sprintf("%s.%s", route.Host, domainName) + } else { + formatted = domainName + } + if route.Path != "" { + formatted += route.Path + } + return formatted +} diff --git a/command/v7/access_rules_command_test.go b/command/v7/access_rules_command_test.go new file mode 100644 index 00000000000..bf34d3ea2b9 --- /dev/null +++ b/command/v7/access_rules_command_test.go @@ -0,0 +1,356 @@ +package v7_test + +import ( + "errors" + + "code.cloudfoundry.org/cli/v9/actor/actionerror" + "code.cloudfoundry.org/cli/v9/actor/v7action" + "code.cloudfoundry.org/cli/v9/api/cloudcontroller/ccerror" + "code.cloudfoundry.org/cli/v9/command/commandfakes" + v7 "code.cloudfoundry.org/cli/v9/command/v7" + "code.cloudfoundry.org/cli/v9/command/v7/v7fakes" + "code.cloudfoundry.org/cli/v9/resources" + "code.cloudfoundry.org/cli/v9/util/configv3" + "code.cloudfoundry.org/cli/v9/util/ui" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" +) + +var _ = Describe("access-rules Command", func() { + var ( + cmd v7.AccessRulesCommand + testUI *ui.UI + fakeConfig *commandfakes.FakeConfig + fakeSharedActor *commandfakes.FakeSharedActor + fakeActor *v7fakes.FakeActor + binaryName string + executeErr error + ) + + BeforeEach(func() { + testUI = ui.NewTestUI(nil, NewBuffer(), NewBuffer()) + fakeConfig = new(commandfakes.FakeConfig) + fakeSharedActor = new(commandfakes.FakeSharedActor) + fakeActor = new(v7fakes.FakeActor) + + binaryName = "faceman" + fakeConfig.BinaryNameReturns(binaryName) + + cmd = v7.AccessRulesCommand{ + BaseCommand: v7.BaseCommand{ + UI: testUI, + Config: fakeConfig, + Actor: fakeActor, + SharedActor: fakeSharedActor, + }, + } + + fakeConfig.TargetedOrganizationReturns(configv3.Organization{ + Name: "some-org", + GUID: "some-org-guid", + }) + fakeConfig.TargetedSpaceReturns(configv3.Space{ + Name: "some-space", + GUID: "some-space-guid", + }) + + fakeActor.GetCurrentUserReturns(configv3.User{Name: "steve"}, nil) + }) + + JustBeforeEach(func() { + executeErr = cmd.Execute(nil) + }) + + When("checking target fails", func() { + BeforeEach(func() { + fakeSharedActor.CheckTargetReturns(actionerror.NoOrganizationTargetedError{BinaryName: binaryName}) + }) + + It("returns an error", func() { + Expect(executeErr).To(MatchError(actionerror.NoOrganizationTargetedError{BinaryName: binaryName})) + + Expect(fakeSharedActor.CheckTargetCallCount()).To(Equal(1)) + checkTargetedOrg, checkTargetedSpace := fakeSharedActor.CheckTargetArgsForCall(0) + Expect(checkTargetedOrg).To(BeTrue()) + Expect(checkTargetedSpace).To(BeTrue()) + }) + }) + + When("the user is not logged in", func() { + var expectedErr error + + BeforeEach(func() { + expectedErr = errors.New("some current user error") + fakeActor.GetCurrentUserReturns(configv3.User{}, expectedErr) + }) + + It("returns an error", func() { + Expect(executeErr).To(Equal(expectedErr)) + }) + }) + + When("getting access rules returns an error", func() { + var expectedErr error + + BeforeEach(func() { + expectedErr = ccerror.RequestError{} + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{}, v7action.Warnings{"warning-1", "warning-2"}, expectedErr) + }) + + It("returns the error and prints warnings", func() { + Expect(executeErr).To(Equal(ccerror.RequestError{})) + + Expect(testUI.Out).To(Say(`Getting access rules in org some-org / space some-space as steve\.\.\.`)) + + Expect(testUI.Err).To(Say("warning-1")) + Expect(testUI.Err).To(Say("warning-2")) + }) + }) + + When("getting access rules succeeds", func() { + BeforeEach(func() { + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{ + { + AccessRule: resources.AccessRule{ + GUID: "rule-guid-1", + Name: "rule-1", + Selector: "cf:app:app-guid-1", + }, + Route: resources.Route{ + GUID: "route-guid-1", + Host: "myapp", + Path: "/api", + }, + DomainName: "example.com", + ScopeType: "app", + SourceName: "my-app", + }, + { + AccessRule: resources.AccessRule{ + GUID: "rule-guid-2", + Name: "rule-2", + Selector: "cf:any", + }, + Route: resources.Route{ + GUID: "route-guid-2", + Host: "webapp", + Path: "", + }, + DomainName: "test.com", + ScopeType: "any", + SourceName: "(any app)", + }, + }, v7action.Warnings{"warning-1"}, nil) + }) + + It("displays the access rules in a table", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(testUI.Out).To(Say(`Getting access rules in org some-org / space some-space as steve\.\.\.`)) + Expect(testUI.Out).To(Say(`name\s+route\s+selector\s+scope\s+source`)) + Expect(testUI.Out).To(Say(`rule-1\s+myapp\.example\.com/api\s+cf:app:app-guid-1\s+my-app`)) + Expect(testUI.Out).To(Say(`rule-2\s+webapp\.test\.com\s+cf:any\s+\(any app\)`)) + + Expect(testUI.Err).To(Say("warning-1")) + + Expect(fakeActor.GetAccessRulesForSpaceCallCount()).To(Equal(1)) + spaceGUID, domainName, hostname, path, labelSelector := fakeActor.GetAccessRulesForSpaceArgsForCall(0) + Expect(spaceGUID).To(Equal("some-space-guid")) + Expect(domainName).To(Equal("")) + Expect(hostname).To(Equal("")) + Expect(path).To(Equal("")) + Expect(labelSelector).To(Equal("")) + }) + }) + + When("no access rules exist", func() { + BeforeEach(func() { + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{}, v7action.Warnings{}, nil) + }) + + It("displays a message indicating no access rules found", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(testUI.Out).To(Say(`Getting access rules in org some-org / space some-space as steve\.\.\.`)) + Expect(testUI.Out).To(Say(`No access rules found\.`)) + }) + }) + + When("filtering by domain", func() { + BeforeEach(func() { + cmd.Domain = "example.com" + + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{ + { + AccessRule: resources.AccessRule{ + GUID: "rule-guid-1", + Name: "rule-1", + Selector: "cf:any", + }, + Route: resources.Route{ + GUID: "route-guid-1", + Host: "myapp", + }, + DomainName: "example.com", + ScopeType: "any", + SourceName: "(any app)", + }, + }, v7action.Warnings{}, nil) + }) + + It("passes the domain filter to the actor", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(fakeActor.GetAccessRulesForSpaceCallCount()).To(Equal(1)) + spaceGUID, domainName, hostname, path, labelSelector := fakeActor.GetAccessRulesForSpaceArgsForCall(0) + Expect(spaceGUID).To(Equal("some-space-guid")) + Expect(domainName).To(Equal("example.com")) + Expect(hostname).To(Equal("")) + Expect(path).To(Equal("")) + Expect(labelSelector).To(Equal("")) + }) + }) + + When("filtering by hostname", func() { + BeforeEach(func() { + cmd.Hostname = "myapp" + + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{}, v7action.Warnings{}, nil) + }) + + It("passes the hostname filter to the actor", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(fakeActor.GetAccessRulesForSpaceCallCount()).To(Equal(1)) + _, _, hostname, _, _ := fakeActor.GetAccessRulesForSpaceArgsForCall(0) + Expect(hostname).To(Equal("myapp")) + }) + }) + + When("filtering by path", func() { + BeforeEach(func() { + cmd.Path = "/api" + + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{}, v7action.Warnings{}, nil) + }) + + It("passes the path filter to the actor", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(fakeActor.GetAccessRulesForSpaceCallCount()).To(Equal(1)) + _, _, _, path, _ := fakeActor.GetAccessRulesForSpaceArgsForCall(0) + Expect(path).To(Equal("/api")) + }) + }) + + When("filtering by labels", func() { + BeforeEach(func() { + cmd.Labels = "env=production,tier=frontend" + + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{}, v7action.Warnings{}, nil) + }) + + It("passes the label selector to the actor", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(fakeActor.GetAccessRulesForSpaceCallCount()).To(Equal(1)) + _, _, _, _, labelSelector := fakeActor.GetAccessRulesForSpaceArgsForCall(0) + Expect(labelSelector).To(Equal("env=production,tier=frontend")) + }) + }) + + When("using multiple filters", func() { + BeforeEach(func() { + cmd.Domain = "example.com" + cmd.Hostname = "myapp" + cmd.Path = "/api" + cmd.Labels = "env=production" + + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{ + { + AccessRule: resources.AccessRule{ + GUID: "rule-guid-1", + Name: "filtered-rule", + Selector: "cf:app:app-guid-1", + }, + Route: resources.Route{ + GUID: "route-guid-1", + Host: "myapp", + Path: "/api", + }, + DomainName: "example.com", + ScopeType: "app", + SourceName: "my-app", + }, + }, v7action.Warnings{}, nil) + }) + + It("passes all filters to the actor", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(fakeActor.GetAccessRulesForSpaceCallCount()).To(Equal(1)) + spaceGUID, domainName, hostname, path, labelSelector := fakeActor.GetAccessRulesForSpaceArgsForCall(0) + Expect(spaceGUID).To(Equal("some-space-guid")) + Expect(domainName).To(Equal("example.com")) + Expect(hostname).To(Equal("myapp")) + Expect(path).To(Equal("/api")) + Expect(labelSelector).To(Equal("env=production")) + }) + + It("displays the filtered results", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(testUI.Out).To(Say(`Getting access rules in org some-org / space some-space as steve\.\.\.`)) + Expect(testUI.Out).To(Say(`name\s+route\s+selector\s+scope\s+source`)) + Expect(testUI.Out).To(Say(`filtered-rule\s+myapp\.example\.com/api\s+cf:app:app-guid-1\s+my-app`)) + }) + }) + + When("route formatting handles edge cases", func() { + BeforeEach(func() { + fakeActor.GetAccessRulesForSpaceReturns([]v7action.AccessRuleWithRoute{ + { + AccessRule: resources.AccessRule{ + GUID: "rule-guid-1", + Name: "no-host-rule", + Selector: "cf:any", + }, + Route: resources.Route{ + GUID: "route-guid-1", + Host: "", + Path: "/api", + }, + DomainName: "example.com", + ScopeType: "any", + SourceName: "(any app)", + }, + { + AccessRule: resources.AccessRule{ + GUID: "rule-guid-2", + Name: "no-path-rule", + Selector: "cf:any", + }, + Route: resources.Route{ + GUID: "route-guid-2", + Host: "myapp", + Path: "", + }, + DomainName: "test.com", + ScopeType: "any", + SourceName: "(any app)", + }, + }, v7action.Warnings{}, nil) + }) + + It("formats routes correctly", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + // No host, with path: "example.com/api" + Expect(testUI.Out).To(Say(`no-host-rule\s+example\.com/api`)) + + // With host, no path: "myapp.test.com" + Expect(testUI.Out).To(Say(`no-path-rule\s+myapp\.test\.com`)) + }) + }) +}) diff --git a/command/v7/actor.go b/command/v7/actor.go index daeac22dd0a..a5fecefc786 100644 --- a/command/v7/actor.go +++ b/command/v7/actor.go @@ -23,6 +23,7 @@ import ( type Actor interface { ApplyOrganizationQuotaByName(quotaName string, orgGUID string) (v7action.Warnings, error) ApplySpaceQuotaByName(quotaName string, spaceGUID string, orgGUID string) (v7action.Warnings, error) + AddAccessRule(ruleName, domainName, selector, hostname, path string) (v7action.Warnings, error) AssignIsolationSegmentToSpaceByNameAndSpace(isolationSegmentName string, spaceGUID string) (v7action.Warnings, error) Authenticate(credentials map[string]string, origin string, grantType uaa.GrantType) error BindSecurityGroupToSpaces(securityGroupGUID string, spaces []resources.Space, lifecycle constant.SecurityGroupLifecycle) (v7action.Warnings, error) @@ -44,20 +45,21 @@ type Actor interface { CreateOrgRole(roleType constant.RoleType, orgGUID string, userNameOrGUID string, userOrigin string, isClient bool) (v7action.Warnings, error) CreateOrganization(orgName string) (resources.Organization, v7action.Warnings, error) CreateOrganizationQuota(name string, limits v7action.QuotaLimits) (v7action.Warnings, error) - CreatePrivateDomain(domainName string, orgName string) (v7action.Warnings, error) + CreatePrivateDomain(domainName string, orgName string, enforceAccessRules bool, accessRulesScope string) (v7action.Warnings, error) CreateRoute(spaceGUID, domainName, hostname, path string, port int, options map[string]*string) (resources.Route, v7action.Warnings, error) CreateRouteBinding(params v7action.CreateRouteBindingParams) (chan v7action.PollJobEvent, v7action.Warnings, error) CreateSecurityGroup(name, filePath string) (v7action.Warnings, error) CreateServiceAppBinding(params v7action.CreateServiceAppBindingParams) (chan v7action.PollJobEvent, v7action.Warnings, error) CreateServiceBroker(model resources.ServiceBroker) (v7action.Warnings, error) CreateServiceKey(params v7action.CreateServiceKeyParams) (chan v7action.PollJobEvent, v7action.Warnings, error) - CreateSharedDomain(domainName string, internal bool, routerGroupName string) (v7action.Warnings, error) + CreateSharedDomain(domainName string, internal bool, routerGroupName string, enforceAccessRules bool, accessRulesScope string) (v7action.Warnings, error) CreateSpace(spaceName, orgGUID string) (resources.Space, v7action.Warnings, error) CreateSpaceQuota(spaceQuotaName string, orgGuid string, limits v7action.QuotaLimits) (v7action.Warnings, error) CreateSpaceRole(roleType constant.RoleType, orgGUID string, spaceGUID string, userNameOrGUID string, userOrigin string, isClient bool) (v7action.Warnings, error) CreateUser(username string, password string, origin string) (resources.User, v7action.Warnings, error) CreateUserProvidedServiceInstance(instance resources.ServiceInstance) (v7action.Warnings, error) DeleteApplicationByNameAndSpace(name, spaceGUID string, deleteRoutes bool) (v7action.Warnings, error) + DeleteAccessRule(ruleName, domainName, hostname, path string) (v7action.Warnings, error) DeleteBuildpackByNameAndStackAndLifecycle(buildpackName string, buildpackStack string, buildpackLifecycle string) (v7action.Warnings, error) DeleteDomain(domain resources.Domain) (v7action.Warnings, error) DeleteInstanceByApplicationNameSpaceProcessTypeAndIndex(appName string, spaceGUID string, processType string, instanceIndex int) (v7action.Warnings, error) @@ -87,6 +89,8 @@ type Actor interface { EnableServiceAccess(offeringName, brokerName, orgName, planName string) (v7action.SkippedPlans, v7action.Warnings, error) EntitleIsolationSegmentToOrganizationByName(isolationSegmentName string, orgName string) (v7action.Warnings, error) GetAppFeature(appGUID string, featureName string) (resources.ApplicationFeature, v7action.Warnings, error) + GetAccessRulesByRoute(domainName, hostname, path string) ([]resources.AccessRule, v7action.Warnings, error) + GetAccessRulesForSpace(spaceGUID string, domainName string, hostname string, path string, labelSelector string) ([]v7action.AccessRuleWithRoute, v7action.Warnings, error) GetAppSummariesForSpace(spaceGUID string, labels string, omitStats bool) ([]v7action.ApplicationSummary, v7action.Warnings, error) GetApplicationByNameAndSpace(appName string, spaceGUID string) (resources.Application, v7action.Warnings, error) GetApplicationMapForRoute(route resources.Route) (map[string]resources.Application, v7action.Warnings, error) diff --git a/command/v7/add_access_rule_command.go b/command/v7/add_access_rule_command.go new file mode 100644 index 00000000000..b3db3914811 --- /dev/null +++ b/command/v7/add_access_rule_command.go @@ -0,0 +1,284 @@ +package v7 + +import ( + "fmt" + + "code.cloudfoundry.org/cli/v9/actor/actionerror" + "code.cloudfoundry.org/cli/v9/actor/v7action" + "code.cloudfoundry.org/cli/v9/command/flag" + "code.cloudfoundry.org/cli/v9/command/translatableerror" +) + +type AddAccessRuleCommand struct { + BaseCommand + + RequiredArgs flag.AddAccessRuleArgs `positional-args:"yes"` + Hostname string `long:"hostname" required:"true" description:"Hostname for the route"` + Path string `long:"path" description:"Path for the route"` + + // Source resolution flags (mutually exclusive as primary source) + SourceApp string `long:"source-app" description:"Allow access from this app (by name)"` + SourceSpace string `long:"source-space" description:"Allow access from all apps in this space (by name) or specify the space for --source-app"` + SourceOrg string `long:"source-org" description:"Allow access from all apps in this org (by name) or specify the org for --source-space/--source-app"` + SourceAny bool `long:"source-any" description:"Allow access from any authenticated app"` + + // Advanced: raw selector flag + Selector string `long:"selector" description:"Raw selector (cf:app:, cf:space:, cf:org:, or cf:any)"` + + usage interface{} `usage:"CF_NAME add-access-rule RULE_NAME DOMAIN --hostname HOSTNAME [--source-app APP_NAME [--source-space SPACE_NAME] [--source-org ORG_NAME] | --source-space SPACE_NAME [--source-org ORG_NAME] | --source-org ORG_NAME | --source-any | --selector SELECTOR] [--path PATH]\n\nALLOW ACCESS TO A ROUTE:\n Create an access rule that allows specific apps, spaces, or orgs to access a route using mTLS authentication.\n\nEXAMPLES:\n # Allow the \"frontend-app\" (in current space) to access the backend route\n cf add-access-rule allow-frontend apps.identity --source-app frontend-app --hostname backend\n\n # Allow an app in a different space to access the route\n cf add-access-rule allow-other-space apps.identity --source-app api-client --source-space other-space --hostname backend\n\n # Allow an app in a different org to access the route\n cf add-access-rule allow-other-org apps.identity --source-app external-client --source-space external-space --source-org external-org --hostname backend\n\n # Allow all apps in the \"monitoring\" space to access the API metrics endpoint\n cf add-access-rule allow-monitoring apps.identity --source-space monitoring --hostname api --path /metrics\n\n # Allow all apps in a space in a different org\n cf add-access-rule allow-prod-space apps.identity --source-space prod-space --source-org prod-org --hostname api\n\n # Allow all apps in the \"platform\" org to access the route\n cf add-access-rule allow-platform-org apps.identity --source-org platform --hostname shared-api\n\n # Allow any authenticated app to access the public API\n cf add-access-rule allow-all apps.identity --source-any --hostname public-api\n\n # Use raw selector (advanced)\n cf add-access-rule allow-raw apps.identity --selector cf:app:d76446a1-f429-4444-8797-be2f78b75b08 --hostname backend"` + relatedCommands interface{} `related_commands:"access-rules, remove-access-rule, create-shared-domain"` +} + +func (cmd AddAccessRuleCommand) Execute(args []string) error { + // Validate source flags + if err := cmd.validateSourceFlags(); err != nil { + return err + } + + err := cmd.SharedActor.CheckTarget(true, true) + if err != nil { + return err + } + + user, err := cmd.Actor.GetCurrentUser() + if err != nil { + return err + } + + // Resolve selector from source flags + selector, scopeDisplay, warnings, err := cmd.resolveSelector() + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + // Validate selector format + if err := validateSelector(selector); err != nil { + return err + } + + ruleName := cmd.RequiredArgs.RuleName + domainName := cmd.RequiredArgs.Domain + + cmd.UI.DisplayTextWithFlavor("Adding access rule {{.RuleName}} for route {{.Hostname}}.{{.Domain}}{{.Path}} as {{.User}}...", + map[string]interface{}{ + "RuleName": ruleName, + "Hostname": cmd.Hostname, + "Domain": domainName, + "Path": formatPath(cmd.Path), + "User": user.Name, + }) + + // Display resolved source (for transparency) + cmd.UI.DisplayText(" {{.ScopeDisplay}}", + map[string]interface{}{ + "ScopeDisplay": scopeDisplay, + }) + cmd.UI.DisplayText(" selector: {{.Selector}}", + map[string]interface{}{ + "Selector": selector, + }) + + warnings, err = cmd.Actor.AddAccessRule(ruleName, domainName, selector, cmd.Hostname, cmd.Path) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + cmd.UI.DisplayOK() + cmd.UI.DisplayText("TIP: Access rule '{{.RuleName}}' has been created. It may take a few seconds for the rule to propagate to GoRouter.", + map[string]interface{}{ + "RuleName": ruleName, + }) + + return nil +} + +// validateSourceFlags ensures exactly one source target is specified and validates combinations +func (cmd AddAccessRuleCommand) validateSourceFlags() error { + sourceFlags := []string{} + + if cmd.Selector != "" { + sourceFlags = append(sourceFlags, "--selector") + } + if cmd.SourceApp != "" { + sourceFlags = append(sourceFlags, "--source-app") + } + if cmd.SourceSpace != "" && cmd.SourceApp == "" { + // --source-space only counts as a primary source if --source-app is NOT provided + sourceFlags = append(sourceFlags, "--source-space") + } + if cmd.SourceOrg != "" && cmd.SourceSpace == "" && cmd.SourceApp == "" { + // --source-org only counts as a primary source if neither --source-space nor --source-app are provided + sourceFlags = append(sourceFlags, "--source-org") + } + if cmd.SourceAny { + sourceFlags = append(sourceFlags, "--source-any") + } + + if len(sourceFlags) == 0 { + return translatableerror.RequiredArgumentError{ + ArgumentName: "one of: --source-app, --source-space, --source-org, --source-any, or --selector", + } + } + + if len(sourceFlags) > 1 { + return translatableerror.ArgumentCombinationError{ + Args: sourceFlags, + } + } + + return nil +} + +// resolveSelector resolves source flags to a selector string +// Returns (selector, scopeDisplay, warnings, error) +// scopeDisplay is a human-readable description for output (e.g., "scope: app, source: frontend-app") +func (cmd AddAccessRuleCommand) resolveSelector() (string, string, v7action.Warnings, error) { + var allWarnings v7action.Warnings + + // Priority: --selector flag (raw selector, no resolution needed) + if cmd.Selector != "" { + return cmd.Selector, fmt.Sprintf("selector: %s", cmd.Selector), allWarnings, nil + } + + // --source-any + if cmd.SourceAny { + return "cf:any", "scope: any, source: any authenticated app", allWarnings, nil + } + + // --source-app (with optional --source-space and --source-org for cross-space/org lookup) + if cmd.SourceApp != "" { + // Determine space GUID for app lookup + spaceGUID := cmd.Config.TargetedSpace().GUID + spaceName := cmd.Config.TargetedSpace().Name + orgName := cmd.Config.TargetedOrganization().Name + + if cmd.SourceSpace != "" { + // Determine org GUID for space lookup + orgGUID := cmd.Config.TargetedOrganization().GUID + if cmd.SourceOrg != "" { + org, warnings, err := cmd.Actor.GetOrganizationByName(cmd.SourceOrg) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return "", "", allWarnings, err + } + orgGUID = org.GUID + orgName = cmd.SourceOrg + } + + // Resolve space by name + space, warnings, err := cmd.Actor.GetSpaceByNameAndOrganization(cmd.SourceSpace, orgGUID) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return "", "", allWarnings, err + } + spaceGUID = space.GUID + spaceName = cmd.SourceSpace + } + + // Resolve app by name in the determined space + app, warnings, err := cmd.Actor.GetApplicationByNameAndSpace(cmd.SourceApp, spaceGUID) + allWarnings = append(allWarnings, warnings...) + if err != nil { + // Enhanced error message for app not found + if _, ok := err.(actionerror.ApplicationNotFoundError); ok { + if cmd.SourceSpace == "" { + // App not found in current space + return "", "", allWarnings, fmt.Errorf( + "App '%s' not found in space '%s' / org '%s'.\nTIP: If the app is in a different space or org, use --source-space and/or --source-org flags.", + cmd.SourceApp, + cmd.Config.TargetedSpace().Name, + cmd.Config.TargetedOrganization().Name, + ) + } + } + return "", "", allWarnings, err + } + + scopeDisplay := fmt.Sprintf("scope: app, source: %s", cmd.SourceApp) + if cmd.SourceSpace != "" { + scopeDisplay += fmt.Sprintf(" (space: %s", spaceName) + if cmd.SourceOrg != "" { + scopeDisplay += fmt.Sprintf(", org: %s", orgName) + } + scopeDisplay += ")" + } + + return fmt.Sprintf("cf:app:%s", app.GUID), scopeDisplay, allWarnings, nil + } + + // --source-space (without --source-app, so create space-level rule) + if cmd.SourceSpace != "" { + // Determine org GUID for space lookup + orgGUID := cmd.Config.TargetedOrganization().GUID + orgName := cmd.Config.TargetedOrganization().Name + if cmd.SourceOrg != "" { + org, warnings, err := cmd.Actor.GetOrganizationByName(cmd.SourceOrg) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return "", "", allWarnings, err + } + orgGUID = org.GUID + orgName = cmd.SourceOrg + } + + // Resolve space by name + space, warnings, err := cmd.Actor.GetSpaceByNameAndOrganization(cmd.SourceSpace, orgGUID) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return "", "", allWarnings, err + } + + scopeDisplay := fmt.Sprintf("scope: space, source: %s", cmd.SourceSpace) + if cmd.SourceOrg != "" { + scopeDisplay += fmt.Sprintf(" (org: %s)", orgName) + } + + return fmt.Sprintf("cf:space:%s", space.GUID), scopeDisplay, allWarnings, nil + } + + // --source-org (without --source-space or --source-app, so create org-level rule) + if cmd.SourceOrg != "" { + org, warnings, err := cmd.Actor.GetOrganizationByName(cmd.SourceOrg) + allWarnings = append(allWarnings, warnings...) + if err != nil { + return "", "", allWarnings, err + } + + scopeDisplay := fmt.Sprintf("scope: org, source: %s", cmd.SourceOrg) + + return fmt.Sprintf("cf:org:%s", org.GUID), scopeDisplay, allWarnings, nil + } + + // Should never reach here due to validation + return "", "", allWarnings, fmt.Errorf("no source specified") +} + +func validateSelector(selector string) error { + // Basic validation - check for cf:app:, cf:space:, cf:org:, or cf:any prefix + validPrefixes := []string{"cf:app:", "cf:space:", "cf:org:", "cf:any"} + for _, prefix := range validPrefixes { + if len(selector) >= len(prefix) && selector[:len(prefix)] == prefix { + if prefix == "cf:any" { + if selector != "cf:any" { + return fmt.Errorf("selector 'cf:any' must not have a GUID suffix") + } + return nil + } + // For other selectors, ensure there's a GUID after the prefix + if len(selector) <= len(prefix) { + return fmt.Errorf("selector '%s' must include a GUID (e.g., %s)", selector, prefix) + } + return nil + } + } + return fmt.Errorf("selector must start with one of: cf:app:, cf:space:, cf:org:, or be exactly 'cf:any'") +} + +func formatPath(path string) string { + if path == "" { + return "" + } + return path +} diff --git a/command/v7/add_access_rule_command_test.go b/command/v7/add_access_rule_command_test.go new file mode 100644 index 00000000000..f6b73907d28 --- /dev/null +++ b/command/v7/add_access_rule_command_test.go @@ -0,0 +1,453 @@ +package v7_test + +import ( + "code.cloudfoundry.org/cli/v9/actor/actionerror" + "code.cloudfoundry.org/cli/v9/actor/v7action" + "code.cloudfoundry.org/cli/v9/command/commandfakes" + "code.cloudfoundry.org/cli/v9/command/flag" + "code.cloudfoundry.org/cli/v9/command/translatableerror" + . "code.cloudfoundry.org/cli/v9/command/v7" + "code.cloudfoundry.org/cli/v9/command/v7/v7fakes" + "code.cloudfoundry.org/cli/v9/resources" + "code.cloudfoundry.org/cli/v9/util/configv3" + "code.cloudfoundry.org/cli/v9/util/ui" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" +) + +var _ = Describe("add-access-rule Command", func() { + var ( + cmd AddAccessRuleCommand + testUI *ui.UI + fakeConfig *commandfakes.FakeConfig + fakeSharedActor *commandfakes.FakeSharedActor + fakeActor *v7fakes.FakeActor + executeErr error + args []string + ) + + BeforeEach(func() { + testUI = ui.NewTestUI(nil, NewBuffer(), NewBuffer()) + fakeConfig = new(commandfakes.FakeConfig) + fakeSharedActor = new(commandfakes.FakeSharedActor) + fakeActor = new(v7fakes.FakeActor) + + cmd = AddAccessRuleCommand{ + BaseCommand: BaseCommand{ + UI: testUI, + Config: fakeConfig, + SharedActor: fakeSharedActor, + Actor: fakeActor, + }, + } + + // Setup default config returns + fakeConfig.TargetedOrganizationReturns(configv3.Organization{ + GUID: "org-guid", + Name: "org-name", + }) + fakeConfig.TargetedSpaceReturns(configv3.Space{ + GUID: "space-guid", + Name: "space-name", + }) + + fakeActor.GetCurrentUserReturns(configv3.User{Name: "test-user"}, nil) + + args = []string{} + }) + + JustBeforeEach(func() { + executeErr = cmd.Execute(args) + }) + + Describe("validation", func() { + Context("when no source flags are provided", func() { + BeforeEach(func() { + cmd.RequiredArgs = flag.AddAccessRuleArgs{ + RuleName: "test-rule", + Domain: "apps.internal", + } + cmd.Hostname = "backend" + }) + + It("returns a RequiredArgumentError", func() { + Expect(executeErr).To(MatchError(translatableerror.RequiredArgumentError{ + ArgumentName: "one of: --source-app, --source-space, --source-org, --source-any, or --selector", + })) + }) + }) + + Context("when multiple mutually exclusive source flags are provided", func() { + BeforeEach(func() { + cmd.RequiredArgs = flag.AddAccessRuleArgs{ + RuleName: "test-rule", + Domain: "apps.internal", + } + cmd.Hostname = "backend" + cmd.SourceApp = "app-name" + cmd.SourceAny = true + }) + + It("returns an ArgumentCombinationError", func() { + Expect(executeErr).To(MatchError(translatableerror.ArgumentCombinationError{ + Args: []string{"--source-app", "--source-any"}, + })) + }) + }) + + Context("when --source-space and --source-any are both provided", func() { + BeforeEach(func() { + cmd.RequiredArgs = flag.AddAccessRuleArgs{ + RuleName: "test-rule", + Domain: "apps.internal", + } + cmd.Hostname = "backend" + cmd.SourceSpace = "some-space" + cmd.SourceAny = true + }) + + It("returns an ArgumentCombinationError", func() { + Expect(executeErr).To(MatchError(translatableerror.ArgumentCombinationError{ + Args: []string{"--source-space", "--source-any"}, + })) + }) + }) + }) + + When("the user is logged in, an org is targeted, and a space is targeted", func() { + BeforeEach(func() { + cmd.RequiredArgs = flag.AddAccessRuleArgs{ + RuleName: "test-rule", + Domain: "apps.internal", + } + cmd.Hostname = "backend" + }) + + Describe("source resolution", func() { + Context("when --source-app is provided (current space)", func() { + BeforeEach(func() { + cmd.SourceApp = "frontend-app" + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "app-guid"}, + v7action.Warnings{"app-warning"}, + nil, + ) + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("resolves the app and creates the access rule", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + // Verify app lookup + Expect(fakeActor.GetApplicationByNameAndSpaceCallCount()).To(Equal(1)) + appName, spaceGUID := fakeActor.GetApplicationByNameAndSpaceArgsForCall(0) + Expect(appName).To(Equal("frontend-app")) + Expect(spaceGUID).To(Equal("space-guid")) + + // Verify access rule creation with resolved selector + Expect(fakeActor.AddAccessRuleCallCount()).To(Equal(1)) + ruleName, domain, selector, hostname, path := fakeActor.AddAccessRuleArgsForCall(0) + Expect(ruleName).To(Equal("test-rule")) + Expect(domain).To(Equal("apps.internal")) + Expect(selector).To(Equal("cf:app:app-guid")) + Expect(hostname).To(Equal("backend")) + Expect(path).To(BeEmpty()) + + // Verify output + Expect(testUI.Out).To(Say("Adding access rule test-rule")) + Expect(testUI.Out).To(Say("scope: app, source: frontend-app")) + Expect(testUI.Out).To(Say("selector: cf:app:app-guid")) + Expect(testUI.Out).To(Say("OK")) + }) + + It("displays warnings", func() { + Expect(testUI.Err).To(Say("app-warning")) + Expect(testUI.Err).To(Say("add-warning")) + }) + }) + + Context("when --source-app is provided with --source-space (cross-space)", func() { + BeforeEach(func() { + cmd.SourceApp = "frontend-app" + cmd.SourceSpace = "other-space" + + fakeActor.GetSpaceByNameAndOrganizationReturns( + resources.Space{GUID: "other-space-guid"}, + v7action.Warnings{"space-warning"}, + nil, + ) + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "app-guid"}, + v7action.Warnings{"app-warning"}, + nil, + ) + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("resolves space then app and creates the access rule", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + // Verify space lookup + Expect(fakeActor.GetSpaceByNameAndOrganizationCallCount()).To(Equal(1)) + spaceName, orgGUID := fakeActor.GetSpaceByNameAndOrganizationArgsForCall(0) + Expect(spaceName).To(Equal("other-space")) + Expect(orgGUID).To(Equal("org-guid")) + + // Verify app lookup in resolved space + Expect(fakeActor.GetApplicationByNameAndSpaceCallCount()).To(Equal(1)) + appName, spaceGUID := fakeActor.GetApplicationByNameAndSpaceArgsForCall(0) + Expect(appName).To(Equal("frontend-app")) + Expect(spaceGUID).To(Equal("other-space-guid")) + + // Verify selector + _, _, selector, _, _ := fakeActor.AddAccessRuleArgsForCall(0) + Expect(selector).To(Equal("cf:app:app-guid")) + + // Verify output shows cross-space info + Expect(testUI.Out).To(Say("scope: app, source: frontend-app \\(space: other-space\\)")) + }) + }) + + Context("when --source-app is provided with --source-space and --source-org (cross-org)", func() { + BeforeEach(func() { + cmd.SourceApp = "frontend-app" + cmd.SourceSpace = "other-space" + cmd.SourceOrg = "other-org" + + fakeActor.GetOrganizationByNameReturns( + resources.Organization{GUID: "other-org-guid"}, + v7action.Warnings{"org-warning"}, + nil, + ) + fakeActor.GetSpaceByNameAndOrganizationReturns( + resources.Space{GUID: "other-space-guid"}, + v7action.Warnings{"space-warning"}, + nil, + ) + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "app-guid"}, + v7action.Warnings{"app-warning"}, + nil, + ) + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("resolves org, space, then app and creates the access rule", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + // Verify org lookup + Expect(fakeActor.GetOrganizationByNameCallCount()).To(Equal(1)) + orgName := fakeActor.GetOrganizationByNameArgsForCall(0) + Expect(orgName).To(Equal("other-org")) + + // Verify space lookup with resolved org + spaceName, orgGUID := fakeActor.GetSpaceByNameAndOrganizationArgsForCall(0) + Expect(spaceName).To(Equal("other-space")) + Expect(orgGUID).To(Equal("other-org-guid")) + + // Verify output shows cross-org info + Expect(testUI.Out).To(Say("scope: app, source: frontend-app \\(space: other-space, org: other-org\\)")) + }) + }) + + Context("when --source-app is not found in current space", func() { + BeforeEach(func() { + cmd.SourceApp = "missing-app" + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{}, + v7action.Warnings{"app-warning"}, + actionerror.ApplicationNotFoundError{Name: "missing-app"}, + ) + }) + + It("returns a helpful error message", func() { + Expect(executeErr).To(HaveOccurred()) + Expect(executeErr.Error()).To(ContainSubstring("App 'missing-app' not found in space 'space-name' / org 'org-name'")) + Expect(executeErr.Error()).To(ContainSubstring("TIP: If the app is in a different space or org, use --source-space and/or --source-org flags")) + }) + }) + + Context("when --source-space is provided (without --source-app)", func() { + BeforeEach(func() { + cmd.SourceSpace = "monitoring-space" + fakeActor.GetSpaceByNameAndOrganizationReturns( + resources.Space{GUID: "space-guid-123"}, + v7action.Warnings{"space-warning"}, + nil, + ) + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("creates a space-level access rule", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + // Verify selector is space-level + _, _, selector, _, _ := fakeActor.AddAccessRuleArgsForCall(0) + Expect(selector).To(Equal("cf:space:space-guid-123")) + + Expect(testUI.Out).To(Say("scope: space, source: monitoring-space")) + }) + }) + + Context("when --source-space is provided with --source-org (cross-org space rule)", func() { + BeforeEach(func() { + cmd.SourceSpace = "prod-space" + cmd.SourceOrg = "prod-org" + + fakeActor.GetOrganizationByNameReturns( + resources.Organization{GUID: "prod-org-guid"}, + v7action.Warnings{"org-warning"}, + nil, + ) + fakeActor.GetSpaceByNameAndOrganizationReturns( + resources.Space{GUID: "prod-space-guid"}, + v7action.Warnings{"space-warning"}, + nil, + ) + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("creates a space-level access rule for the specified org", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + // Verify org lookup + Expect(fakeActor.GetOrganizationByNameCallCount()).To(Equal(1)) + orgName := fakeActor.GetOrganizationByNameArgsForCall(0) + Expect(orgName).To(Equal("prod-org")) + + // Verify space lookup with resolved org + spaceName, orgGUID := fakeActor.GetSpaceByNameAndOrganizationArgsForCall(0) + Expect(spaceName).To(Equal("prod-space")) + Expect(orgGUID).To(Equal("prod-org-guid")) + + // Verify selector is space-level + _, _, selector, _, _ := fakeActor.AddAccessRuleArgsForCall(0) + Expect(selector).To(Equal("cf:space:prod-space-guid")) + + Expect(testUI.Out).To(Say("scope: space, source: prod-space \\(org: prod-org\\)")) + }) + }) + + Context("when --source-org is provided (without --source-space or --source-app)", func() { + BeforeEach(func() { + cmd.SourceOrg = "platform-org" + fakeActor.GetOrganizationByNameReturns( + resources.Organization{GUID: "org-guid-456"}, + v7action.Warnings{"org-warning"}, + nil, + ) + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("creates an org-level access rule", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + // Verify selector is org-level + _, _, selector, _, _ := fakeActor.AddAccessRuleArgsForCall(0) + Expect(selector).To(Equal("cf:org:org-guid-456")) + + Expect(testUI.Out).To(Say("scope: org, source: platform-org")) + }) + }) + + Context("when --source-any is provided", func() { + BeforeEach(func() { + cmd.SourceAny = true + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("creates an 'any' access rule", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + _, _, selector, _, _ := fakeActor.AddAccessRuleArgsForCall(0) + Expect(selector).To(Equal("cf:any")) + + Expect(testUI.Out).To(Say("scope: any, source: any authenticated app")) + }) + }) + + Context("when --selector is provided (raw selector)", func() { + BeforeEach(func() { + cmd.Selector = "cf:app:raw-guid-123" + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("uses the raw selector without resolution", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + _, _, selector, _, _ := fakeActor.AddAccessRuleArgsForCall(0) + Expect(selector).To(Equal("cf:app:raw-guid-123")) + + Expect(testUI.Out).To(Say("selector: cf:app:raw-guid-123")) + + // Should not call any resolution methods + Expect(fakeActor.GetApplicationByNameAndSpaceCallCount()).To(Equal(0)) + Expect(fakeActor.GetSpaceByNameAndOrganizationCallCount()).To(Equal(0)) + Expect(fakeActor.GetOrganizationByNameCallCount()).To(Equal(0)) + }) + }) + + Context("when --path is provided", func() { + BeforeEach(func() { + cmd.SourceAny = true + cmd.Path = "/metrics" + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, nil) + }) + + It("passes the path to the actor", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + _, _, _, _, path := fakeActor.AddAccessRuleArgsForCall(0) + Expect(path).To(Equal("/metrics")) + }) + }) + }) + + Describe("error handling", func() { + Context("when AddAccessRule fails", func() { + BeforeEach(func() { + cmd.SourceAny = true + fakeActor.AddAccessRuleReturns(v7action.Warnings{"add-warning"}, actionerror.RouteNotFoundError{}) + }) + + It("returns the error", func() { + Expect(executeErr).To(MatchError(actionerror.RouteNotFoundError{})) + Expect(testUI.Err).To(Say("add-warning")) + }) + }) + + Context("when space lookup fails", func() { + BeforeEach(func() { + cmd.SourceSpace = "nonexistent-space" + fakeActor.GetSpaceByNameAndOrganizationReturns( + resources.Space{}, + v7action.Warnings{"space-warning"}, + actionerror.SpaceNotFoundError{Name: "nonexistent-space"}, + ) + }) + + It("returns the error", func() { + Expect(executeErr).To(MatchError(actionerror.SpaceNotFoundError{Name: "nonexistent-space"})) + Expect(testUI.Err).To(Say("space-warning")) + }) + }) + + Context("when org lookup fails", func() { + BeforeEach(func() { + cmd.SourceOrg = "nonexistent-org" + fakeActor.GetOrganizationByNameReturns( + resources.Organization{}, + v7action.Warnings{"org-warning"}, + actionerror.OrganizationNotFoundError{Name: "nonexistent-org"}, + ) + }) + + It("returns the error", func() { + Expect(executeErr).To(MatchError(actionerror.OrganizationNotFoundError{Name: "nonexistent-org"})) + Expect(testUI.Err).To(Say("org-warning")) + }) + }) + }) + }) +}) diff --git a/command/v7/create_private_domain_command.go b/command/v7/create_private_domain_command.go index 2701ca91c47..1a95ae97250 100644 --- a/command/v7/create_private_domain_command.go +++ b/command/v7/create_private_domain_command.go @@ -10,9 +10,11 @@ import ( type CreatePrivateDomainCommand struct { BaseCommand - RequiredArgs flag.OrgDomain `positional-args:"yes"` - usage interface{} `usage:"CF_NAME create-private-domain ORG DOMAIN"` - relatedCommands interface{} `related_commands:"create-shared-domain, domains, share-private-domain"` + RequiredArgs flag.OrgDomain `positional-args:"yes"` + EnforceAccessRules bool `long:"enforce-access-rules" description:"Enable platform-enforced access control for routes on this domain (requires mTLS domain configuration in GoRouter)"` + Scope string `long:"scope" description:"Operator-level scope boundary for access rules: 'any', 'org', or 'space' (only valid with --enforce-access-rules)"` + usage interface{} `usage:"CF_NAME create-private-domain ORG DOMAIN [--enforce-access-rules [--scope (any|org|space)]]"` + relatedCommands interface{} `related_commands:"create-shared-domain, domains, share-private-domain, add-access-rule, access-rules"` } func (cmd CreatePrivateDomainCommand) Execute(args []string) error { @@ -29,6 +31,16 @@ func (cmd CreatePrivateDomainCommand) Execute(args []string) error { domain := cmd.RequiredArgs.Domain orgName := cmd.RequiredArgs.Organization + // Validate that --scope is only used with --enforce-access-rules + if cmd.Scope != "" && !cmd.EnforceAccessRules { + return fmt.Errorf("--scope can only be used with --enforce-access-rules") + } + + // Validate scope values + if cmd.Scope != "" && cmd.Scope != "any" && cmd.Scope != "org" && cmd.Scope != "space" { + return fmt.Errorf("--scope must be one of: any, org, space") + } + cmd.UI.DisplayTextWithFlavor("Creating private domain {{.Domain}} for org {{.Organization}} as {{.User}}...", map[string]interface{}{ "Domain": domain, @@ -36,7 +48,7 @@ func (cmd CreatePrivateDomainCommand) Execute(args []string) error { "Organization": orgName, }) - warnings, err := cmd.Actor.CreatePrivateDomain(domain, orgName) + warnings, err := cmd.Actor.CreatePrivateDomain(domain, orgName, cmd.EnforceAccessRules, cmd.Scope) cmd.UI.DisplayWarnings(warnings) if err != nil { @@ -53,9 +65,16 @@ func (cmd CreatePrivateDomainCommand) Execute(args []string) error { cmd.UI.DisplayOK() - cmd.UI.DisplayText("TIP: Domain '{{.Domain}}' is a private domain. Run 'cf share-private-domain' to share this domain with a different org.", - map[string]interface{}{ - "Domain": domain, - }) + if cmd.EnforceAccessRules { + cmd.UI.DisplayText("TIP: Domain '{{.Domain}}' is a private identity-aware domain with access rule enforcement enabled. Routes on this domain require access rules to allow traffic.", + map[string]interface{}{ + "Domain": domain, + }) + } else { + cmd.UI.DisplayText("TIP: Domain '{{.Domain}}' is a private domain. Run 'cf share-private-domain' to share this domain with a different org.", + map[string]interface{}{ + "Domain": domain, + }) + } return nil } diff --git a/command/v7/create_private_domain_command_test.go b/command/v7/create_private_domain_command_test.go index 67db5bf98cd..d30941f39d7 100644 --- a/command/v7/create_private_domain_command_test.go +++ b/command/v7/create_private_domain_command_test.go @@ -113,7 +113,7 @@ var _ = Describe("create-private-domain Command", func() { It("creates the domain", func() { Expect(fakeActor.CreatePrivateDomainCallCount()).To(Equal(1)) - expectedDomainName, expectedOrgName := fakeActor.CreatePrivateDomainArgsForCall(0) + expectedDomainName, expectedOrgName, _, _ := fakeActor.CreatePrivateDomainArgsForCall(0) Expect(expectedDomainName).To(Equal(domainName)) Expect(expectedOrgName).To(Equal(orgName)) }) diff --git a/command/v7/create_shared_domain_command.go b/command/v7/create_shared_domain_command.go index e48ec61dd6f..d154dac0312 100644 --- a/command/v7/create_shared_domain_command.go +++ b/command/v7/create_shared_domain_command.go @@ -1,17 +1,21 @@ package v7 import ( + "fmt" + "code.cloudfoundry.org/cli/v9/command/flag" ) type CreateSharedDomainCommand struct { BaseCommand - RequiredArgs flag.Domain `positional-args:"yes"` - RouterGroup string `long:"router-group" description:"Routes for this domain will use routers in the specified router group"` - Internal bool `long:"internal" description:"Applications that use internal routes communicate directly on the container network"` - usage interface{} `usage:"CF_NAME create-shared-domain DOMAIN [--router-group ROUTER_GROUP_NAME | --internal]"` - relatedCommands interface{} `related_commands:"create-private-domain, domains"` + RequiredArgs flag.Domain `positional-args:"yes"` + RouterGroup string `long:"router-group" description:"Routes for this domain will use routers in the specified router group"` + Internal bool `long:"internal" description:"Applications that use internal routes communicate directly on the container network"` + EnforceAccessRules bool `long:"enforce-access-rules" description:"Enable platform-enforced access control for routes on this domain (requires mTLS domain configuration in GoRouter)"` + Scope string `long:"scope" description:"Operator-level scope boundary for access rules: 'any', 'org', or 'space' (only valid with --enforce-access-rules)"` + usage interface{} `usage:"CF_NAME create-shared-domain DOMAIN [--router-group ROUTER_GROUP_NAME | --internal] [--enforce-access-rules [--scope (any|org|space)]]"` + relatedCommands interface{} `related_commands:"create-private-domain, domains, add-access-rule, access-rules"` } func (cmd CreateSharedDomainCommand) Execute(args []string) error { @@ -27,22 +31,40 @@ func (cmd CreateSharedDomainCommand) Execute(args []string) error { domain := cmd.RequiredArgs.Domain + // Validate that --scope is only used with --enforce-access-rules + if cmd.Scope != "" && !cmd.EnforceAccessRules { + return fmt.Errorf("--scope can only be used with --enforce-access-rules") + } + + // Validate scope values + if cmd.Scope != "" && cmd.Scope != "any" && cmd.Scope != "org" && cmd.Scope != "space" { + return fmt.Errorf("--scope must be one of: any, org, space") + } + cmd.UI.DisplayTextWithFlavor("Creating shared domain {{.Domain}} as {{.User}}...", map[string]interface{}{ "Domain": domain, "User": user.Name, }) - warnings, err := cmd.Actor.CreateSharedDomain(domain, cmd.Internal, cmd.RouterGroup) + warnings, err := cmd.Actor.CreateSharedDomain(domain, cmd.Internal, cmd.RouterGroup, cmd.EnforceAccessRules, cmd.Scope) cmd.UI.DisplayWarnings(warnings) if err != nil { return err } cmd.UI.DisplayOK() - cmd.UI.DisplayText("TIP: Domain '{{.Domain}}' is shared with all orgs. Run 'cf domains' to view available domains.", - map[string]interface{}{ - "Domain": domain, - }) + + if cmd.EnforceAccessRules { + cmd.UI.DisplayText("TIP: Domain '{{.Domain}}' is a shared identity-aware domain with access rule enforcement enabled. Routes on this domain require access rules to allow traffic.", + map[string]interface{}{ + "Domain": domain, + }) + } else { + cmd.UI.DisplayText("TIP: Domain '{{.Domain}}' is shared with all orgs. Run 'cf domains' to view available domains.", + map[string]interface{}{ + "Domain": domain, + }) + } return nil } diff --git a/command/v7/create_shared_domain_command_test.go b/command/v7/create_shared_domain_command_test.go index 9df31c8cbb3..6e757f92859 100644 --- a/command/v7/create_shared_domain_command_test.go +++ b/command/v7/create_shared_domain_command_test.go @@ -126,7 +126,7 @@ var _ = Describe("create-shared-domain Command", func() { It("creates the domain", func() { Expect(fakeActor.CreateSharedDomainCallCount()).To(Equal(1)) - expectedDomainName, expectedInternal, expectedRouterGroup := fakeActor.CreateSharedDomainArgsForCall(0) + expectedDomainName, expectedInternal, expectedRouterGroup, _, _ := fakeActor.CreateSharedDomainArgsForCall(0) Expect(expectedDomainName).To(Equal(domainName)) Expect(expectedInternal).To(BeTrue()) Expect(expectedRouterGroup).To(Equal("router-group")) diff --git a/command/v7/remove_access_rule_command.go b/command/v7/remove_access_rule_command.go new file mode 100644 index 00000000000..12d0b08eb4b --- /dev/null +++ b/command/v7/remove_access_rule_command.go @@ -0,0 +1,71 @@ +package v7 + +import ( + "code.cloudfoundry.org/cli/v9/command/flag" +) + +type RemoveAccessRuleCommand struct { + BaseCommand + + RequiredArgs flag.RemoveAccessRuleArgs `positional-args:"yes"` + Hostname string `long:"hostname" required:"true" description:"Hostname for the route"` + Path string `long:"path" description:"Path for the route"` + Force bool `short:"f" description:"Force deletion without confirmation"` + usage interface{} `usage:"CF_NAME remove-access-rule RULE_NAME DOMAIN --hostname HOSTNAME [--path PATH] [-f]\n\nEXAMPLES:\n cf remove-access-rule allow-frontend apps.identity --hostname backend\n cf remove-access-rule allow-monitoring apps.identity --hostname api --path /metrics -f"` + relatedCommands interface{} `related_commands:"access-rules, add-access-rule"` +} + +func (cmd RemoveAccessRuleCommand) Execute(args []string) error { + err := cmd.SharedActor.CheckTarget(true, true) + if err != nil { + return err + } + + user, err := cmd.Actor.GetCurrentUser() + if err != nil { + return err + } + + ruleName := cmd.RequiredArgs.RuleName + domainName := cmd.RequiredArgs.Domain + + if !cmd.Force { + prompt := "Really remove access rule {{.RuleName}} for route {{.Hostname}}.{{.Domain}}{{.Path}}?" + response, promptErr := cmd.UI.DisplayBoolPrompt(false, prompt, map[string]interface{}{ + "RuleName": ruleName, + "Hostname": cmd.Hostname, + "Domain": domainName, + "Path": formatPath(cmd.Path), + }) + + if promptErr != nil { + return promptErr + } + + if !response { + cmd.UI.DisplayText("Access rule '{{.RuleName}}' has not been removed.", map[string]interface{}{ + "RuleName": ruleName, + }) + return nil + } + } + + cmd.UI.DisplayTextWithFlavor("Removing access rule {{.RuleName}} for route {{.Hostname}}.{{.Domain}}{{.Path}} as {{.User}}...", + map[string]interface{}{ + "RuleName": ruleName, + "Hostname": cmd.Hostname, + "Domain": domainName, + "Path": formatPath(cmd.Path), + "User": user.Name, + }) + + warnings, err := cmd.Actor.DeleteAccessRule(ruleName, domainName, cmd.Hostname, cmd.Path) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + cmd.UI.DisplayOK() + + return nil +} diff --git a/command/v7/v7fakes/fake_actor.go b/command/v7/v7fakes/fake_actor.go index e8f1915a932..b3093ad655e 100644 --- a/command/v7/v7fakes/fake_actor.go +++ b/command/v7/v7fakes/fake_actor.go @@ -22,6 +22,23 @@ import ( ) type FakeActor struct { + AddAccessRuleStub func(string, string, string, string, string) (v7action.Warnings, error) + addAccessRuleMutex sync.RWMutex + addAccessRuleArgsForCall []struct { + arg1 string + arg2 string + arg3 string + arg4 string + arg5 string + } + addAccessRuleReturns struct { + result1 v7action.Warnings + result2 error + } + addAccessRuleReturnsOnCall map[int]struct { + result1 v7action.Warnings + result2 error + } ApplyOrganizationQuotaByNameStub func(string, string) (v7action.Warnings, error) applyOrganizationQuotaByNameMutex sync.RWMutex applyOrganizationQuotaByNameArgsForCall []struct { @@ -357,11 +374,13 @@ type FakeActor struct { result1 v7action.Warnings result2 error } - CreatePrivateDomainStub func(string, string) (v7action.Warnings, error) + CreatePrivateDomainStub func(string, string, bool, string) (v7action.Warnings, error) createPrivateDomainMutex sync.RWMutex createPrivateDomainArgsForCall []struct { arg1 string arg2 string + arg3 bool + arg4 string } createPrivateDomainReturns struct { result1 v7action.Warnings @@ -463,12 +482,14 @@ type FakeActor struct { result2 v7action.Warnings result3 error } - CreateSharedDomainStub func(string, bool, string) (v7action.Warnings, error) + CreateSharedDomainStub func(string, bool, string, bool, string) (v7action.Warnings, error) createSharedDomainMutex sync.RWMutex createSharedDomainArgsForCall []struct { arg1 string arg2 bool arg3 string + arg4 bool + arg5 string } createSharedDomainReturns struct { result1 v7action.Warnings @@ -557,6 +578,22 @@ type FakeActor struct { result1 v7action.Warnings result2 error } + DeleteAccessRuleStub func(string, string, string, string) (v7action.Warnings, error) + deleteAccessRuleMutex sync.RWMutex + deleteAccessRuleArgsForCall []struct { + arg1 string + arg2 string + arg3 string + arg4 string + } + deleteAccessRuleReturns struct { + result1 v7action.Warnings + result2 error + } + deleteAccessRuleReturnsOnCall map[int]struct { + result1 v7action.Warnings + result2 error + } DeleteApplicationByNameAndSpaceStub func(string, string, bool) (v7action.Warnings, error) deleteApplicationByNameAndSpaceMutex sync.RWMutex deleteApplicationByNameAndSpaceArgsForCall []struct { @@ -989,6 +1026,42 @@ type FakeActor struct { result1 v7action.Warnings result2 error } + GetAccessRulesByRouteStub func(string, string, string) ([]resources.AccessRule, v7action.Warnings, error) + getAccessRulesByRouteMutex sync.RWMutex + getAccessRulesByRouteArgsForCall []struct { + arg1 string + arg2 string + arg3 string + } + getAccessRulesByRouteReturns struct { + result1 []resources.AccessRule + result2 v7action.Warnings + result3 error + } + getAccessRulesByRouteReturnsOnCall map[int]struct { + result1 []resources.AccessRule + result2 v7action.Warnings + result3 error + } + GetAccessRulesForSpaceStub func(string, string, string, string, string) ([]v7action.AccessRuleWithRoute, v7action.Warnings, error) + getAccessRulesForSpaceMutex sync.RWMutex + getAccessRulesForSpaceArgsForCall []struct { + arg1 string + arg2 string + arg3 string + arg4 string + arg5 string + } + getAccessRulesForSpaceReturns struct { + result1 []v7action.AccessRuleWithRoute + result2 v7action.Warnings + result3 error + } + getAccessRulesForSpaceReturnsOnCall map[int]struct { + result1 []v7action.AccessRuleWithRoute + result2 v7action.Warnings + result3 error + } GetAppFeatureStub func(string, string) (resources.ApplicationFeature, v7action.Warnings, error) getAppFeatureMutex sync.RWMutex getAppFeatureArgsForCall []struct { @@ -3780,6 +3853,74 @@ type FakeActor struct { invocationsMutex sync.RWMutex } +func (fake *FakeActor) AddAccessRule(arg1 string, arg2 string, arg3 string, arg4 string, arg5 string) (v7action.Warnings, error) { + fake.addAccessRuleMutex.Lock() + ret, specificReturn := fake.addAccessRuleReturnsOnCall[len(fake.addAccessRuleArgsForCall)] + fake.addAccessRuleArgsForCall = append(fake.addAccessRuleArgsForCall, struct { + arg1 string + arg2 string + arg3 string + arg4 string + arg5 string + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.AddAccessRuleStub + fakeReturns := fake.addAccessRuleReturns + fake.recordInvocation("AddAccessRule", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.addAccessRuleMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeActor) AddAccessRuleCallCount() int { + fake.addAccessRuleMutex.RLock() + defer fake.addAccessRuleMutex.RUnlock() + return len(fake.addAccessRuleArgsForCall) +} + +func (fake *FakeActor) AddAccessRuleCalls(stub func(string, string, string, string, string) (v7action.Warnings, error)) { + fake.addAccessRuleMutex.Lock() + defer fake.addAccessRuleMutex.Unlock() + fake.AddAccessRuleStub = stub +} + +func (fake *FakeActor) AddAccessRuleArgsForCall(i int) (string, string, string, string, string) { + fake.addAccessRuleMutex.RLock() + defer fake.addAccessRuleMutex.RUnlock() + argsForCall := fake.addAccessRuleArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *FakeActor) AddAccessRuleReturns(result1 v7action.Warnings, result2 error) { + fake.addAccessRuleMutex.Lock() + defer fake.addAccessRuleMutex.Unlock() + fake.AddAccessRuleStub = nil + fake.addAccessRuleReturns = struct { + result1 v7action.Warnings + result2 error + }{result1, result2} +} + +func (fake *FakeActor) AddAccessRuleReturnsOnCall(i int, result1 v7action.Warnings, result2 error) { + fake.addAccessRuleMutex.Lock() + defer fake.addAccessRuleMutex.Unlock() + fake.AddAccessRuleStub = nil + if fake.addAccessRuleReturnsOnCall == nil { + fake.addAccessRuleReturnsOnCall = make(map[int]struct { + result1 v7action.Warnings + result2 error + }) + } + fake.addAccessRuleReturnsOnCall[i] = struct { + result1 v7action.Warnings + result2 error + }{result1, result2} +} + func (fake *FakeActor) ApplyOrganizationQuotaByName(arg1 string, arg2 string) (v7action.Warnings, error) { fake.applyOrganizationQuotaByNameMutex.Lock() ret, specificReturn := fake.applyOrganizationQuotaByNameReturnsOnCall[len(fake.applyOrganizationQuotaByNameArgsForCall)] @@ -5273,19 +5414,21 @@ func (fake *FakeActor) CreateOrganizationQuotaReturnsOnCall(i int, result1 v7act }{result1, result2} } -func (fake *FakeActor) CreatePrivateDomain(arg1 string, arg2 string) (v7action.Warnings, error) { +func (fake *FakeActor) CreatePrivateDomain(arg1 string, arg2 string, arg3 bool, arg4 string) (v7action.Warnings, error) { fake.createPrivateDomainMutex.Lock() ret, specificReturn := fake.createPrivateDomainReturnsOnCall[len(fake.createPrivateDomainArgsForCall)] fake.createPrivateDomainArgsForCall = append(fake.createPrivateDomainArgsForCall, struct { arg1 string arg2 string - }{arg1, arg2}) + arg3 bool + arg4 string + }{arg1, arg2, arg3, arg4}) stub := fake.CreatePrivateDomainStub fakeReturns := fake.createPrivateDomainReturns - fake.recordInvocation("CreatePrivateDomain", []interface{}{arg1, arg2}) + fake.recordInvocation("CreatePrivateDomain", []interface{}{arg1, arg2, arg3, arg4}) fake.createPrivateDomainMutex.Unlock() if stub != nil { - return stub(arg1, arg2) + return stub(arg1, arg2, arg3, arg4) } if specificReturn { return ret.result1, ret.result2 @@ -5299,17 +5442,17 @@ func (fake *FakeActor) CreatePrivateDomainCallCount() int { return len(fake.createPrivateDomainArgsForCall) } -func (fake *FakeActor) CreatePrivateDomainCalls(stub func(string, string) (v7action.Warnings, error)) { +func (fake *FakeActor) CreatePrivateDomainCalls(stub func(string, string, bool, string) (v7action.Warnings, error)) { fake.createPrivateDomainMutex.Lock() defer fake.createPrivateDomainMutex.Unlock() fake.CreatePrivateDomainStub = stub } -func (fake *FakeActor) CreatePrivateDomainArgsForCall(i int) (string, string) { +func (fake *FakeActor) CreatePrivateDomainArgsForCall(i int) (string, string, bool, string) { fake.createPrivateDomainMutex.RLock() defer fake.createPrivateDomainMutex.RUnlock() argsForCall := fake.createPrivateDomainArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4 } func (fake *FakeActor) CreatePrivateDomainReturns(result1 v7action.Warnings, result2 error) { @@ -5740,20 +5883,22 @@ func (fake *FakeActor) CreateServiceKeyReturnsOnCall(i int, result1 chan v7actio }{result1, result2, result3} } -func (fake *FakeActor) CreateSharedDomain(arg1 string, arg2 bool, arg3 string) (v7action.Warnings, error) { +func (fake *FakeActor) CreateSharedDomain(arg1 string, arg2 bool, arg3 string, arg4 bool, arg5 string) (v7action.Warnings, error) { fake.createSharedDomainMutex.Lock() ret, specificReturn := fake.createSharedDomainReturnsOnCall[len(fake.createSharedDomainArgsForCall)] fake.createSharedDomainArgsForCall = append(fake.createSharedDomainArgsForCall, struct { arg1 string arg2 bool arg3 string - }{arg1, arg2, arg3}) + arg4 bool + arg5 string + }{arg1, arg2, arg3, arg4, arg5}) stub := fake.CreateSharedDomainStub fakeReturns := fake.createSharedDomainReturns - fake.recordInvocation("CreateSharedDomain", []interface{}{arg1, arg2, arg3}) + fake.recordInvocation("CreateSharedDomain", []interface{}{arg1, arg2, arg3, arg4, arg5}) fake.createSharedDomainMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3) + return stub(arg1, arg2, arg3, arg4, arg5) } if specificReturn { return ret.result1, ret.result2 @@ -5767,17 +5912,17 @@ func (fake *FakeActor) CreateSharedDomainCallCount() int { return len(fake.createSharedDomainArgsForCall) } -func (fake *FakeActor) CreateSharedDomainCalls(stub func(string, bool, string) (v7action.Warnings, error)) { +func (fake *FakeActor) CreateSharedDomainCalls(stub func(string, bool, string, bool, string) (v7action.Warnings, error)) { fake.createSharedDomainMutex.Lock() defer fake.createSharedDomainMutex.Unlock() fake.CreateSharedDomainStub = stub } -func (fake *FakeActor) CreateSharedDomainArgsForCall(i int) (string, bool, string) { +func (fake *FakeActor) CreateSharedDomainArgsForCall(i int) (string, bool, string, bool, string) { fake.createSharedDomainMutex.RLock() defer fake.createSharedDomainMutex.RUnlock() argsForCall := fake.createSharedDomainArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 } func (fake *FakeActor) CreateSharedDomainReturns(result1 v7action.Warnings, result2 error) { @@ -6142,6 +6287,73 @@ func (fake *FakeActor) CreateUserProvidedServiceInstanceReturnsOnCall(i int, res }{result1, result2} } +func (fake *FakeActor) DeleteAccessRule(arg1 string, arg2 string, arg3 string, arg4 string) (v7action.Warnings, error) { + fake.deleteAccessRuleMutex.Lock() + ret, specificReturn := fake.deleteAccessRuleReturnsOnCall[len(fake.deleteAccessRuleArgsForCall)] + fake.deleteAccessRuleArgsForCall = append(fake.deleteAccessRuleArgsForCall, struct { + arg1 string + arg2 string + arg3 string + arg4 string + }{arg1, arg2, arg3, arg4}) + stub := fake.DeleteAccessRuleStub + fakeReturns := fake.deleteAccessRuleReturns + fake.recordInvocation("DeleteAccessRule", []interface{}{arg1, arg2, arg3, arg4}) + fake.deleteAccessRuleMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeActor) DeleteAccessRuleCallCount() int { + fake.deleteAccessRuleMutex.RLock() + defer fake.deleteAccessRuleMutex.RUnlock() + return len(fake.deleteAccessRuleArgsForCall) +} + +func (fake *FakeActor) DeleteAccessRuleCalls(stub func(string, string, string, string) (v7action.Warnings, error)) { + fake.deleteAccessRuleMutex.Lock() + defer fake.deleteAccessRuleMutex.Unlock() + fake.DeleteAccessRuleStub = stub +} + +func (fake *FakeActor) DeleteAccessRuleArgsForCall(i int) (string, string, string, string) { + fake.deleteAccessRuleMutex.RLock() + defer fake.deleteAccessRuleMutex.RUnlock() + argsForCall := fake.deleteAccessRuleArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4 +} + +func (fake *FakeActor) DeleteAccessRuleReturns(result1 v7action.Warnings, result2 error) { + fake.deleteAccessRuleMutex.Lock() + defer fake.deleteAccessRuleMutex.Unlock() + fake.DeleteAccessRuleStub = nil + fake.deleteAccessRuleReturns = struct { + result1 v7action.Warnings + result2 error + }{result1, result2} +} + +func (fake *FakeActor) DeleteAccessRuleReturnsOnCall(i int, result1 v7action.Warnings, result2 error) { + fake.deleteAccessRuleMutex.Lock() + defer fake.deleteAccessRuleMutex.Unlock() + fake.DeleteAccessRuleStub = nil + if fake.deleteAccessRuleReturnsOnCall == nil { + fake.deleteAccessRuleReturnsOnCall = make(map[int]struct { + result1 v7action.Warnings + result2 error + }) + } + fake.deleteAccessRuleReturnsOnCall[i] = struct { + result1 v7action.Warnings + result2 error + }{result1, result2} +} + func (fake *FakeActor) DeleteApplicationByNameAndSpace(arg1 string, arg2 string, arg3 bool) (v7action.Warnings, error) { fake.deleteApplicationByNameAndSpaceMutex.Lock() ret, specificReturn := fake.deleteApplicationByNameAndSpaceReturnsOnCall[len(fake.deleteApplicationByNameAndSpaceArgsForCall)] @@ -8068,6 +8280,146 @@ func (fake *FakeActor) EntitleIsolationSegmentToOrganizationByNameReturnsOnCall( }{result1, result2} } +func (fake *FakeActor) GetAccessRulesByRoute(arg1 string, arg2 string, arg3 string) ([]resources.AccessRule, v7action.Warnings, error) { + fake.getAccessRulesByRouteMutex.Lock() + ret, specificReturn := fake.getAccessRulesByRouteReturnsOnCall[len(fake.getAccessRulesByRouteArgsForCall)] + fake.getAccessRulesByRouteArgsForCall = append(fake.getAccessRulesByRouteArgsForCall, struct { + arg1 string + arg2 string + arg3 string + }{arg1, arg2, arg3}) + stub := fake.GetAccessRulesByRouteStub + fakeReturns := fake.getAccessRulesByRouteReturns + fake.recordInvocation("GetAccessRulesByRoute", []interface{}{arg1, arg2, arg3}) + fake.getAccessRulesByRouteMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeActor) GetAccessRulesByRouteCallCount() int { + fake.getAccessRulesByRouteMutex.RLock() + defer fake.getAccessRulesByRouteMutex.RUnlock() + return len(fake.getAccessRulesByRouteArgsForCall) +} + +func (fake *FakeActor) GetAccessRulesByRouteCalls(stub func(string, string, string) ([]resources.AccessRule, v7action.Warnings, error)) { + fake.getAccessRulesByRouteMutex.Lock() + defer fake.getAccessRulesByRouteMutex.Unlock() + fake.GetAccessRulesByRouteStub = stub +} + +func (fake *FakeActor) GetAccessRulesByRouteArgsForCall(i int) (string, string, string) { + fake.getAccessRulesByRouteMutex.RLock() + defer fake.getAccessRulesByRouteMutex.RUnlock() + argsForCall := fake.getAccessRulesByRouteArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeActor) GetAccessRulesByRouteReturns(result1 []resources.AccessRule, result2 v7action.Warnings, result3 error) { + fake.getAccessRulesByRouteMutex.Lock() + defer fake.getAccessRulesByRouteMutex.Unlock() + fake.GetAccessRulesByRouteStub = nil + fake.getAccessRulesByRouteReturns = struct { + result1 []resources.AccessRule + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeActor) GetAccessRulesByRouteReturnsOnCall(i int, result1 []resources.AccessRule, result2 v7action.Warnings, result3 error) { + fake.getAccessRulesByRouteMutex.Lock() + defer fake.getAccessRulesByRouteMutex.Unlock() + fake.GetAccessRulesByRouteStub = nil + if fake.getAccessRulesByRouteReturnsOnCall == nil { + fake.getAccessRulesByRouteReturnsOnCall = make(map[int]struct { + result1 []resources.AccessRule + result2 v7action.Warnings + result3 error + }) + } + fake.getAccessRulesByRouteReturnsOnCall[i] = struct { + result1 []resources.AccessRule + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeActor) GetAccessRulesForSpace(arg1 string, arg2 string, arg3 string, arg4 string, arg5 string) ([]v7action.AccessRuleWithRoute, v7action.Warnings, error) { + fake.getAccessRulesForSpaceMutex.Lock() + ret, specificReturn := fake.getAccessRulesForSpaceReturnsOnCall[len(fake.getAccessRulesForSpaceArgsForCall)] + fake.getAccessRulesForSpaceArgsForCall = append(fake.getAccessRulesForSpaceArgsForCall, struct { + arg1 string + arg2 string + arg3 string + arg4 string + arg5 string + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.GetAccessRulesForSpaceStub + fakeReturns := fake.getAccessRulesForSpaceReturns + fake.recordInvocation("GetAccessRulesForSpace", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.getAccessRulesForSpaceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeActor) GetAccessRulesForSpaceCallCount() int { + fake.getAccessRulesForSpaceMutex.RLock() + defer fake.getAccessRulesForSpaceMutex.RUnlock() + return len(fake.getAccessRulesForSpaceArgsForCall) +} + +func (fake *FakeActor) GetAccessRulesForSpaceCalls(stub func(string, string, string, string, string) ([]v7action.AccessRuleWithRoute, v7action.Warnings, error)) { + fake.getAccessRulesForSpaceMutex.Lock() + defer fake.getAccessRulesForSpaceMutex.Unlock() + fake.GetAccessRulesForSpaceStub = stub +} + +func (fake *FakeActor) GetAccessRulesForSpaceArgsForCall(i int) (string, string, string, string, string) { + fake.getAccessRulesForSpaceMutex.RLock() + defer fake.getAccessRulesForSpaceMutex.RUnlock() + argsForCall := fake.getAccessRulesForSpaceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *FakeActor) GetAccessRulesForSpaceReturns(result1 []v7action.AccessRuleWithRoute, result2 v7action.Warnings, result3 error) { + fake.getAccessRulesForSpaceMutex.Lock() + defer fake.getAccessRulesForSpaceMutex.Unlock() + fake.GetAccessRulesForSpaceStub = nil + fake.getAccessRulesForSpaceReturns = struct { + result1 []v7action.AccessRuleWithRoute + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeActor) GetAccessRulesForSpaceReturnsOnCall(i int, result1 []v7action.AccessRuleWithRoute, result2 v7action.Warnings, result3 error) { + fake.getAccessRulesForSpaceMutex.Lock() + defer fake.getAccessRulesForSpaceMutex.Unlock() + fake.GetAccessRulesForSpaceStub = nil + if fake.getAccessRulesForSpaceReturnsOnCall == nil { + fake.getAccessRulesForSpaceReturnsOnCall = make(map[int]struct { + result1 []v7action.AccessRuleWithRoute + result2 v7action.Warnings + result3 error + }) + } + fake.getAccessRulesForSpaceReturnsOnCall[i] = struct { + result1 []v7action.AccessRuleWithRoute + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeActor) GetAppFeature(arg1 string, arg2 string) (resources.ApplicationFeature, v7action.Warnings, error) { fake.getAppFeatureMutex.Lock() ret, specificReturn := fake.getAppFeatureReturnsOnCall[len(fake.getAppFeatureArgsForCall)] diff --git a/resources/access_rule_resource.go b/resources/access_rule_resource.go new file mode 100644 index 00000000000..6ccf5b34ad9 --- /dev/null +++ b/resources/access_rule_resource.go @@ -0,0 +1,84 @@ +package resources + +import ( + "encoding/json" + "time" + + "code.cloudfoundry.org/cli/v9/api/cloudcontroller" +) + +type AccessRule struct { + GUID string `json:"guid,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Name string `json:"name"` + Selector string `json:"selector"` + RouteGUID string `json:"-"` + + // Metadata is used for custom tagging of API resources + Metadata *Metadata `json:"metadata,omitempty"` +} + +func (a AccessRule) MarshalJSON() ([]byte, error) { + type alias struct { + GUID string `json:"guid,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Name string `json:"name"` + Selector string `json:"selector"` + Metadata *Metadata `json:"metadata,omitempty"` + + Relationships struct { + Route struct { + Data struct { + GUID string `json:"guid"` + } `json:"data"` + } `json:"route"` + } `json:"relationships"` + } + + var aliasData alias + aliasData.GUID = a.GUID + aliasData.CreatedAt = a.CreatedAt + aliasData.UpdatedAt = a.UpdatedAt + aliasData.Name = a.Name + aliasData.Selector = a.Selector + aliasData.Metadata = a.Metadata + aliasData.Relationships.Route.Data.GUID = a.RouteGUID + + return json.Marshal(aliasData) +} + +func (a *AccessRule) UnmarshalJSON(data []byte) error { + var alias struct { + GUID string `json:"guid,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Name string `json:"name"` + Selector string `json:"selector"` + Metadata *Metadata `json:"metadata,omitempty"` + + Relationships struct { + Route struct { + Data struct { + GUID string `json:"guid,omitempty"` + } `json:"data,omitempty"` + } `json:"route,omitempty"` + } `json:"relationships,omitempty"` + } + + err := cloudcontroller.DecodeJSON(data, &alias) + if err != nil { + return err + } + + a.GUID = alias.GUID + a.CreatedAt = alias.CreatedAt + a.UpdatedAt = alias.UpdatedAt + a.Name = alias.Name + a.Selector = alias.Selector + a.RouteGUID = alias.Relationships.Route.Data.GUID + a.Metadata = alias.Metadata + + return nil +} diff --git a/resources/access_rule_resource_test.go b/resources/access_rule_resource_test.go new file mode 100644 index 00000000000..52e3a9b5110 --- /dev/null +++ b/resources/access_rule_resource_test.go @@ -0,0 +1,69 @@ +package resources_test + +import ( + "encoding/json" + "testing" + + "code.cloudfoundry.org/cli/v9/resources" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAccessRuleResource(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AccessRule Resource Suite") +} + +var _ = Describe("AccessRule", func() { + Describe("MarshalJSON", func() { + It("marshals the access rule with relationships", func() { + rule := resources.AccessRule{ + Name: "allow-backend", + Selector: "cf:app:some-app-guid", + RouteGUID: "some-route-guid", + } + + data, err := json.Marshal(rule) + Expect(err).NotTo(HaveOccurred()) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + Expect(err).NotTo(HaveOccurred()) + + Expect(result["name"]).To(Equal("allow-backend")) + Expect(result["selector"]).To(Equal("cf:app:some-app-guid")) + Expect(result["relationships"]).NotTo(BeNil()) + + relationships := result["relationships"].(map[string]interface{}) + route := relationships["route"].(map[string]interface{}) + routeData := route["data"].(map[string]interface{}) + Expect(routeData["guid"]).To(Equal("some-route-guid")) + }) + }) + + Describe("UnmarshalJSON", func() { + It("unmarshals the access rule from relationships", func() { + jsonData := `{ + "guid": "some-guid", + "name": "test-rule", + "selector": "cf:app:app-guid", + "relationships": { + "route": { + "data": { + "guid": "route-guid-123" + } + } + } + }` + + var rule resources.AccessRule + err := json.Unmarshal([]byte(jsonData), &rule) + Expect(err).NotTo(HaveOccurred()) + + Expect(rule.GUID).To(Equal("some-guid")) + Expect(rule.Name).To(Equal("test-rule")) + Expect(rule.Selector).To(Equal("cf:app:app-guid")) + Expect(rule.RouteGUID).To(Equal("route-guid-123")) + }) + }) +}) diff --git a/resources/domain_resource.go b/resources/domain_resource.go index 1a032a78b01..44841048165 100644 --- a/resources/domain_resource.go +++ b/resources/domain_resource.go @@ -6,12 +6,14 @@ import ( ) type Domain struct { - GUID string `json:"guid,omitempty"` - Name string `json:"name"` - Internal types.NullBool `json:"internal,omitempty"` - OrganizationGUID string `jsonry:"relationships.organization.data.guid,omitempty"` - RouterGroup string `jsonry:"router_group.guid,omitempty"` - Protocols []string `jsonry:"supported_protocols,omitempty"` + GUID string `json:"guid,omitempty"` + Name string `json:"name"` + Internal types.NullBool `json:"internal,omitempty"` + OrganizationGUID string `jsonry:"relationships.organization.data.guid,omitempty"` + RouterGroup string `jsonry:"router_group.guid,omitempty"` + Protocols []string `jsonry:"supported_protocols,omitempty"` + EnforceAccessRules types.NullBool `json:"enforce_access_rules,omitempty"` + AccessRulesScope string `json:"access_rules_scope,omitempty"` // Metadata is used for custom tagging of API resources Metadata *Metadata `json:"metadata,omitempty"` @@ -19,12 +21,14 @@ type Domain struct { func (d Domain) MarshalJSON() ([]byte, error) { type domainWithBoolPointer struct { - GUID string `jsonry:"guid,omitempty"` - Name string `jsonry:"name"` - Internal *bool `jsonry:"internal,omitempty"` - OrganizationGUID string `jsonry:"relationships.organization.data.guid,omitempty"` - RouterGroup string `jsonry:"router_group.guid,omitempty"` - Protocols []string `jsonry:"supported_protocols,omitempty"` + GUID string `jsonry:"guid,omitempty"` + Name string `jsonry:"name"` + Internal *bool `jsonry:"internal,omitempty"` + OrganizationGUID string `jsonry:"relationships.organization.data.guid,omitempty"` + RouterGroup string `jsonry:"router_group.guid,omitempty"` + Protocols []string `jsonry:"supported_protocols,omitempty"` + EnforceAccessRules *bool `jsonry:"enforce_access_rules,omitempty"` + AccessRulesScope string `jsonry:"access_rules_scope,omitempty"` } clone := domainWithBoolPointer{ @@ -33,11 +37,15 @@ func (d Domain) MarshalJSON() ([]byte, error) { OrganizationGUID: d.OrganizationGUID, RouterGroup: d.RouterGroup, Protocols: d.Protocols, + AccessRulesScope: d.AccessRulesScope, } if d.Internal.IsSet { clone.Internal = &d.Internal.Value } + if d.EnforceAccessRules.IsSet { + clone.EnforceAccessRules = &d.EnforceAccessRules.Value + } return jsonry.Marshal(clone) }