Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/generate/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions cmd/run/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
29 changes: 26 additions & 3 deletions docs/module-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion pkg/gitops/authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
203 changes: 172 additions & 31 deletions pkg/gitops/method_policies.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -90,32 +211,47 @@ 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)
}
for _, method := range skipped {
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())
}
}
}
Expand All @@ -127,22 +263,27 @@ 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)
}
}
sort.Strings(served.Unserved)
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.
Expand Down
Loading
Loading