diff --git a/cmd/generate/permissions.go b/cmd/generate/permissions.go index d064cf5d..b3b2bb88 100644 --- a/cmd/generate/permissions.go +++ b/cmd/generate/permissions.go @@ -133,7 +133,11 @@ func permissionDestinations(ctx context.Context, module *resources.Module, outpu return nil, nil, nil, fmt.Errorf("%s publishes no policies; run codefly generate contracts", module.Dir()) } if output != "" { - return []permissionDestination{{dir: output, methods: served.All()}}, nil, served.Unserved, nil + methods, tableErr := served.All() + if tableErr != nil { + return nil, nil, nil, tableErr + } + return []permissionDestination{{dir: output, methods: methods}}, nil, served.Unserved, nil } services, err := module.LoadServices(ctx) if err != nil { diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 353e458b..905347bc 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -604,7 +604,8 @@ func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDelive } // newCompositionDelivery speaks the composition interface over conn, reading -// each module's method policies, scope ceilings, roles and owner-awareness +// each module's method policies (the table its manifest carries to this +// host, gitops.HostMethodPolicies), scope ceilings, roles and owner-awareness // from the module as the run composes it at the time of the delivery. func (cluster *launcher) newCompositionDelivery(conn grpc.ClientConnInterface) *gitops.CompositionDelivery { load := func(ctx context.Context, module string) (string, error) { @@ -616,11 +617,11 @@ func (cluster *launcher) newCompositionDelivery(conn grpc.ClientConnInterface) * } return gitops.NewCompositionDelivery(conn, cluster.signer, func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) { - dir, err := load(ctx, module) + m, err := cluster.composed().LoadModuleFromName(ctx, module) if err != nil { return nil, false, err } - return gitops.MethodPolicies(dir) + return gitops.HostMethodPolicies(ctx, cluster.composed(), cluster.env, m) }, func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error) { dir, err := load(ctx, module) diff --git a/docs/module-manifests.md b/docs/module-manifests.md index 56f8f04e..752035ef 100644 --- a/docs/module-manifests.md +++ b/docs/module-manifests.md @@ -1303,7 +1303,28 @@ reject invalid declarations before their build/apply effects. Hop warning mode cannot bypass these checks. Unknown or contradictory method requirements are invalid; an intentionally restricted business role is valid. Identical policies published on multiple endpoints are accepted; conflicting policies for the same -method are refused rather than silently taking the first. +method are refused rather than silently taking the first — within one service, +and across the services of one table (below), since a table is keyed by method +and an HTTP route names no service. A gRPC service's OpenAPI document (its REST +gateway or proxy face, `openapi/api.swagger.json`) is its proto rendered for +HTTP and has no place for the policy option: a route it declares nothing for, +which one of the service's proto methods binds with `google.api.http`, carries +that method's policy. An `x-codefly-authority-policy` the document does declare +is a declaration of its own, refused when it differs from the proto's. + +**The table a host receives.** A module's manifest carries to the host the +table of its package less the services that stand in for that host: a service +whose exported endpoint implements a Codefly interface the `host.delivery` +service also implements (at any version), and which does not declare +`module-identity`. The local authority is one — it implements the host +interfaces so a workspace with no saas host has one, and a composition whose +host is saas never runs it — so toward saas/accounts the authority module's +table is its hop's alone. A call to a stand-in is the host's to answer, never +one the host decides for the module, so the preflight does not hold its methods +and a caller's binding does not resolve against them. A service that runs as +the module is always in the table, whatever it implements, and so is every +service of a module that implements nothing the host does. A host the +workspace does not compose cannot be read, so nothing is set aside toward it. **A method that declares no policy is refused only where policies are consumed.** The structural checks above hold everywhere: both `requires` and @@ -1316,7 +1337,9 @@ declares nothing at all is refused, by name and with the reason, in two cases: the manifest to the host's authority; or - the module's `module.contract.codefly.yaml` sets `require_method_policies: true`, which holds it to a policy on every method - wherever it is delivered. + its package publishes, stand-ins included, wherever it is delivered. + +In the first case the methods held are the table the host receives (above). The host's own module — the module of the `host.delivery` service, `saas` for `delivery: saas/accounts/connect` whatever `host.component` says (`saas` on a @@ -1328,7 +1351,7 @@ composition is still refused by name, and the host module's own Toward any other host — the saas host every released composition delivers to — an unmarked method passes, listed with neither field set exactly as -`MethodPolicies` reads it, and the hop in front of the module refuses it per +`HostMethodPolicies` reads it, and the hop in front of the module refuses it per call. `codefly generate permissions` always requires a declaration: a table names every method. diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index 06049857..ce659408 100644 --- a/pkg/gitops/authority.go +++ b/pkg/gitops/authority.go @@ -97,7 +97,7 @@ func moduleAuthority( if loadErr != nil { return nil, false, fmt.Errorf("it is not a module this workspace composes: %w", loadErr) } - return MethodPolicies(callee.Dir()) + return HostMethodPolicies(ctx, workspace, env, callee) }) resolved, err := request.contract.Resolve(workspaceValues{provided: provided}, modulecontract.WithCallees(callees)) if err != nil { diff --git a/pkg/gitops/method_policies.go b/pkg/gitops/method_policies.go index 467ea256..889a82f3 100644 --- a/pkg/gitops/method_policies.go +++ b/pkg/gitops/method_policies.go @@ -1,58 +1,169 @@ package gitops import ( + "context" "errors" "fmt" "os" "path/filepath" + "slices" "sort" + "strings" "github.com/codefly-dev/core/composition" modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" "google.golang.org/protobuf/proto" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/codefly-dev/cli/pkg/environments" "github.com/codefly-dev/cli/pkg/methodpolicy" ) // MethodPolicies reads what each method a module's published protobuf contracts // serve declares with the authority's policy option, from the module package -// at dir. Every served method is listed, a method with no mark with neither -// field set, so the authority tells "declares nothing" from "not served". A -// method a published descriptor declares but no endpoint of the module serves -// (a protocol another module serves, say) is not listed: the module answers -// for no call to it. A module with no package declares no method policy: ok is -// false. +// at dir: the whole package's table, every service's (ServedMethodPolicies.All). +// Every served method is listed, a method with no mark with neither field set, +// so the authority tells "declares nothing" from "not served". A method a +// published descriptor declares but no endpoint of the module serves (a +// protocol another module serves, say) is not listed: the module answers for +// no call to it. A module with no package declares no method policy: ok is +// false. What a host receives is HostMethodPolicies, which may set services +// aside. func MethodPolicies(dir string) (methods []*compositionv1.MethodPolicyV1, ok bool, err error) { served, ok, err := ServiceMethodPolicies(dir) if err != nil || !ok { return nil, ok, err } - return served.All(), true, nil + methods, err = served.All() + return methods, err == nil, err } -// All is the whole module's table: every service's served methods, each -// once, and the foreign methods, sorted by method. -func (served *ServedMethodPolicies) All() []*compositionv1.MethodPolicyV1 { +// HostMethodPolicies is the table a module's manifest carries to env's host, +// and so the one the permission preflight holds and a caller's binding +// resolves against: the package's table less the services that stand in for +// that host, or are it (hostStandIns). Those serve the host's own interfaces, +// so a call to them is the host's to answer and never one the host decides +// for the module, and a composition whose host is another service never runs +// them as the module. A service that runs as the module (module-identity) is +// never set aside, nor is any service of a module that implements no +// interface the host does: every such module's table is MethodPolicies. +func HostMethodPolicies(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, module *resources.Module) ([]*compositionv1.MethodPolicyV1, bool, error) { + served, ok, err := ServiceMethodPolicies(module.Dir()) + if err != nil || !ok { + return nil, ok, err + } + standIns, err := hostStandIns(ctx, workspace, env, module) + if err != nil { + return nil, false, err + } + methods, err := served.Table(func(service string) bool { return !standIns[service] }) + if err != nil { + return nil, false, err + } + return methods, true, nil +} + +// hostStandIns names the services of module that stand in for env's host: a +// service whose endpoint the module interface exports as implementing a +// Codefly interface the host service (the one host.delivery names) also +// implements, at any version, and which does not run as the module. The +// local authority is one: it implements the host interfaces so a workspace +// with no saas host has one, and a composition that binds those interfaces to +// another host never runs it. In the host's own module the host service is +// named by the same rule. A host the workspace does not compose cannot be +// read, so nothing is set aside toward it: every method stays held, as +// methodPoliciesRequired holds such a host to receive every policy. A host +// module that does not load is an error with its reason. +func hostStandIns(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, module *resources.Module) (map[string]bool, error) { + if env == nil || env.Host == nil || module.Interface == nil { + return nil, nil + } + hostModule, hostService, _ := env.Host.DeliveryEndpoint() + host, err := workspace.LoadModuleFromName(ctx, hostModule) + switch { + case err != nil && isModuleNotFound(err): + return nil, nil + case err != nil: + return nil, fmt.Errorf("cannot tell which services of module %s stand in for host %s/%s: its module does not load: %w", module.Name, hostModule, hostService, err) + } + hosted := map[string]bool{} + if host.Interface != nil { + for _, endpoint := range host.Interface.Endpoints { + if endpoint.Service != hostService { + continue + } + for _, implemented := range endpoint.Implements { + hosted[interfaceName(implemented)] = true + } + } + } + standIns := map[string]bool{} + for _, endpoint := range module.Interface.Endpoints { + if standIns[endpoint.Service] || !slices.ContainsFunc(endpoint.Implements, func(implemented string) bool { return hosted[interfaceName(implemented)] }) { + continue + } + service, err := module.LoadServiceFromName(ctx, endpoint.Service) + if err != nil { + return nil, fmt.Errorf("module %s service %s implements an interface of host %s/%s and does not load: %w", module.Name, endpoint.Service, hostModule, hostService, err) + } + if !service.ModuleIdentity { + standIns[endpoint.Service] = true + } + } + return standIns, nil +} + +// interfaceName is a Codefly interface reference without its version: +// "codefly.dev/authority" for "codefly.dev/authority@0.3.3". +func interfaceName(reference string) string { + name, _, _ := strings.Cut(reference, "@") + return name +} + +// All is the whole package's table: Table with every service. +func (served *ServedMethodPolicies) All() ([]*compositionv1.MethodPolicyV1, error) { + return served.Table(nil) +} + +// Table is the table of the services keep admits (every service when keep is +// nil), each method once, and the foreign methods, sorted by method. A table +// is one authority's view of one audience, keyed by method alone, so two of +// its services serving one method — an HTTP route, which names no service — +// must declare one policy for it. Two services that are never in one table may +// declare it differently. +func (served *ServedMethodPolicies) Table(keep func(service string) bool) ([]*compositionv1.MethodPolicyV1, error) { methods := append([]*compositionv1.MethodPolicyV1(nil), served.Foreign...) - seen := map[string]bool{} + seen := map[string]*compositionv1.MethodPolicyV1{} + for _, m := range served.Foreign { + seen[m.GetMethod()] = m + } for _, service := range served.Services { + if keep != nil && !keep(service) { + continue + } for _, m := range served.ByService[service] { - if !seen[m.GetMethod()] { - seen[m.GetMethod()] = true + previous := seen[m.GetMethod()] + switch { + case previous == nil: + seen[m.GetMethod()] = m methods = append(methods, m) + case !proto.Equal(previous, m): + return nil, fmt.Errorf("module package %s: conflicting policies for %s", served.Package, m.GetMethod()) } } } sort.Slice(methods, func(i, j int) bool { return methods[i].GetMethod() < methods[j].GetMethod() }) - return methods + return methods, nil } // ServedMethodPolicies is a module's method policies by where they are // served: each service's published contracts, and the module contract's // foreign methods, which name no service. type ServedMethodPolicies struct { + // Package is the package's ID, which a refusal names. + Package string // Services are the package's services that publish a contract, in // manifest order. Services []string @@ -65,9 +176,19 @@ type ServedMethodPolicies struct { } // ServiceMethodPolicies reads the same policies as MethodPolicies, kept by -// the service whose published contract serves them. A method two services -// serve must carry one policy, and a foreign method no published contract may -// serve, exactly as MethodPolicies refuses. +// the service whose published contract serves them. A method one service +// serves on two endpoints must carry one policy there, and a foreign method +// no published contract may serve; a method two services serve meets in a +// table, which refuses it there (Table). +// +// A service's protobuf contracts are read before its OpenAPI ones, and a +// route an OpenAPI operation of the service declares nothing for, which one +// of the service's protobuf methods binds (google.api.http), is that method's +// route with that method's policy: the document is the REST gateway's +// rendering of the proto, which has no place for the policy option, and the +// proto is the one place the module declares both (methodpolicy). An +// operation that declares a policy of its own is a declaration, and one that +// differs from the proto's is refused. // // What a protobuf endpoint serves is what the API contract catalog // (contracts/api/catalog.codefly.json) records for it: the services and @@ -90,17 +211,26 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er if err != nil { return served, false, err } + served.Package = manifest.ID catalog, err := methodpolicy.LoadCatalog(dir) if err != nil { return served, false, err } - seen := map[string]*compositionv1.MethodPolicyV1{} + servedAnywhere := map[string]bool{} unserved := map[string]bool{} for index := range manifest.Services { service := &manifest.Services[index] - own := map[string]bool{} - for _, contract := range service.APIContracts { - methods, skipped, err := methodpolicy.Contract(dir, catalog, service.Name, &contract) + contracts := make([]*composition.ProvidedAPIContract, 0, len(service.APIContracts)) + for position := range service.APIContracts { + contracts = append(contracts, &service.APIContracts[position]) + } + sort.SliceStable(contracts, func(i, j int) bool { + return contracts[i].Kind == composition.APIContractKindProtobuf && contracts[j].Kind != composition.APIContractKindProtobuf + }) + own := map[string]*compositionv1.MethodPolicyV1{} + protoRoutes := map[string]bool{} + for _, contract := range contracts { + methods, skipped, err := methodpolicy.Contract(dir, catalog, service.Name, contract) if err != nil { return served, false, fmt.Errorf("module package %s, service %s: %w", manifest.ID, service.Name, err) } @@ -108,14 +238,20 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er unserved[method] = true } for _, m := range methods { - if previous := seen[m.GetMethod()]; previous == nil { - seen[m.GetMethod()] = m - } else if !proto.Equal(previous, m) { - return served, false, fmt.Errorf("module package %s: conflicting policies for %s", manifest.ID, m.GetMethod()) - } - if !own[m.GetMethod()] { - own[m.GetMethod()] = true + servedAnywhere[m.GetMethod()] = true + previous := own[m.GetMethod()] + switch { + case previous == nil: + own[m.GetMethod()] = m + if contract.Kind == composition.APIContractKindProtobuf { + protoRoutes[m.GetMethod()] = true + } served.ByService[service.Name] = append(served.ByService[service.Name], m) + case proto.Equal(previous, m): + case contract.Kind == composition.APIContractKindOpenAPI && protoRoutes[m.GetMethod()] && declaresNothing(m): + // The gateway's copy of a route the service's proto declares. + default: + return served, false, fmt.Errorf("module package %s: conflicting policies for %s", manifest.ID, m.GetMethod()) } } } @@ -127,15 +263,15 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er // A foreign method is one no published contract serves: a module that // declares one it also publishes would have two answers for one method. for _, m := range foreign { - if seen[m.GetMethod()] != nil { + if servedAnywhere[m.GetMethod()] { return served, false, fmt.Errorf("module package %s declares foreign method %s, which its published contracts already serve", manifest.ID, m.GetMethod()) } - seen[m.GetMethod()] = m + servedAnywhere[m.GetMethod()] = true } // A method one endpoint serves and another's descriptor merely carries is // served. for method := range unserved { - if seen[method] == nil { + if !servedAnywhere[method] { served.Unserved = append(served.Unserved, method) } } @@ -143,6 +279,11 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er return served, true, nil } +// declaresNothing reports a method listed with neither field set. +func declaresNothing(m *compositionv1.MethodPolicyV1) bool { + return !m.GetAnyCheckedCaller() && len(m.GetRequires()) == 0 +} + // foreignMethods reads the methods the module at dir serves from a protocol // it does not own, from its module contract, each with what a call requires. // A module that publishes no contract declares none. diff --git a/pkg/gitops/method_policies_test.go b/pkg/gitops/method_policies_test.go index dbb60fcd..ff02ae92 100644 --- a/pkg/gitops/method_policies_test.go +++ b/pkg/gitops/method_policies_test.go @@ -371,7 +371,10 @@ func TestMethodPoliciesListOnlyTheServedMethods(t *testing.T) { if !slices.Equal(served.Unserved, []string{"/api.ApiService/Plain"}) { t.Fatalf("unserved: %v", served.Unserved) } - methods := served.All() + methods, err := served.All() + if err != nil { + t.Fatal(err) + } var names []string for _, m := range methods { names = append(names, m.GetMethod()) @@ -388,3 +391,81 @@ func TestMethodPoliciesListOnlyTheServedMethods(t *testing.T) { t.Fatalf("a catalog with no entry for the endpoint: %v", err) } } + +// gatewayPackage is policyPackage with a second contract on its api service: +// the OpenAPI document of an http endpoint, written before the protobuf one, +// the shape `codefly generate contracts` publishes for a gRPC service's REST +// gateway or proxy face. document is that document. +func gatewayPackage(t *testing.T, document string) string { + t.Helper() + dir := policyPackage(t, false) + path := filepath.Join(dir, "contracts", "api", "api", "http", "openapi.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(document), 0o644); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(document)) + manifestPath := filepath.Join(dir, "module.package.codefly.yaml") + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + gateway := strings.Replace(string(manifest), " endpoints: [grpc]\n api-contracts:\n", ` endpoints: [grpc, http] + api-contracts: + - endpoint: http + kind: openapi + package: api.proto + path: contracts/api/api/http/openapi.json + digest: sha256:`+hex.EncodeToString(sum[:])+"\n", 1) + if gateway == string(manifest) { + t.Fatal("the package manifest changed shape") + } + if err := os.WriteFile(manifestPath, []byte(gateway), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +// A gRPC service's OpenAPI document is its proto rendered for HTTP, which has +// no place for the policy option: a route it declares nothing for, which one +// of the service's proto methods binds, carries that method's policy rather +// than a second, empty declaration that would contradict it, whichever +// contract the package lists first. A route the proto does not bind is the +// document's own, listed as it declares it, and a policy the document does +// declare is a declaration, refused when it differs from the proto's. +func TestAGatewayRouteCarriesThePolicyOfTheProtoMethodItRenders(t *testing.T) { + methods, ok, err := MethodPolicies(gatewayPackage(t, `{"swagger":"2.0","info":{"title":"api.proto"},"paths":{ + "/v1/console/whoami":{"get":{}}, + "/v1/console/extra":{"get":{}}}}`)) + if err != nil || !ok { + t.Fatalf("methods: %v %v", ok, err) + } + byName := map[string]*compositionv1.MethodPolicyV1{} + for _, m := range methods { + if byName[m.GetMethod()] != nil { + t.Fatalf("%s is listed twice", m.GetMethod()) + } + byName[m.GetMethod()] = m + } + if r := byName["GET /v1/console/whoami"].GetRequires(); len(r) != 1 || r[0].GetResourceKind() != "console.identity" { + t.Fatalf("the gateway's copy of WhoAmI's route carries WhoAmI's policy: %v", byName["GET /v1/console/whoami"]) + } + if extra := byName["GET /v1/console/extra"]; extra == nil || !declaresNothing(extra) { + t.Fatalf("a route no proto method binds is listed as the document declares it: %v", extra) + } + if err := ValidateMethodPolicies([]*compositionv1.MethodPolicyV1{byName["GET /v1/console/whoami"]}, true); err != nil { + t.Fatalf("the derived route declares a policy: %v", err) + } + + _, _, err = MethodPolicies(gatewayPackage(t, `{"swagger":"2.0","paths":{ + "/v1/console/whoami":{"get":{"x-codefly-authority-policy":{"any_checked_caller":true}}}}}`)) + if err == nil || !strings.Contains(err.Error(), "conflicting policies for GET /v1/console/whoami") { + t.Fatalf("a document declaring another policy for the route: %v", err) + } + if _, _, err = MethodPolicies(gatewayPackage(t, `{"swagger":"2.0","paths":{ + "/v1/console/whoami":{"get":{"x-codefly-authority-policy":{"requires":[{"resource_kind":"console.identity","actions":["read"]}]}}}}}`)); err != nil { + t.Fatalf("a document declaring the proto's own policy: %v", err) + } +} diff --git a/pkg/gitops/permission_validation.go b/pkg/gitops/permission_validation.go index c0a4e11e..b37daa27 100644 --- a/pkg/gitops/permission_validation.go +++ b/pkg/gitops/permission_validation.go @@ -78,14 +78,16 @@ func HostImplements(ctx context.Context, workspace *resources.Workspace, hostMod // required where it is consumed: by a host implementing codefly.dev/composition, // which receives the policies with each manifest, or wherever the module's own // contract opts in (require_method_policies). Toward any other host an unmarked -// method passes, as MethodPolicies lists it: the hop refuses it per call. +// method passes, as HostMethodPolicies lists it: the hop refuses it per call. // // The host's own module — the module of the host.delivery service — is not // behind the hop: its methods are the host's, never fronted by the policies it // receives, so the host clause does not hold it (its own // require_method_policies still does). Every other module in the composition -// is held. host.component names the host's instance, not a module, so it is -// never read here. A host that cannot be read never drops the requirement: one +// is held, on the table its manifest carries (HostMethodPolicies): a service +// of it that stands in for the host is the host's in the same way and is not +// in that table, while every service that runs as the module is. host.component +// names the host's instance, not a module, so it is never read here. A host that cannot be read never drops the requirement: one // the workspace does not compose is held to receive every policy, and one that // does not load is an error with its reason. func methodPoliciesRequired(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, moduleName string, request authorityRequest) (string, bool, error) { @@ -123,22 +125,33 @@ func ValidateModulePermissions(ctx context.Context, workspace *resources.Workspa // selectedSource is an explicit development deployment input, already resolved // through loadDevService, which must be checked even atop a published package. +// +// The methods held are the table the module's manifest carries to the host +// (HostMethodPolicies); a module whose own contract sets +// require_method_policies holds every method its package publishes, the +// services that stand in for the host included, since it asked for exactly +// that. func validateModulePermissions(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *environments.Environment, selectedSource *resources.Service) error { - methods, _, err := MethodPolicies(module.Dir()) + request, err := authorityRequestOf(module) if err != nil { return err } - request, err := authorityRequestOf(module) + reason, required, err := methodPoliciesRequired(ctx, workspace, env, module.Name, request) if err != nil { return err } - reason, required, err := methodPoliciesRequired(ctx, workspace, env, module.Name, request) + var methods []*compositionv1.MethodPolicyV1 + if request.contract != nil && request.contract.RequireMethodPolicies { + methods, _, err = MethodPolicies(module.Dir()) + } else { + methods, _, err = HostMethodPolicies(ctx, workspace, env, module) + } if err != nil { return err } if err = ValidateMethodPolicies(methods, required); err != nil { if required { - return fmt.Errorf("module %s permissions (every published method declares a policy: %s): %w", module.Name, reason, err) + return fmt.Errorf("module %s permissions (every method it delivers declares a policy: %s): %w", module.Name, reason, err) } return fmt.Errorf("module %s permissions: %w", module.Name, err) } diff --git a/pkg/gitops/permission_validation_test.go b/pkg/gitops/permission_validation_test.go index 8c8a8534..97698ff9 100644 --- a/pkg/gitops/permission_validation_test.go +++ b/pkg/gitops/permission_validation_test.go @@ -5,12 +5,14 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "testing" hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" modulecontract "github.com/codefly-dev/core/contracts/module" "github.com/codefly-dev/core/resources" + "google.golang.org/genproto/googleapis/api/annotations" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/descriptorpb" @@ -373,3 +375,128 @@ func TestPreflightUsesSelectedClosureRatherThanEveryPinnedModule(t *testing.T) { require.NoError(t, CheckCompositionPermissions(t.Context(), workspace, env, true, "good")) require.ErrorContains(t, CheckCompositionPermissions(t.Context(), workspace, env, true, "bad"), "declares both requirements and any_checked_caller") } + +// addHostStandIn adds the local authority's shape to shop: a service "local" +// whose exported grpc endpoint implements implemented — the interface the +// host's delivery service implements, for a stand-in — and publishes +// LocalService, two methods declaring nothing. route, when set, binds one of +// them to that HTTP route; identity declares module-identity on it. +func addHostStandIn(t *testing.T, workspace *resources.Workspace, implemented, route string, identity bool) *resources.Workspace { + t.Helper() + dir := filepath.Join(workspace.Dir(), "modules", "shop") + service := &descriptorpb.ServiceDescriptorProto{Name: proto.String("LocalService")} + for _, name := range []string{"Check", "Current"} { + m := &descriptorpb.MethodDescriptorProto{Name: proto.String(name), InputType: proto.String(".host.Empty"), OutputType: proto.String(".host.Empty")} + if name == "Current" && route != "" { + m.Options = &descriptorpb.MethodOptions{} + proto.SetExtension(m.Options, annotations.E_Http, &annotations.HttpRule{Pattern: &annotations.HttpRule_Get{Get: route}}) + } + service.Method = append(service.Method, m) + } + set, err := proto.Marshal(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{ + Name: proto.String("local.proto"), Package: proto.String("host"), Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("Empty")}}, Service: []*descriptorpb.ServiceDescriptorProto{service}, + }}}) + require.NoError(t, err) + local := hostedServiceYAML("local") + if identity { + local += "module-identity: true\n" + } + source := "syntax = \"proto3\";\npackage host;\n" + files := map[string]string{ + resources.ModuleConfigurationName: "kind: module\nname: shop\nservices:\n - name: api\n - name: local\ninterface:\n endpoints:\n" + + " - service: api\n endpoint: grpc\n - service: api\n endpoint: http\n visibility: public\n" + + " - service: local\n endpoint: grpc\n implements: [" + implemented + "]\n", + filepath.Join("services", "local", resources.ServiceConfigurationName): local, + filepath.Join("services", "local", "proto", "local.proto"): source, + filepath.Join("contracts", "api", "local", "grpc", "proto", "local.proto"): source, + filepath.Join("contracts", "api", "local", "grpc", "contract.binpb"): string(set), + } + for rel, content := range files { + full := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) + } + manifestPath := filepath.Join(dir, "module.package.codefly.yaml") + manifest, err := os.ReadFile(manifestPath) + require.NoError(t, err) + if !strings.Contains(string(manifest), " - name: local\n") { + published := strings.Replace(string(manifest), "\ncontracts:\n", fmt.Sprintf(` + - name: local + endpoints: [grpc] + api-contracts: + - endpoint: grpc + kind: protobuf + package: host + path: contracts/api/local/grpc/contract.binpb + digest: sha256:%x +contracts: +`, sha256.Sum256(set)), 1) + require.NotEqual(t, string(manifest), published) + manifest = []byte(published) + } else { + manifest = regexp.MustCompile(`(path: contracts/api/local/grpc/contract.binpb\n\s+digest: )sha256:[0-9a-f]+`).ReplaceAll(manifest, []byte(fmt.Sprintf("${1}sha256:%x", sha256.Sum256(set)))) + } + require.NoError(t, os.WriteFile(manifestPath, manifest, 0o644)) + reloaded, err := resources.LoadWorkspaceFromDir(t.Context(), workspace.Dir()) + require.NoError(t, err) + return reloaded +} + +// A service that stands in for the host — it implements the interface the +// host's delivery service implements, and does not run as the module, as the +// local authority does beside its hop — is the host's code: a call to it is +// never one the host decides for the module. So the table the module's +// manifest carries to that host, which the preflight holds and a caller's +// binding resolves against, leaves it out: its unmarked methods are not +// refused, and a route it binds differently from the module's own service is +// no conflict there, though the whole package still declares it twice. Every +// service that runs as the module stays held by name — the module's own api, +// and the stand-in itself once it declares module-identity — and a service +// implementing an interface the host does not implement stands in for nothing. +func TestAServiceStandingInForTheHostIsNotInTheTableItsManifestCarries(t *testing.T) { + const composition = CompositionInterface + "@1.0.0" + ctx := t.Context() + workspace, _, _ := sourcePolicyWorkspace(t) + workspace = composeCompositionHost(t, workspace) + workspace = addHostStandIn(t, workspace, composition, "/v1/console/whoami", false) + env := selectedEnvironment(t, workspace, "staging") + module, err := workspace.LoadModuleFromName(ctx, "shop") + require.NoError(t, err) + + _, _, err = MethodPolicies(module.Dir()) + require.ErrorContains(t, err, "conflicting policies for GET /v1/console/whoami") + methods, ok, err := HostMethodPolicies(ctx, workspace, env, module) + require.NoError(t, err) + require.True(t, ok) + var names []string + for _, m := range methods { + names = append(names, m.GetMethod()) + } + require.Contains(t, names, "/api.ApiService/WhoAmI") + require.Contains(t, names, "GET /v1/console/whoami") + require.NotContains(t, names, "/host.LocalService/Check") + require.NoError(t, ValidateModulePermissions(ctx, workspace, module, env)) + require.NoError(t, CheckCompositionPermissions(ctx, workspace, env, true, "shop")) + + // The module's own service still declares a policy on every method. + markPlain(t, module.Dir(), nil) + err = CheckCompositionPermissions(ctx, workspace, env, true, "shop") + require.ErrorContains(t, err, "/api.ApiService/Plain declares no permission policy") + require.NotContains(t, err.Error(), "LocalService") + markPlain(t, module.Dir(), &hopv1.Policy{AnyCheckedCaller: true}) + + // A stand-in that runs as the module is what the host fronts. + workspace = addHostStandIn(t, workspace, composition, "", true) + module, err = workspace.LoadModuleFromName(ctx, "shop") + require.NoError(t, err) + err = ValidateModulePermissions(ctx, workspace, module, env) + require.ErrorContains(t, err, "/host.LocalService/Check declares no permission policy") + require.ErrorContains(t, err, "/host.LocalService/Current declares no permission policy") + + // An interface the host does not implement makes no stand-in. + workspace = addHostStandIn(t, workspace, "codefly.dev/session@0.2.0", "", false) + module, err = workspace.LoadModuleFromName(ctx, "shop") + require.NoError(t, err) + require.ErrorContains(t, ValidateModulePermissions(ctx, workspace, module, env), "/host.LocalService/Check declares no permission policy") +}