From 1b3757bdb9b7981ae0516beec6ee0a58dbb5ae6c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 14:08:25 -0400 Subject: [PATCH 1/6] feat: enforce per-method effect/network policy on the Solution contract (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binds a host-enforceable solution_method_policy extension to every Solution RPC (mirroring provider_method_policy) and adds the host-side dispatch gate that reads and enforces it. - Proto: SolutionNetworkMode + SolutionEffect (severity-ordered) and the solution_method_policy method option; annotate Create/Update/Package/Render and GetSolutionInformation with their true network reach and state effect. - Host: a unary client interceptor (solution.EnforcingClientInterceptor) installed on every agent connection refuses to dispatch a Solution RPC whose declared policy exceeds the per-call ceiling stamped via solution.WithCeiling; non-Solution calls pass through untouched. - The gate is honest about scope: it constrains what the host invokes, not what a plugin does inside a handler — the Solution contract has no host-brokered callback path, so unmediated writes are out of scope by design. Co-Authored-By: Claude Opus 4.8 --- agents/manager/loader.go | 6 + .../services/solution/v0/solution.pb.go | 412 +++++++++++++----- .../services/solution/v0/solution_grpc.pb.go | 38 +- .../solution/v0/v0connect/solution.connect.go | 16 +- .../services/solution/v0/solution.proto | 95 +++- solution/policy.go | 148 +++++++ solution/policy_test.go | 211 +++++++++ 7 files changed, 774 insertions(+), 152 deletions(-) create mode 100644 solution/policy.go create mode 100644 solution/policy_test.go diff --git a/agents/manager/loader.go b/agents/manager/loader.go index 65b34e6a..0c2e9326 100644 --- a/agents/manager/loader.go +++ b/agents/manager/loader.go @@ -28,6 +28,7 @@ import ( "github.com/codefly-dev/core/resources" runnersbase "github.com/codefly-dev/core/runners/base" "github.com/codefly-dev/core/runners/sandbox" + "github.com/codefly-dev/core/solution" coretoolbox "github.com/codefly-dev/core/toolbox" "github.com/codefly-dev/core/wool" @@ -1028,6 +1029,11 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithStatsHandler(otelgrpc.NewClientHandler()), grpc.WithPerRPCCredentials(bearerCreds{token: authToken}), + // Host-side dispatch gate for the Solution contract: refuses to send a + // Solution RPC whose declared effect/network policy exceeds the ceiling + // stamped on the call context (solution.WithCeiling). No-ops for every + // other service, so it is safe on every agent connection. + grpc.WithChainUnaryInterceptor(solution.EnforcingClientInterceptor()), grpcconfig.TypedMessageClientDialOption(), ) if err != nil { diff --git a/generated/go/codefly/services/solution/v0/solution.pb.go b/generated/go/codefly/services/solution/v0/solution.pb.go index 1818a242..76ccba76 100644 --- a/generated/go/codefly/services/solution/v0/solution.pb.go +++ b/generated/go/codefly/services/solution/v0/solution.pb.go @@ -15,6 +15,7 @@ import ( v0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + descriptorpb "google.golang.org/protobuf/types/descriptorpb" ) const ( @@ -24,6 +25,176 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// SolutionNetworkMode declares the maximum network reach an RPC may use. +// Values are ordered by increasing reach so a host ceiling admits every +// mode at or below it. +type SolutionNetworkMode int32 + +const ( + // SOLUTION_NETWORK_MODE_UNSPECIFIED is never admitted. + SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED SolutionNetworkMode = 0 + // SOLUTION_NETWORK_MODE_OFFLINE forbids all network access. + SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE SolutionNetworkMode = 1 + // SOLUTION_NETWORK_MODE_REGISTRY_WRITE permits pushing to an OCI registry. + SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE SolutionNetworkMode = 2 +) + +// Enum value maps for SolutionNetworkMode. +var ( + SolutionNetworkMode_name = map[int32]string{ + 0: "SOLUTION_NETWORK_MODE_UNSPECIFIED", + 1: "SOLUTION_NETWORK_MODE_OFFLINE", + 2: "SOLUTION_NETWORK_MODE_REGISTRY_WRITE", + } + SolutionNetworkMode_value = map[string]int32{ + "SOLUTION_NETWORK_MODE_UNSPECIFIED": 0, + "SOLUTION_NETWORK_MODE_OFFLINE": 1, + "SOLUTION_NETWORK_MODE_REGISTRY_WRITE": 2, + } +) + +func (x SolutionNetworkMode) Enum() *SolutionNetworkMode { + p := new(SolutionNetworkMode) + *p = x + return p +} + +func (x SolutionNetworkMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SolutionNetworkMode) Descriptor() protoreflect.EnumDescriptor { + return file_codefly_services_solution_v0_solution_proto_enumTypes[0].Descriptor() +} + +func (SolutionNetworkMode) Type() protoreflect.EnumType { + return &file_codefly_services_solution_v0_solution_proto_enumTypes[0] +} + +func (x SolutionNetworkMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SolutionNetworkMode.Descriptor instead. +func (SolutionNetworkMode) EnumDescriptor() ([]byte, []int) { + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{0} +} + +// SolutionEffect declares the maximum state effect of one RPC. Values are +// ordered by increasing effect so a host ceiling admits every effect at or +// below it. +type SolutionEffect int32 + +const ( + // SOLUTION_EFFECT_UNSPECIFIED is never admitted. + SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED SolutionEffect = 0 + // SOLUTION_EFFECT_READ_ONLY inspects state but writes nothing. + SolutionEffect_SOLUTION_EFFECT_READ_ONLY SolutionEffect = 1 + // SOLUTION_EFFECT_LOCAL_WRITE writes only the local filesystem. + SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE SolutionEffect = 2 + // SOLUTION_EFFECT_REGISTRY_WRITE pushes a remote OCI artifact. + SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE SolutionEffect = 3 +) + +// Enum value maps for SolutionEffect. +var ( + SolutionEffect_name = map[int32]string{ + 0: "SOLUTION_EFFECT_UNSPECIFIED", + 1: "SOLUTION_EFFECT_READ_ONLY", + 2: "SOLUTION_EFFECT_LOCAL_WRITE", + 3: "SOLUTION_EFFECT_REGISTRY_WRITE", + } + SolutionEffect_value = map[string]int32{ + "SOLUTION_EFFECT_UNSPECIFIED": 0, + "SOLUTION_EFFECT_READ_ONLY": 1, + "SOLUTION_EFFECT_LOCAL_WRITE": 2, + "SOLUTION_EFFECT_REGISTRY_WRITE": 3, + } +) + +func (x SolutionEffect) Enum() *SolutionEffect { + p := new(SolutionEffect) + *p = x + return p +} + +func (x SolutionEffect) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SolutionEffect) Descriptor() protoreflect.EnumDescriptor { + return file_codefly_services_solution_v0_solution_proto_enumTypes[1].Descriptor() +} + +func (SolutionEffect) Type() protoreflect.EnumType { + return &file_codefly_services_solution_v0_solution_proto_enumTypes[1] +} + +func (x SolutionEffect) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SolutionEffect.Descriptor instead. +func (SolutionEffect) EnumDescriptor() ([]byte, []int) { + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{1} +} + +// SolutionMethodPolicy is descriptor metadata the host reads and enforces +// before invoking a Solution RPC. +type SolutionMethodPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // network is the maximum network reach available to the method. + Network SolutionNetworkMode `protobuf:"varint,1,opt,name=network,proto3,enum=codefly.services.solution.v0.SolutionNetworkMode" json:"network,omitempty"` + // effect is the maximum state effect available to the method. + Effect SolutionEffect `protobuf:"varint,2,opt,name=effect,proto3,enum=codefly.services.solution.v0.SolutionEffect" json:"effect,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SolutionMethodPolicy) Reset() { + *x = SolutionMethodPolicy{} + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SolutionMethodPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolutionMethodPolicy) ProtoMessage() {} + +func (x *SolutionMethodPolicy) ProtoReflect() protoreflect.Message { + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SolutionMethodPolicy.ProtoReflect.Descriptor instead. +func (*SolutionMethodPolicy) Descriptor() ([]byte, []int) { + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{0} +} + +func (x *SolutionMethodPolicy) GetNetwork() SolutionNetworkMode { + if x != nil { + return x.Network + } + return SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED +} + +func (x *SolutionMethodPolicy) GetEffect() SolutionEffect { + if x != nil { + return x.Effect + } + return SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED +} + // SolutionArtifact binds one concrete solution executor binary to its package. type SolutionArtifact struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -43,7 +214,7 @@ type SolutionArtifact struct { func (x *SolutionArtifact) Reset() { *x = SolutionArtifact{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[0] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -55,7 +226,7 @@ func (x *SolutionArtifact) String() string { func (*SolutionArtifact) ProtoMessage() {} func (x *SolutionArtifact) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[0] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -68,7 +239,7 @@ func (x *SolutionArtifact) ProtoReflect() protoreflect.Message { // Deprecated: Use SolutionArtifact.ProtoReflect.Descriptor instead. func (*SolutionArtifact) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{0} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{1} } func (x *SolutionArtifact) GetPublisher() string { @@ -121,7 +292,7 @@ type SolutionContext struct { func (x *SolutionContext) Reset() { *x = SolutionContext{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[1] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -133,7 +304,7 @@ func (x *SolutionContext) String() string { func (*SolutionContext) ProtoMessage() {} func (x *SolutionContext) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[1] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -146,7 +317,7 @@ func (x *SolutionContext) ProtoReflect() protoreflect.Message { // Deprecated: Use SolutionContext.ProtoReflect.Descriptor instead. func (*SolutionContext) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{1} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{2} } func (x *SolutionContext) GetWorkspace() string { @@ -187,7 +358,7 @@ type SolutionCapabilities struct { func (x *SolutionCapabilities) Reset() { *x = SolutionCapabilities{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[2] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -199,7 +370,7 @@ func (x *SolutionCapabilities) String() string { func (*SolutionCapabilities) ProtoMessage() {} func (x *SolutionCapabilities) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[2] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -212,7 +383,7 @@ func (x *SolutionCapabilities) ProtoReflect() protoreflect.Message { // Deprecated: Use SolutionCapabilities.ProtoReflect.Descriptor instead. func (*SolutionCapabilities) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{2} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{3} } func (x *SolutionCapabilities) GetSupportsCreate() bool { @@ -254,7 +425,7 @@ type GetSolutionInformationRequest struct { func (x *GetSolutionInformationRequest) Reset() { *x = GetSolutionInformationRequest{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[3] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -266,7 +437,7 @@ func (x *GetSolutionInformationRequest) String() string { func (*GetSolutionInformationRequest) ProtoMessage() {} func (x *GetSolutionInformationRequest) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[3] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -279,7 +450,7 @@ func (x *GetSolutionInformationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSolutionInformationRequest.ProtoReflect.Descriptor instead. func (*GetSolutionInformationRequest) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{3} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{4} } func (x *GetSolutionInformationRequest) GetArtifact() *SolutionArtifact { @@ -304,7 +475,7 @@ type GetSolutionInformationResponse struct { func (x *GetSolutionInformationResponse) Reset() { *x = GetSolutionInformationResponse{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[4] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -316,7 +487,7 @@ func (x *GetSolutionInformationResponse) String() string { func (*GetSolutionInformationResponse) ProtoMessage() {} func (x *GetSolutionInformationResponse) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[4] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -329,7 +500,7 @@ func (x *GetSolutionInformationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSolutionInformationResponse.ProtoReflect.Descriptor instead. func (*GetSolutionInformationResponse) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{4} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{5} } func (x *GetSolutionInformationResponse) GetArtifact() *SolutionArtifact { @@ -368,7 +539,7 @@ type CreateRequest struct { func (x *CreateRequest) Reset() { *x = CreateRequest{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[5] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -380,7 +551,7 @@ func (x *CreateRequest) String() string { func (*CreateRequest) ProtoMessage() {} func (x *CreateRequest) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[5] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -393,7 +564,7 @@ func (x *CreateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateRequest.ProtoReflect.Descriptor instead. func (*CreateRequest) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{5} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{6} } func (x *CreateRequest) GetContext() *SolutionContext { @@ -430,7 +601,7 @@ type CreateResponse struct { func (x *CreateResponse) Reset() { *x = CreateResponse{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[6] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -442,7 +613,7 @@ func (x *CreateResponse) String() string { func (*CreateResponse) ProtoMessage() {} func (x *CreateResponse) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[6] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -455,7 +626,7 @@ func (x *CreateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateResponse.ProtoReflect.Descriptor instead. func (*CreateResponse) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{6} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{7} } func (x *CreateResponse) GetCreatedPaths() []string { @@ -487,7 +658,7 @@ type UpdateRequest struct { func (x *UpdateRequest) Reset() { *x = UpdateRequest{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[7] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -499,7 +670,7 @@ func (x *UpdateRequest) String() string { func (*UpdateRequest) ProtoMessage() {} func (x *UpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[7] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -512,7 +683,7 @@ func (x *UpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. func (*UpdateRequest) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{7} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{8} } func (x *UpdateRequest) GetContext() *SolutionContext { @@ -549,7 +720,7 @@ type UpdateResponse struct { func (x *UpdateResponse) Reset() { *x = UpdateResponse{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[8] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -561,7 +732,7 @@ func (x *UpdateResponse) String() string { func (*UpdateResponse) ProtoMessage() {} func (x *UpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[8] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -574,7 +745,7 @@ func (x *UpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. func (*UpdateResponse) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{8} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{9} } func (x *UpdateResponse) GetUpdatedPaths() []string { @@ -606,7 +777,7 @@ type PackageRequest struct { func (x *PackageRequest) Reset() { *x = PackageRequest{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[9] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -618,7 +789,7 @@ func (x *PackageRequest) String() string { func (*PackageRequest) ProtoMessage() {} func (x *PackageRequest) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[9] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -631,7 +802,7 @@ func (x *PackageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PackageRequest.ProtoReflect.Descriptor instead. func (*PackageRequest) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{9} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{10} } func (x *PackageRequest) GetContext() *SolutionContext { @@ -670,7 +841,7 @@ type PackageResponse struct { func (x *PackageResponse) Reset() { *x = PackageResponse{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[10] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -682,7 +853,7 @@ func (x *PackageResponse) String() string { func (*PackageResponse) ProtoMessage() {} func (x *PackageResponse) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[10] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -695,7 +866,7 @@ func (x *PackageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PackageResponse.ProtoReflect.Descriptor instead. func (*PackageResponse) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{10} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{11} } func (x *PackageResponse) GetReference() string { @@ -736,7 +907,7 @@ type RenderRequest struct { func (x *RenderRequest) Reset() { *x = RenderRequest{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[11] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -748,7 +919,7 @@ func (x *RenderRequest) String() string { func (*RenderRequest) ProtoMessage() {} func (x *RenderRequest) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[11] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -761,7 +932,7 @@ func (x *RenderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenderRequest.ProtoReflect.Descriptor instead. func (*RenderRequest) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{11} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{12} } func (x *RenderRequest) GetContext() *SolutionContext { @@ -805,7 +976,7 @@ type RenderResponse struct { func (x *RenderResponse) Reset() { *x = RenderResponse{} - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[12] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -817,7 +988,7 @@ func (x *RenderResponse) String() string { func (*RenderResponse) ProtoMessage() {} func (x *RenderResponse) ProtoReflect() protoreflect.Message { - mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[12] + mi := &file_codefly_services_solution_v0_solution_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -830,7 +1001,7 @@ func (x *RenderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenderResponse.ProtoReflect.Descriptor instead. func (*RenderResponse) Descriptor() ([]byte, []int) { - return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{12} + return file_codefly_services_solution_v0_solution_proto_rawDescGZIP(), []int{13} } func (x *RenderResponse) GetRenderedPaths() []string { @@ -847,11 +1018,33 @@ func (x *RenderResponse) GetDiagnostics() []*v0.FailureDiagnostic { return nil } +var file_codefly_services_solution_v0_solution_proto_extTypes = []protoimpl.ExtensionInfo{ + { + ExtendedType: (*descriptorpb.MethodOptions)(nil), + ExtensionType: (*SolutionMethodPolicy)(nil), + Field: 51002, + Name: "codefly.services.solution.v0.solution_method_policy", + Tag: "bytes,51002,opt,name=solution_method_policy", + Filename: "codefly/services/solution/v0/solution.proto", + }, +} + +// Extension fields to descriptorpb.MethodOptions. +var ( + // solution_method_policy binds enforceable network and effect policy to an RPC. + // + // optional codefly.services.solution.v0.SolutionMethodPolicy solution_method_policy = 51002; + E_SolutionMethodPolicy = &file_codefly_services_solution_v0_solution_proto_extTypes[0] +) + var File_codefly_services_solution_v0_solution_proto protoreflect.FileDescriptor const file_codefly_services_solution_v0_solution_proto_rawDesc = "" + "\n" + - "+codefly/services/solution/v0/solution.proto\x12\x1ccodefly.services.solution.v0\x1a\x1bbuf/validate/validate.proto\x1a\x1dcodefly/base/v0/failure.proto\"\xb0\x01\n" + + "+codefly/services/solution/v0/solution.proto\x12\x1ccodefly.services.solution.v0\x1a\x1bbuf/validate/validate.proto\x1a\x1dcodefly/base/v0/failure.proto\x1a google/protobuf/descriptor.proto\"\xa9\x01\n" + + "\x14SolutionMethodPolicy\x12K\n" + + "\anetwork\x18\x01 \x01(\x0e21.codefly.services.solution.v0.SolutionNetworkModeR\anetwork\x12D\n" + + "\x06effect\x18\x02 \x01(\x0e2,.codefly.services.solution.v0.SolutionEffectR\x06effect\"\xb0\x01\n" + "\x10SolutionArtifact\x12\x1c\n" + "\tpublisher\x18\x01 \x01(\tR\tpublisher\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + @@ -915,13 +1108,23 @@ const file_codefly_services_solution_v0_solution_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x92\x01\n" + "\x0eRenderResponse\x120\n" + "\x0erendered_paths\x18\x01 \x03(\tB\t\xbaH\x06\x92\x01\x03\x10\x90NR\rrenderedPaths\x12N\n" + - "\vdiagnostics\x18\x02 \x03(\v2\".codefly.base.v0.FailureDiagnosticB\b\xbaH\x05\x92\x01\x02\x10dR\vdiagnostics2\xb7\x04\n" + - "\bSolution\x12\x93\x01\n" + - "\x16GetSolutionInformation\x12;.codefly.services.solution.v0.GetSolutionInformationRequest\x1a<.codefly.services.solution.v0.GetSolutionInformationResponse\x12c\n" + - "\x06Create\x12+.codefly.services.solution.v0.CreateRequest\x1a,.codefly.services.solution.v0.CreateResponse\x12c\n" + - "\x06Update\x12+.codefly.services.solution.v0.UpdateRequest\x1a,.codefly.services.solution.v0.UpdateResponse\x12f\n" + - "\aPackage\x12,.codefly.services.solution.v0.PackageRequest\x1a-.codefly.services.solution.v0.PackageResponse\x12c\n" + - "\x06Render\x12+.codefly.services.solution.v0.RenderRequest\x1a,.codefly.services.solution.v0.RenderResponseB\x8c\x02\n" + + "\vdiagnostics\x18\x02 \x03(\v2\".codefly.base.v0.FailureDiagnosticB\b\xbaH\x05\x92\x01\x02\x10dR\vdiagnostics*\x89\x01\n" + + "\x13SolutionNetworkMode\x12%\n" + + "!SOLUTION_NETWORK_MODE_UNSPECIFIED\x10\x00\x12!\n" + + "\x1dSOLUTION_NETWORK_MODE_OFFLINE\x10\x01\x12(\n" + + "$SOLUTION_NETWORK_MODE_REGISTRY_WRITE\x10\x02*\x95\x01\n" + + "\x0eSolutionEffect\x12\x1f\n" + + "\x1bSOLUTION_EFFECT_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19SOLUTION_EFFECT_READ_ONLY\x10\x01\x12\x1f\n" + + "\x1bSOLUTION_EFFECT_LOCAL_WRITE\x10\x02\x12\"\n" + + "\x1eSOLUTION_EFFECT_REGISTRY_WRITE\x10\x032\xe9\x04\n" + + "\bSolution\x12\x9d\x01\n" + + "\x16GetSolutionInformation\x12;.codefly.services.solution.v0.GetSolutionInformationRequest\x1a<.codefly.services.solution.v0.GetSolutionInformationResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x01\x12m\n" + + "\x06Create\x12+.codefly.services.solution.v0.CreateRequest\x1a,.codefly.services.solution.v0.CreateResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x02\x12m\n" + + "\x06Update\x12+.codefly.services.solution.v0.UpdateRequest\x1a,.codefly.services.solution.v0.UpdateResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x02\x12p\n" + + "\aPackage\x12,.codefly.services.solution.v0.PackageRequest\x1a-.codefly.services.solution.v0.PackageResponse\"\b\xd2\xf3\x18\x04\b\x02\x10\x03\x12m\n" + + "\x06Render\x12+.codefly.services.solution.v0.RenderRequest\x1a,.codefly.services.solution.v0.RenderResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x02:\x8a\x01\n" + + "\x16solution_method_policy\x12\x1e.google.protobuf.MethodOptions\x18\xba\x8e\x03 \x01(\v22.codefly.services.solution.v0.SolutionMethodPolicyR\x14solutionMethodPolicyB\x8c\x02\n" + " com.codefly.services.solution.v0B\rSolutionProtoP\x01ZEgithub.com/codefly-dev/core/generated/go/codefly/services/solution/v0\xa2\x02\x04CSSV\xaa\x02\x1cCodefly.Services.Solution.V0\xca\x02\x1cCodefly\\Services\\Solution\\V0\xe2\x02(Codefly\\Services\\Solution\\V0\\GPBMetadata\xea\x02\x1fCodefly::Services::Solution::V0b\x06proto3" var ( @@ -936,58 +1139,67 @@ func file_codefly_services_solution_v0_solution_proto_rawDescGZIP() []byte { return file_codefly_services_solution_v0_solution_proto_rawDescData } -var file_codefly_services_solution_v0_solution_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_codefly_services_solution_v0_solution_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_codefly_services_solution_v0_solution_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_codefly_services_solution_v0_solution_proto_goTypes = []any{ - (*SolutionArtifact)(nil), // 0: codefly.services.solution.v0.SolutionArtifact - (*SolutionContext)(nil), // 1: codefly.services.solution.v0.SolutionContext - (*SolutionCapabilities)(nil), // 2: codefly.services.solution.v0.SolutionCapabilities - (*GetSolutionInformationRequest)(nil), // 3: codefly.services.solution.v0.GetSolutionInformationRequest - (*GetSolutionInformationResponse)(nil), // 4: codefly.services.solution.v0.GetSolutionInformationResponse - (*CreateRequest)(nil), // 5: codefly.services.solution.v0.CreateRequest - (*CreateResponse)(nil), // 6: codefly.services.solution.v0.CreateResponse - (*UpdateRequest)(nil), // 7: codefly.services.solution.v0.UpdateRequest - (*UpdateResponse)(nil), // 8: codefly.services.solution.v0.UpdateResponse - (*PackageRequest)(nil), // 9: codefly.services.solution.v0.PackageRequest - (*PackageResponse)(nil), // 10: codefly.services.solution.v0.PackageResponse - (*RenderRequest)(nil), // 11: codefly.services.solution.v0.RenderRequest - (*RenderResponse)(nil), // 12: codefly.services.solution.v0.RenderResponse - nil, // 13: codefly.services.solution.v0.CreateRequest.ParametersEntry - nil, // 14: codefly.services.solution.v0.UpdateRequest.ParametersEntry - nil, // 15: codefly.services.solution.v0.RenderRequest.ValuesEntry - (*v0.FailureDiagnostic)(nil), // 16: codefly.base.v0.FailureDiagnostic + (SolutionNetworkMode)(0), // 0: codefly.services.solution.v0.SolutionNetworkMode + (SolutionEffect)(0), // 1: codefly.services.solution.v0.SolutionEffect + (*SolutionMethodPolicy)(nil), // 2: codefly.services.solution.v0.SolutionMethodPolicy + (*SolutionArtifact)(nil), // 3: codefly.services.solution.v0.SolutionArtifact + (*SolutionContext)(nil), // 4: codefly.services.solution.v0.SolutionContext + (*SolutionCapabilities)(nil), // 5: codefly.services.solution.v0.SolutionCapabilities + (*GetSolutionInformationRequest)(nil), // 6: codefly.services.solution.v0.GetSolutionInformationRequest + (*GetSolutionInformationResponse)(nil), // 7: codefly.services.solution.v0.GetSolutionInformationResponse + (*CreateRequest)(nil), // 8: codefly.services.solution.v0.CreateRequest + (*CreateResponse)(nil), // 9: codefly.services.solution.v0.CreateResponse + (*UpdateRequest)(nil), // 10: codefly.services.solution.v0.UpdateRequest + (*UpdateResponse)(nil), // 11: codefly.services.solution.v0.UpdateResponse + (*PackageRequest)(nil), // 12: codefly.services.solution.v0.PackageRequest + (*PackageResponse)(nil), // 13: codefly.services.solution.v0.PackageResponse + (*RenderRequest)(nil), // 14: codefly.services.solution.v0.RenderRequest + (*RenderResponse)(nil), // 15: codefly.services.solution.v0.RenderResponse + nil, // 16: codefly.services.solution.v0.CreateRequest.ParametersEntry + nil, // 17: codefly.services.solution.v0.UpdateRequest.ParametersEntry + nil, // 18: codefly.services.solution.v0.RenderRequest.ValuesEntry + (*v0.FailureDiagnostic)(nil), // 19: codefly.base.v0.FailureDiagnostic + (*descriptorpb.MethodOptions)(nil), // 20: google.protobuf.MethodOptions } var file_codefly_services_solution_v0_solution_proto_depIdxs = []int32{ - 0, // 0: codefly.services.solution.v0.SolutionContext.artifact:type_name -> codefly.services.solution.v0.SolutionArtifact - 0, // 1: codefly.services.solution.v0.GetSolutionInformationRequest.artifact:type_name -> codefly.services.solution.v0.SolutionArtifact - 0, // 2: codefly.services.solution.v0.GetSolutionInformationResponse.artifact:type_name -> codefly.services.solution.v0.SolutionArtifact - 2, // 3: codefly.services.solution.v0.GetSolutionInformationResponse.capabilities:type_name -> codefly.services.solution.v0.SolutionCapabilities - 16, // 4: codefly.services.solution.v0.GetSolutionInformationResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic - 1, // 5: codefly.services.solution.v0.CreateRequest.context:type_name -> codefly.services.solution.v0.SolutionContext - 13, // 6: codefly.services.solution.v0.CreateRequest.parameters:type_name -> codefly.services.solution.v0.CreateRequest.ParametersEntry - 16, // 7: codefly.services.solution.v0.CreateResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic - 1, // 8: codefly.services.solution.v0.UpdateRequest.context:type_name -> codefly.services.solution.v0.SolutionContext - 14, // 9: codefly.services.solution.v0.UpdateRequest.parameters:type_name -> codefly.services.solution.v0.UpdateRequest.ParametersEntry - 16, // 10: codefly.services.solution.v0.UpdateResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic - 1, // 11: codefly.services.solution.v0.PackageRequest.context:type_name -> codefly.services.solution.v0.SolutionContext - 16, // 12: codefly.services.solution.v0.PackageResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic - 1, // 13: codefly.services.solution.v0.RenderRequest.context:type_name -> codefly.services.solution.v0.SolutionContext - 15, // 14: codefly.services.solution.v0.RenderRequest.values:type_name -> codefly.services.solution.v0.RenderRequest.ValuesEntry - 16, // 15: codefly.services.solution.v0.RenderResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic - 3, // 16: codefly.services.solution.v0.Solution.GetSolutionInformation:input_type -> codefly.services.solution.v0.GetSolutionInformationRequest - 5, // 17: codefly.services.solution.v0.Solution.Create:input_type -> codefly.services.solution.v0.CreateRequest - 7, // 18: codefly.services.solution.v0.Solution.Update:input_type -> codefly.services.solution.v0.UpdateRequest - 9, // 19: codefly.services.solution.v0.Solution.Package:input_type -> codefly.services.solution.v0.PackageRequest - 11, // 20: codefly.services.solution.v0.Solution.Render:input_type -> codefly.services.solution.v0.RenderRequest - 4, // 21: codefly.services.solution.v0.Solution.GetSolutionInformation:output_type -> codefly.services.solution.v0.GetSolutionInformationResponse - 6, // 22: codefly.services.solution.v0.Solution.Create:output_type -> codefly.services.solution.v0.CreateResponse - 8, // 23: codefly.services.solution.v0.Solution.Update:output_type -> codefly.services.solution.v0.UpdateResponse - 10, // 24: codefly.services.solution.v0.Solution.Package:output_type -> codefly.services.solution.v0.PackageResponse - 12, // 25: codefly.services.solution.v0.Solution.Render:output_type -> codefly.services.solution.v0.RenderResponse - 21, // [21:26] is the sub-list for method output_type - 16, // [16:21] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 0, // 0: codefly.services.solution.v0.SolutionMethodPolicy.network:type_name -> codefly.services.solution.v0.SolutionNetworkMode + 1, // 1: codefly.services.solution.v0.SolutionMethodPolicy.effect:type_name -> codefly.services.solution.v0.SolutionEffect + 3, // 2: codefly.services.solution.v0.SolutionContext.artifact:type_name -> codefly.services.solution.v0.SolutionArtifact + 3, // 3: codefly.services.solution.v0.GetSolutionInformationRequest.artifact:type_name -> codefly.services.solution.v0.SolutionArtifact + 3, // 4: codefly.services.solution.v0.GetSolutionInformationResponse.artifact:type_name -> codefly.services.solution.v0.SolutionArtifact + 5, // 5: codefly.services.solution.v0.GetSolutionInformationResponse.capabilities:type_name -> codefly.services.solution.v0.SolutionCapabilities + 19, // 6: codefly.services.solution.v0.GetSolutionInformationResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic + 4, // 7: codefly.services.solution.v0.CreateRequest.context:type_name -> codefly.services.solution.v0.SolutionContext + 16, // 8: codefly.services.solution.v0.CreateRequest.parameters:type_name -> codefly.services.solution.v0.CreateRequest.ParametersEntry + 19, // 9: codefly.services.solution.v0.CreateResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic + 4, // 10: codefly.services.solution.v0.UpdateRequest.context:type_name -> codefly.services.solution.v0.SolutionContext + 17, // 11: codefly.services.solution.v0.UpdateRequest.parameters:type_name -> codefly.services.solution.v0.UpdateRequest.ParametersEntry + 19, // 12: codefly.services.solution.v0.UpdateResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic + 4, // 13: codefly.services.solution.v0.PackageRequest.context:type_name -> codefly.services.solution.v0.SolutionContext + 19, // 14: codefly.services.solution.v0.PackageResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic + 4, // 15: codefly.services.solution.v0.RenderRequest.context:type_name -> codefly.services.solution.v0.SolutionContext + 18, // 16: codefly.services.solution.v0.RenderRequest.values:type_name -> codefly.services.solution.v0.RenderRequest.ValuesEntry + 19, // 17: codefly.services.solution.v0.RenderResponse.diagnostics:type_name -> codefly.base.v0.FailureDiagnostic + 20, // 18: codefly.services.solution.v0.solution_method_policy:extendee -> google.protobuf.MethodOptions + 2, // 19: codefly.services.solution.v0.solution_method_policy:type_name -> codefly.services.solution.v0.SolutionMethodPolicy + 6, // 20: codefly.services.solution.v0.Solution.GetSolutionInformation:input_type -> codefly.services.solution.v0.GetSolutionInformationRequest + 8, // 21: codefly.services.solution.v0.Solution.Create:input_type -> codefly.services.solution.v0.CreateRequest + 10, // 22: codefly.services.solution.v0.Solution.Update:input_type -> codefly.services.solution.v0.UpdateRequest + 12, // 23: codefly.services.solution.v0.Solution.Package:input_type -> codefly.services.solution.v0.PackageRequest + 14, // 24: codefly.services.solution.v0.Solution.Render:input_type -> codefly.services.solution.v0.RenderRequest + 7, // 25: codefly.services.solution.v0.Solution.GetSolutionInformation:output_type -> codefly.services.solution.v0.GetSolutionInformationResponse + 9, // 26: codefly.services.solution.v0.Solution.Create:output_type -> codefly.services.solution.v0.CreateResponse + 11, // 27: codefly.services.solution.v0.Solution.Update:output_type -> codefly.services.solution.v0.UpdateResponse + 13, // 28: codefly.services.solution.v0.Solution.Package:output_type -> codefly.services.solution.v0.PackageResponse + 15, // 29: codefly.services.solution.v0.Solution.Render:output_type -> codefly.services.solution.v0.RenderResponse + 25, // [25:30] is the sub-list for method output_type + 20, // [20:25] is the sub-list for method input_type + 19, // [19:20] is the sub-list for extension type_name + 18, // [18:19] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_codefly_services_solution_v0_solution_proto_init() } @@ -1000,14 +1212,16 @@ func file_codefly_services_solution_v0_solution_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_codefly_services_solution_v0_solution_proto_rawDesc), len(file_codefly_services_solution_v0_solution_proto_rawDesc)), - NumEnums: 0, - NumMessages: 16, - NumExtensions: 0, + NumEnums: 2, + NumMessages: 17, + NumExtensions: 1, NumServices: 1, }, GoTypes: file_codefly_services_solution_v0_solution_proto_goTypes, DependencyIndexes: file_codefly_services_solution_v0_solution_proto_depIdxs, + EnumInfos: file_codefly_services_solution_v0_solution_proto_enumTypes, MessageInfos: file_codefly_services_solution_v0_solution_proto_msgTypes, + ExtensionInfos: file_codefly_services_solution_v0_solution_proto_extTypes, }.Build() File_codefly_services_solution_v0_solution_proto = out.File file_codefly_services_solution_v0_solution_proto_goTypes = nil diff --git a/generated/go/codefly/services/solution/v0/solution_grpc.pb.go b/generated/go/codefly/services/solution/v0/solution_grpc.pb.go index 6fe79396..d3e75624 100644 --- a/generated/go/codefly/services/solution/v0/solution_grpc.pb.go +++ b/generated/go/codefly/services/solution/v0/solution_grpc.pb.go @@ -36,27 +36,22 @@ const ( // manifests into a gitops repository. The solution spec it operates on stays // codefly-agnostic; only the executor is a codefly plugin. // -// The per-RPC effect notes below describe each method's maximum side effect. -// They are documentation only in this version: unlike provider.proto's -// host-enforced provider_method_policy, nothing yet enforces these ceilings. -// A host-enforceable method policy is tracked in codefly-dev/core#289, and the -// agents.Serve() wiring that lets a plugin expose this contract is tracked in -// codefly-dev/core#290. +// Every RPC binds a solution_method_policy declaring its maximum network reach +// and state effect. The host reads the policy from these descriptors and +// refuses to invoke any method whose declared ceiling exceeds the ceiling +// admitted for the current operation. The agents.Serve() wiring that lets a +// plugin expose this contract is tracked in codefly-dev/core#290. type SolutionClient interface { // GetSolutionInformation returns the concrete artifact identity and the - // lifecycle operations this executor implements. Effect: read-only, no writes. + // lifecycle operations this executor implements. GetSolutionInformation(ctx context.Context, in *GetSolutionInformationRequest, opts ...grpc.CallOption) (*GetSolutionInformationResponse, error) // Create scaffolds a new solution into a destination directory. - // Effect: writes files under the destination directory. Create(ctx context.Context, in *CreateRequest, opts ...grpc.CallOption) (*CreateResponse, error) // Update reconciles an existing solution source with the executor's template. - // Effect: mutates files under the source directory. Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*UpdateResponse, error) - // Package builds an OCI artifact from a solution source directory. - // Effect: reads the source directory and pushes an OCI artifact (network write). + // Package builds an OCI artifact from a solution source directory and pushes it. Package(ctx context.Context, in *PackageRequest, opts ...grpc.CallOption) (*PackageResponse, error) // Render renders a packaged solution's manifests into a gitops destination. - // Effect: writes manifests into the gitops destination (filesystem/git mutation). Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) } @@ -127,27 +122,22 @@ func (c *solutionClient) Render(ctx context.Context, in *RenderRequest, opts ... // manifests into a gitops repository. The solution spec it operates on stays // codefly-agnostic; only the executor is a codefly plugin. // -// The per-RPC effect notes below describe each method's maximum side effect. -// They are documentation only in this version: unlike provider.proto's -// host-enforced provider_method_policy, nothing yet enforces these ceilings. -// A host-enforceable method policy is tracked in codefly-dev/core#289, and the -// agents.Serve() wiring that lets a plugin expose this contract is tracked in -// codefly-dev/core#290. +// Every RPC binds a solution_method_policy declaring its maximum network reach +// and state effect. The host reads the policy from these descriptors and +// refuses to invoke any method whose declared ceiling exceeds the ceiling +// admitted for the current operation. The agents.Serve() wiring that lets a +// plugin expose this contract is tracked in codefly-dev/core#290. type SolutionServer interface { // GetSolutionInformation returns the concrete artifact identity and the - // lifecycle operations this executor implements. Effect: read-only, no writes. + // lifecycle operations this executor implements. GetSolutionInformation(context.Context, *GetSolutionInformationRequest) (*GetSolutionInformationResponse, error) // Create scaffolds a new solution into a destination directory. - // Effect: writes files under the destination directory. Create(context.Context, *CreateRequest) (*CreateResponse, error) // Update reconciles an existing solution source with the executor's template. - // Effect: mutates files under the source directory. Update(context.Context, *UpdateRequest) (*UpdateResponse, error) - // Package builds an OCI artifact from a solution source directory. - // Effect: reads the source directory and pushes an OCI artifact (network write). + // Package builds an OCI artifact from a solution source directory and pushes it. Package(context.Context, *PackageRequest) (*PackageResponse, error) // Render renders a packaged solution's manifests into a gitops destination. - // Effect: writes manifests into the gitops destination (filesystem/git mutation). Render(context.Context, *RenderRequest) (*RenderResponse, error) mustEmbedUnimplementedSolutionServer() } diff --git a/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go b/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go index 4413155b..09f56716 100644 --- a/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go +++ b/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go @@ -50,19 +50,15 @@ const ( // SolutionClient is a client for the codefly.services.solution.v0.Solution service. type SolutionClient interface { // GetSolutionInformation returns the concrete artifact identity and the - // lifecycle operations this executor implements. Effect: read-only, no writes. + // lifecycle operations this executor implements. GetSolutionInformation(context.Context, *connect.Request[v0.GetSolutionInformationRequest]) (*connect.Response[v0.GetSolutionInformationResponse], error) // Create scaffolds a new solution into a destination directory. - // Effect: writes files under the destination directory. Create(context.Context, *connect.Request[v0.CreateRequest]) (*connect.Response[v0.CreateResponse], error) // Update reconciles an existing solution source with the executor's template. - // Effect: mutates files under the source directory. Update(context.Context, *connect.Request[v0.UpdateRequest]) (*connect.Response[v0.UpdateResponse], error) - // Package builds an OCI artifact from a solution source directory. - // Effect: reads the source directory and pushes an OCI artifact (network write). + // Package builds an OCI artifact from a solution source directory and pushes it. Package(context.Context, *connect.Request[v0.PackageRequest]) (*connect.Response[v0.PackageResponse], error) // Render renders a packaged solution's manifests into a gitops destination. - // Effect: writes manifests into the gitops destination (filesystem/git mutation). Render(context.Context, *connect.Request[v0.RenderRequest]) (*connect.Response[v0.RenderResponse], error) } @@ -147,19 +143,15 @@ func (c *solutionClient) Render(ctx context.Context, req *connect.Request[v0.Ren // SolutionHandler is an implementation of the codefly.services.solution.v0.Solution service. type SolutionHandler interface { // GetSolutionInformation returns the concrete artifact identity and the - // lifecycle operations this executor implements. Effect: read-only, no writes. + // lifecycle operations this executor implements. GetSolutionInformation(context.Context, *connect.Request[v0.GetSolutionInformationRequest]) (*connect.Response[v0.GetSolutionInformationResponse], error) // Create scaffolds a new solution into a destination directory. - // Effect: writes files under the destination directory. Create(context.Context, *connect.Request[v0.CreateRequest]) (*connect.Response[v0.CreateResponse], error) // Update reconciles an existing solution source with the executor's template. - // Effect: mutates files under the source directory. Update(context.Context, *connect.Request[v0.UpdateRequest]) (*connect.Response[v0.UpdateResponse], error) - // Package builds an OCI artifact from a solution source directory. - // Effect: reads the source directory and pushes an OCI artifact (network write). + // Package builds an OCI artifact from a solution source directory and pushes it. Package(context.Context, *connect.Request[v0.PackageRequest]) (*connect.Response[v0.PackageResponse], error) // Render renders a packaged solution's manifests into a gitops destination. - // Effect: writes manifests into the gitops destination (filesystem/git mutation). Render(context.Context, *connect.Request[v0.RenderRequest]) (*connect.Response[v0.RenderResponse], error) } diff --git a/proto/codefly/services/solution/v0/solution.proto b/proto/codefly/services/solution/v0/solution.proto index 5aaa1726..76092c06 100644 --- a/proto/codefly/services/solution/v0/solution.proto +++ b/proto/codefly/services/solution/v0/solution.proto @@ -4,34 +4,95 @@ package codefly.services.solution.v0; import "buf/validate/validate.proto"; import "codefly/base/v0/failure.proto"; +import "google/protobuf/descriptor.proto"; + +// SolutionNetworkMode declares the maximum network reach an RPC may use. +// Values are ordered by increasing reach so a host ceiling admits every +// mode at or below it. +enum SolutionNetworkMode { + // SOLUTION_NETWORK_MODE_UNSPECIFIED is never admitted. + SOLUTION_NETWORK_MODE_UNSPECIFIED = 0; + // SOLUTION_NETWORK_MODE_OFFLINE forbids all network access. + SOLUTION_NETWORK_MODE_OFFLINE = 1; + // SOLUTION_NETWORK_MODE_REGISTRY_WRITE permits pushing to an OCI registry. + SOLUTION_NETWORK_MODE_REGISTRY_WRITE = 2; +} + +// SolutionEffect declares the maximum state effect of one RPC. Values are +// ordered by increasing effect so a host ceiling admits every effect at or +// below it. +enum SolutionEffect { + // SOLUTION_EFFECT_UNSPECIFIED is never admitted. + SOLUTION_EFFECT_UNSPECIFIED = 0; + // SOLUTION_EFFECT_READ_ONLY inspects state but writes nothing. + SOLUTION_EFFECT_READ_ONLY = 1; + // SOLUTION_EFFECT_LOCAL_WRITE writes only the local filesystem. + SOLUTION_EFFECT_LOCAL_WRITE = 2; + // SOLUTION_EFFECT_REGISTRY_WRITE pushes a remote OCI artifact. + SOLUTION_EFFECT_REGISTRY_WRITE = 3; +} + +// SolutionMethodPolicy is descriptor metadata the host reads and enforces +// before invoking a Solution RPC. +message SolutionMethodPolicy { + // network is the maximum network reach available to the method. + SolutionNetworkMode network = 1; + // effect is the maximum state effect available to the method. + SolutionEffect effect = 2; +} + +extend google.protobuf.MethodOptions { + // solution_method_policy binds enforceable network and effect policy to an RPC. + SolutionMethodPolicy solution_method_policy = 51002; +} // Solution is the host-to-solution executor protocol. A solution agent // scaffolds a solution, packages it as an OCI artifact, and renders its // manifests into a gitops repository. The solution spec it operates on stays // codefly-agnostic; only the executor is a codefly plugin. // -// The per-RPC effect notes below describe each method's maximum side effect. -// They are documentation only in this version: unlike provider.proto's -// host-enforced provider_method_policy, nothing yet enforces these ceilings. -// A host-enforceable method policy is tracked in codefly-dev/core#289, and the -// agents.Serve() wiring that lets a plugin expose this contract is tracked in -// codefly-dev/core#290. +// Every RPC binds a solution_method_policy declaring its maximum network reach +// and state effect. The host reads the policy from these descriptors and +// refuses to invoke any method whose declared ceiling exceeds the ceiling +// admitted for the current operation. The agents.Serve() wiring that lets a +// plugin expose this contract is tracked in codefly-dev/core#290. service Solution { // GetSolutionInformation returns the concrete artifact identity and the - // lifecycle operations this executor implements. Effect: read-only, no writes. - rpc GetSolutionInformation(GetSolutionInformationRequest) returns (GetSolutionInformationResponse); + // lifecycle operations this executor implements. + rpc GetSolutionInformation(GetSolutionInformationRequest) returns (GetSolutionInformationResponse) { + option (solution_method_policy) = { + network: SOLUTION_NETWORK_MODE_OFFLINE + effect: SOLUTION_EFFECT_READ_ONLY + }; + } // Create scaffolds a new solution into a destination directory. - // Effect: writes files under the destination directory. - rpc Create(CreateRequest) returns (CreateResponse); + rpc Create(CreateRequest) returns (CreateResponse) { + option (solution_method_policy) = { + network: SOLUTION_NETWORK_MODE_OFFLINE + effect: SOLUTION_EFFECT_LOCAL_WRITE + }; + } // Update reconciles an existing solution source with the executor's template. - // Effect: mutates files under the source directory. - rpc Update(UpdateRequest) returns (UpdateResponse); - // Package builds an OCI artifact from a solution source directory. - // Effect: reads the source directory and pushes an OCI artifact (network write). - rpc Package(PackageRequest) returns (PackageResponse); + rpc Update(UpdateRequest) returns (UpdateResponse) { + option (solution_method_policy) = { + network: SOLUTION_NETWORK_MODE_OFFLINE + effect: SOLUTION_EFFECT_LOCAL_WRITE + }; + } + // Package builds an OCI artifact from a solution source directory and pushes it. + rpc Package(PackageRequest) returns (PackageResponse) { + option (solution_method_policy) = { + network: SOLUTION_NETWORK_MODE_REGISTRY_WRITE + effect: SOLUTION_EFFECT_REGISTRY_WRITE + }; + } // Render renders a packaged solution's manifests into a gitops destination. - // Effect: writes manifests into the gitops destination (filesystem/git mutation). - rpc Render(RenderRequest) returns (RenderResponse); + rpc Render(RenderRequest) returns (RenderResponse) { + option (solution_method_policy) = { + network: SOLUTION_NETWORK_MODE_OFFLINE + effect: SOLUTION_EFFECT_LOCAL_WRITE + }; + } } // SolutionArtifact binds one concrete solution executor binary to its package. diff --git a/solution/policy.go b/solution/policy.go new file mode 100644 index 00000000..4b28acb5 --- /dev/null +++ b/solution/policy.go @@ -0,0 +1,148 @@ +// Package solution holds the host-side dispatch gate for the Solution executor +// contract. The Solution service binds a solution_method_policy to every RPC +// declaring its maximum network reach and state effect; the host reads that +// policy from the generated descriptors and refuses to dispatch any method +// whose declared ceiling exceeds the ceiling admitted for the current operation. +// +// The gate constrains what the host chooses to invoke — it does not, and under +// this contract cannot, constrain what a solution executor actually does inside +// a handler. Unlike provider.proto (where ProviderHost brokers the provider's +// side effects and the host enforces the policy at the point of effect), the +// Solution contract has no host-brokered callback path: a plugin's real +// filesystem and registry writes are unmediated. Enforcing declared effects +// against plugin behavior would require a broker this contract does not define. +package solution + +import ( + "context" + "fmt" + "strings" + + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +const solutionServiceName protoreflect.FullName = "codefly.services.solution.v0.Solution" + +// Ceiling is the maximum network reach and state effect a host admits for one +// operation. A method is admitted only when both of its declared policy fields +// are at or below the ceiling. +type Ceiling struct { + Network solutionv0.SolutionNetworkMode + Effect solutionv0.SolutionEffect +} + +type ceilingContextKey struct{} + +// WithCeiling stamps the ceiling admitted for the current operation onto a +// context. The host sets it per call because one solution-agent connection is +// long-lived and reused across operations (see agents/manager.loader: +// AgentConn.GRPCConn), so the ceiling belongs to the call, not the dial. A +// Solution RPC issued without a ceiling in its context is refused by +// EnforcingClientInterceptor. +func WithCeiling(ctx context.Context, ceiling Ceiling) context.Context { + return context.WithValue(ctx, ceilingContextKey{}, ceiling) +} + +// CeilingFrom returns the ceiling stamped on the context, if any. +func CeilingFrom(ctx context.Context) (Ceiling, bool) { + ceiling, ok := ctx.Value(ceilingContextKey{}).(Ceiling) + return ceiling, ok +} + +// PolicyFor returns the declared method policy for a full gRPC method name +// (e.g. "/codefly.services.solution.v0.Solution/Package"). The second result +// reports whether the method belongs to the Solution service; a Solution method +// with no annotation returns (nil, true) so callers fail closed. +func PolicyFor(fullMethod string) (*solutionv0.SolutionMethodPolicy, bool) { + method := methodDescriptor(fullMethod) + if method == nil { + return nil, false + } + options, ok := method.Options().(*descriptorpb.MethodOptions) + if !ok || !proto.HasExtension(options, solutionv0.E_SolutionMethodPolicy) { + return nil, true + } + return proto.GetExtension(options, solutionv0.E_SolutionMethodPolicy).(*solutionv0.SolutionMethodPolicy), true +} + +// Admits reports whether a method policy is within the ceiling. It fails closed: +// a nil policy, an unspecified policy field, or an unspecified ceiling field is +// never admitted. +func Admits(policy *solutionv0.SolutionMethodPolicy, ceiling Ceiling) error { + if policy == nil { + return fmt.Errorf("no method policy declared") + } + network, effect := policy.GetNetwork(), policy.GetEffect() + if network == solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED { + return fmt.Errorf("network mode is unspecified") + } + if effect == solutionv0.SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED { + return fmt.Errorf("effect is unspecified") + } + if ceiling.Network == solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED { + return fmt.Errorf("ceiling network mode is unspecified") + } + if ceiling.Effect == solutionv0.SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED { + return fmt.Errorf("ceiling effect is unspecified") + } + if network > ceiling.Network { + return fmt.Errorf("network mode %s exceeds ceiling %s", network, ceiling.Network) + } + if effect > ceiling.Effect { + return fmt.Errorf("effect %s exceeds ceiling %s", effect, ceiling.Effect) + } + return nil +} + +// EnforcingClientInterceptor is the host-side dispatch gate: a unary client +// interceptor installed on every agent connection (agents/manager.loader). For +// each outgoing Solution RPC it reads the declared policy and the ceiling +// stamped on the call context (see WithCeiling) and refuses to dispatch a call +// whose declared ceiling exceeds the admitted ceiling, or that carries no +// ceiling at all. Calls to services other than Solution pass through untouched, +// so installing it universally does not affect non-solution agents. +// +// It covers unary RPCs only, which is complete because the Solution contract is +// unary-only — an invariant TestSolutionContractIsUnaryOnly guards. A streaming +// Solution RPC must not be added without a matching stream gate, or it would +// dispatch unchecked. +func EnforcingClientInterceptor() grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + policy, isSolution := PolicyFor(method) + if !isSolution { + return invoker(ctx, method, req, reply, cc, opts...) + } + ceiling, ok := CeilingFrom(ctx) + if !ok { + return status.Errorf(codes.PermissionDenied, "solution method %s denied: no operation ceiling on context", method) + } + if err := Admits(policy, ceiling); err != nil { + return status.Errorf(codes.PermissionDenied, "solution method %s denied: %v", method, err) + } + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +// methodDescriptor resolves the Solution method descriptor for a full gRPC +// method name, or nil when the name does not target the Solution service. +func methodDescriptor(fullMethod string) protoreflect.MethodDescriptor { + trimmed := strings.TrimPrefix(fullMethod, "/") + slash := strings.LastIndex(trimmed, "/") + if slash < 0 { + return nil + } + if protoreflect.FullName(trimmed[:slash]) != solutionServiceName { + return nil + } + service := solutionv0.File_codefly_services_solution_v0_solution_proto.Services().ByName("Solution") + if service == nil { + return nil + } + return service.Methods().ByName(protoreflect.Name(trimmed[slash+1:])) +} diff --git a/solution/policy_test.go b/solution/policy_test.go new file mode 100644 index 00000000..5d696ae0 --- /dev/null +++ b/solution/policy_test.go @@ -0,0 +1,211 @@ +package solution_test + +import ( + "context" + "net" + "testing" + + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" + "github.com/codefly-dev/core/solution" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +func TestSolutionRPCMethodPolicyIsMachineEnforceable(t *testing.T) { + expected := map[string]*solutionv0.SolutionMethodPolicy{ + "GetSolutionInformation": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY}, + "Create": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, + "Update": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, + "Package": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE}, + "Render": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, + } + + service := solutionv0.File_codefly_services_solution_v0_solution_proto.Services().ByName("Solution") + require.NotNil(t, service) + require.Equal(t, len(expected), service.Methods().Len()) + for i := 0; i < service.Methods().Len(); i++ { + method := service.Methods().Get(i) + options := method.Options().(*descriptorpb.MethodOptions) + require.True(t, proto.HasExtension(options, solutionv0.E_SolutionMethodPolicy), method.Name()) + policy := proto.GetExtension(options, solutionv0.E_SolutionMethodPolicy).(*solutionv0.SolutionMethodPolicy) + want := expected[string(method.Name())] + require.NotNil(t, want, method.Name()) + require.True(t, proto.Equal(want, policy), method.Name()) + } +} + +// TestSolutionContractIsUnaryOnly guards the invariant EnforcingClientInterceptor +// relies on: the gate is unary-only, so a streaming Solution RPC would dispatch +// unchecked. Adding one must fail here until a stream gate exists. +func TestSolutionContractIsUnaryOnly(t *testing.T) { + service := solutionv0.File_codefly_services_solution_v0_solution_proto.Services().ByName("Solution") + require.NotNil(t, service) + for i := 0; i < service.Methods().Len(); i++ { + method := service.Methods().Get(i) + require.False(t, method.IsStreamingClient(), "%s is client-streaming", method.Name()) + require.False(t, method.IsStreamingServer(), "%s is server-streaming", method.Name()) + } +} + +// TestSolutionMethodPolicyAxesAreCoherent guards the two policy axes against +// incoherent combinations. Network and effect are independent in the model (as +// in provider_method_policy), but for real methods a registry push is a remote +// write and vice versa: you cannot push to a registry while offline, and no +// non-registry effect needs registry network. This catches an annotation like +// {network: OFFLINE, effect: REGISTRY_WRITE} that the ordered ceiling checks +// would otherwise silently accept. +func TestSolutionMethodPolicyAxesAreCoherent(t *testing.T) { + service := solutionv0.File_codefly_services_solution_v0_solution_proto.Services().ByName("Solution") + require.NotNil(t, service) + for i := 0; i < service.Methods().Len(); i++ { + method := service.Methods().Get(i) + options := method.Options().(*descriptorpb.MethodOptions) + policy := proto.GetExtension(options, solutionv0.E_SolutionMethodPolicy).(*solutionv0.SolutionMethodPolicy) + registryNetwork := policy.GetNetwork() == solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE + registryEffect := policy.GetEffect() == solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE + require.Equal(t, registryEffect, registryNetwork, + "%s: registry network and registry effect must agree", method.Name()) + } +} + +func TestPolicyForResolvesByFullMethodName(t *testing.T) { + policy, isSolution := solution.PolicyFor(solutionv0.Solution_Package_FullMethodName) + require.True(t, isSolution) + require.Equal(t, solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, policy.GetNetwork()) + require.Equal(t, solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE, policy.GetEffect()) + + _, isSolution = solution.PolicyFor("/grpc.health.v1.Health/Check") + require.False(t, isSolution) + + _, isSolution = solution.PolicyFor("/codefly.services.solution.v0.Solution/DoesNotExist") + require.False(t, isSolution) +} + +func TestAdmitsEnforcesBothAxesAndFailsClosed(t *testing.T) { + localWriteCeiling := solution.Ceiling{ + Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, + Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE, + } + registryCeiling := solution.Ceiling{ + Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, + Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE, + } + + create := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE} + pkg := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE} + + require.NoError(t, solution.Admits(create, localWriteCeiling)) + require.NoError(t, solution.Admits(create, registryCeiling)) + // Package exceeds a local-write ceiling on both axes. + require.Error(t, solution.Admits(pkg, localWriteCeiling)) + require.NoError(t, solution.Admits(pkg, registryCeiling)) + + // Fail closed: nil policy, unspecified policy field, unspecified ceiling field. + require.Error(t, solution.Admits(nil, registryCeiling)) + require.Error(t, solution.Admits(&solutionv0.SolutionMethodPolicy{Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY}, registryCeiling)) + require.Error(t, solution.Admits(create, solution.Ceiling{})) +} + +// recordingSolutionServer is a real Solution server that records which RPCs it +// handled so a test can assert a denied call never reached it. +type recordingSolutionServer struct { + solutionv0.UnimplementedSolutionServer + handled map[string]int +} + +func (s *recordingSolutionServer) Create(context.Context, *solutionv0.CreateRequest) (*solutionv0.CreateResponse, error) { + s.handled["Create"]++ + return &solutionv0.CreateResponse{}, nil +} + +func (s *recordingSolutionServer) Package(context.Context, *solutionv0.PackageRequest) (*solutionv0.PackageResponse, error) { + s.handled["Package"]++ + return &solutionv0.PackageResponse{}, nil +} + +func TestEnforcingClientInterceptorDeniesOverCeilingBeforeTheWire(t *testing.T) { + server := &recordingSolutionServer{handled: map[string]int{}} + listener := bufconn.Listen(1 << 20) + grpcServer := grpc.NewServer() + solutionv0.RegisterSolutionServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(grpcServer.Stop) + + conn, err := grpc.NewClient( + "passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(solution.EnforcingClientInterceptor()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client := solutionv0.NewSolutionClient(conn) + + ceiling := solution.Ceiling{ + Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, + Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE, + } + ctx := solution.WithCeiling(context.Background(), ceiling) + + // Create is at the ceiling — admitted and reaches the server. + _, err = client.Create(ctx, &solutionv0.CreateRequest{}) + require.NoError(t, err) + require.Equal(t, 1, server.handled["Create"]) + + // Package exceeds the ceiling — denied before crossing the wire. + _, err = client.Package(ctx, &solutionv0.PackageRequest{}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Equal(t, 0, server.handled["Package"]) + + // A Solution RPC with no ceiling on its context fails closed — the same + // long-lived connection is reused, so a missing per-call ceiling must deny. + _, err = client.Create(context.Background(), &solutionv0.CreateRequest{}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Equal(t, 1, server.handled["Create"]) +} + +func TestEnforcingClientInterceptorPassesThroughNonSolutionCalls(t *testing.T) { + interceptor := solution.EnforcingClientInterceptor() + invoked := false + invoker := func(context.Context, string, any, any, *grpc.ClientConn, ...grpc.CallOption) error { + invoked = true + return nil + } + // No ceiling on the context, yet a non-Solution method must still dispatch. + err := interceptor(context.Background(), "/grpc.health.v1.Health/Check", nil, nil, nil, invoker) + require.NoError(t, err) + require.True(t, invoked) +} + +func TestSolutionPolicyEnumsAreOrderedAsCeilings(t *testing.T) { + networks := solutionv0.SolutionNetworkMode(0).Descriptor() + require.Equal(t, []protoreflect.Name{ + "SOLUTION_NETWORK_MODE_UNSPECIFIED", + "SOLUTION_NETWORK_MODE_OFFLINE", + "SOLUTION_NETWORK_MODE_REGISTRY_WRITE", + }, enumNames(networks.Values())) + + effects := solutionv0.SolutionEffect(0).Descriptor() + require.Equal(t, []protoreflect.Name{ + "SOLUTION_EFFECT_UNSPECIFIED", + "SOLUTION_EFFECT_READ_ONLY", + "SOLUTION_EFFECT_LOCAL_WRITE", + "SOLUTION_EFFECT_REGISTRY_WRITE", + }, enumNames(effects.Values())) +} + +func enumNames(values protoreflect.EnumValueDescriptors) []protoreflect.Name { + out := make([]protoreflect.Name, values.Len()) + for i := 0; i < values.Len(); i++ { + out[i] = values.Get(i).Name() + } + return out +} From a3b7fd244dc439cd8cd5cc6f996691176803d422 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 18:07:38 -0400 Subject: [PATCH 2/6] fix: give the solution ceiling a provenance chokepoint (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the enforcement gate surfaced that its security value rested on unconstrained caller discipline: - The ceiling was a struct with exported fields, so any caller could hand- assemble an over-broad or incoherent ceiling (e.g. registry network with read-only effect) and silently widen its own privilege — the decorative- annotation smell one step removed. Unexport the fields and expose only named operation-intent constructors (CeilingInspect/CeilingScaffold/CeilingPublish), so a caller declares the operation it performs and the intent→ceiling mapping is a single audited, tested chokepoint. Incoherent ceilings are now unconstructible. - The no-ceiling denial ("no operation ceiling on context") read like an auth failure and hid the real requirement. Make the message name solution.WithCeiling as the fix so the first integrator debugs the ceiling, not the token. - Match the test dial to production by using WithChainUnaryInterceptor. Adds TestOperationCeilingsAdmitExactlyTheirRPCs pinning that each operation ceiling admits exactly its intended RPC set. Co-Authored-By: Claude Opus 4.8 --- solution/policy.go | 67 +++++++++++++++++++++++++++-------- solution/policy_test.go | 78 +++++++++++++++++++++++++++++------------ 2 files changed, 108 insertions(+), 37 deletions(-) diff --git a/solution/policy.go b/solution/policy.go index 4b28acb5..b97b8c10 100644 --- a/solution/policy.go +++ b/solution/policy.go @@ -32,19 +32,55 @@ const solutionServiceName protoreflect.FullName = "codefly.services.solution.v0. // Ceiling is the maximum network reach and state effect a host admits for one // operation. A method is admitted only when both of its declared policy fields // are at or below the ceiling. +// +// The fields are unexported and a ceiling is obtained only through the named +// operation constructors below. That gives the ceiling a provenance: a caller +// declares the operation it is performing (inspect/scaffold/publish) rather than +// hand-assembling bounds, so it cannot silently widen its own privilege with a +// struct literal, and the interceptor can never receive an incoherent ceiling +// (e.g. registry network with only read-only effect). The intent→ceiling mapping +// lives here as the single audited chokepoint. type Ceiling struct { - Network solutionv0.SolutionNetworkMode - Effect solutionv0.SolutionEffect + network solutionv0.SolutionNetworkMode + effect solutionv0.SolutionEffect +} + +// CeilingInspect admits only offline, read-only RPCs — GetSolutionInformation. +// It is the ceiling for an operation that reads a solution executor's +// advertisement without invoking any lifecycle mutation. +func CeilingInspect() Ceiling { + return Ceiling{ + network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, + effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY, + } +} + +// CeilingScaffold admits offline local-filesystem RPCs — Create, Update, and +// Render — but not Package's registry push. +func CeilingScaffold() Ceiling { + return Ceiling{ + network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, + effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE, + } +} + +// CeilingPublish admits every Solution RPC, including Package's registry push. +func CeilingPublish() Ceiling { + return Ceiling{ + network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, + effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE, + } } type ceilingContextKey struct{} // WithCeiling stamps the ceiling admitted for the current operation onto a -// context. The host sets it per call because one solution-agent connection is -// long-lived and reused across operations (see agents/manager.loader: -// AgentConn.GRPCConn), so the ceiling belongs to the call, not the dial. A -// Solution RPC issued without a ceiling in its context is refused by -// EnforcingClientInterceptor. +// context. Pass a ceiling from one of the operation constructors +// (CeilingInspect/CeilingScaffold/CeilingPublish). The host sets it per call +// because one solution-agent connection is long-lived and reused across +// operations (see agents/manager.loader: AgentConn.GRPCConn), so the ceiling +// belongs to the call, not the dial. A Solution RPC issued without a ceiling in +// its context is refused by EnforcingClientInterceptor. func WithCeiling(ctx context.Context, ceiling Ceiling) context.Context { return context.WithValue(ctx, ceilingContextKey{}, ceiling) } @@ -85,17 +121,17 @@ func Admits(policy *solutionv0.SolutionMethodPolicy, ceiling Ceiling) error { if effect == solutionv0.SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED { return fmt.Errorf("effect is unspecified") } - if ceiling.Network == solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED { + if ceiling.network == solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED { return fmt.Errorf("ceiling network mode is unspecified") } - if ceiling.Effect == solutionv0.SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED { + if ceiling.effect == solutionv0.SolutionEffect_SOLUTION_EFFECT_UNSPECIFIED { return fmt.Errorf("ceiling effect is unspecified") } - if network > ceiling.Network { - return fmt.Errorf("network mode %s exceeds ceiling %s", network, ceiling.Network) + if network > ceiling.network { + return fmt.Errorf("network mode %s exceeds ceiling %s", network, ceiling.network) } - if effect > ceiling.Effect { - return fmt.Errorf("effect %s exceeds ceiling %s", effect, ceiling.Effect) + if effect > ceiling.effect { + return fmt.Errorf("effect %s exceeds ceiling %s", effect, ceiling.effect) } return nil } @@ -120,7 +156,10 @@ func EnforcingClientInterceptor() grpc.UnaryClientInterceptor { } ceiling, ok := CeilingFrom(ctx) if !ok { - return status.Errorf(codes.PermissionDenied, "solution method %s denied: no operation ceiling on context", method) + return status.Errorf(codes.PermissionDenied, + "solution method %s denied: caller stamped no operation ceiling on the context; "+ + "the host must declare one with solution.WithCeiling (e.g. CeilingInspect) before dispatch", + method) } if err := Admits(policy, ceiling); err != nil { return status.Errorf(codes.PermissionDenied, "solution method %s denied: %v", method, err) diff --git a/solution/policy_test.go b/solution/policy_test.go index 5d696ae0..c67e4a1d 100644 --- a/solution/policy_test.go +++ b/solution/policy_test.go @@ -89,30 +89,66 @@ func TestPolicyForResolvesByFullMethodName(t *testing.T) { } func TestAdmitsEnforcesBothAxesAndFailsClosed(t *testing.T) { - localWriteCeiling := solution.Ceiling{ - Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, - Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE, - } - registryCeiling := solution.Ceiling{ - Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, - Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE, - } - create := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE} pkg := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE} - require.NoError(t, solution.Admits(create, localWriteCeiling)) - require.NoError(t, solution.Admits(create, registryCeiling)) - // Package exceeds a local-write ceiling on both axes. - require.Error(t, solution.Admits(pkg, localWriteCeiling)) - require.NoError(t, solution.Admits(pkg, registryCeiling)) + require.NoError(t, solution.Admits(create, solution.CeilingScaffold())) + require.NoError(t, solution.Admits(create, solution.CeilingPublish())) + // Package exceeds a scaffold ceiling on both axes. + require.Error(t, solution.Admits(pkg, solution.CeilingScaffold())) + require.NoError(t, solution.Admits(pkg, solution.CeilingPublish())) - // Fail closed: nil policy, unspecified policy field, unspecified ceiling field. - require.Error(t, solution.Admits(nil, registryCeiling)) - require.Error(t, solution.Admits(&solutionv0.SolutionMethodPolicy{Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY}, registryCeiling)) + // Fail closed: nil policy, unspecified policy field, zero-value ceiling. + require.Error(t, solution.Admits(nil, solution.CeilingPublish())) + require.Error(t, solution.Admits(&solutionv0.SolutionMethodPolicy{Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY}, solution.CeilingPublish())) require.Error(t, solution.Admits(create, solution.Ceiling{})) } +// TestOperationCeilingsAdmitExactlyTheirRPCs pins the provenance chokepoint: each +// named operation ceiling admits exactly the Solution RPCs that operation is +// allowed to invoke and denies the rest. A drift in the intent→ceiling mapping +// (or an RPC's declared policy) surfaces here rather than silently widening what +// an operation can dispatch. +func TestOperationCeilingsAdmitExactlyTheirRPCs(t *testing.T) { + rpcPolicy := func(fullMethod string) *solutionv0.SolutionMethodPolicy { + policy, isSolution := solution.PolicyFor(fullMethod) + require.True(t, isSolution, fullMethod) + return policy + } + all := map[string]*solutionv0.SolutionMethodPolicy{ + "GetSolutionInformation": rpcPolicy(solutionv0.Solution_GetSolutionInformation_FullMethodName), + "Create": rpcPolicy(solutionv0.Solution_Create_FullMethodName), + "Update": rpcPolicy(solutionv0.Solution_Update_FullMethodName), + "Package": rpcPolicy(solutionv0.Solution_Package_FullMethodName), + "Render": rpcPolicy(solutionv0.Solution_Render_FullMethodName), + } + cases := []struct { + name string + ceiling solution.Ceiling + admits map[string]bool + }{ + {"inspect", solution.CeilingInspect(), map[string]bool{"GetSolutionInformation": true}}, + {"scaffold", solution.CeilingScaffold(), map[string]bool{ + "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, + }}, + {"publish", solution.CeilingPublish(), map[string]bool{ + "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, "Package": true, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for rpc, policy := range all { + err := solution.Admits(policy, tc.ceiling) + if tc.admits[rpc] { + require.NoError(t, err, "%s must admit %s", tc.name, rpc) + } else { + require.Error(t, err, "%s must deny %s", tc.name, rpc) + } + } + }) + } +} + // recordingSolutionServer is a real Solution server that records which RPCs it // handled so a test can assert a denied call never reached it. type recordingSolutionServer struct { @@ -142,18 +178,14 @@ func TestEnforcingClientInterceptorDeniesOverCeilingBeforeTheWire(t *testing.T) "passthrough:///bufconn", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUnaryInterceptor(solution.EnforcingClientInterceptor()), + grpc.WithChainUnaryInterceptor(solution.EnforcingClientInterceptor()), ) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) client := solutionv0.NewSolutionClient(conn) - ceiling := solution.Ceiling{ - Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, - Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE, - } - ctx := solution.WithCeiling(context.Background(), ceiling) + ctx := solution.WithCeiling(context.Background(), solution.CeilingScaffold()) // Create is at the ceiling — admitted and reaches the server. _, err = client.Create(ctx, &solutionv0.CreateRequest{}) From 5f7f0f3e9173a48701604bd276f39a506a87958b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 18:17:44 -0400 Subject: [PATCH 3/6] refactor: unexport ceilingFrom, now dead external surface (#289) Unexporting Ceiling's fields left CeilingFrom exported but useless to any external caller: it is consumed only by the interceptor, and a caller can no longer read the returned ceiling's bounds. Unexport it so the package surface reflects the single-chokepoint design (only WithCeiling is public). Co-Authored-By: Claude Opus 4.8 --- solution/policy.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/solution/policy.go b/solution/policy.go index b97b8c10..2dce05bb 100644 --- a/solution/policy.go +++ b/solution/policy.go @@ -85,8 +85,11 @@ func WithCeiling(ctx context.Context, ceiling Ceiling) context.Context { return context.WithValue(ctx, ceilingContextKey{}, ceiling) } -// CeilingFrom returns the ceiling stamped on the context, if any. -func CeilingFrom(ctx context.Context) (Ceiling, bool) { +// ceilingFrom returns the ceiling stamped on the context, if any. It is +// unexported because only the interceptor consumes a ceiling; a caller cannot +// read a Ceiling's bounds (its fields are unexported), so there is nothing for +// external code to do with the value. +func ceilingFrom(ctx context.Context) (Ceiling, bool) { ceiling, ok := ctx.Value(ceilingContextKey{}).(Ceiling) return ceiling, ok } @@ -154,7 +157,7 @@ func EnforcingClientInterceptor() grpc.UnaryClientInterceptor { if !isSolution { return invoker(ctx, method, req, reply, cc, opts...) } - ceiling, ok := CeilingFrom(ctx) + ceiling, ok := ceilingFrom(ctx) if !ok { return status.Errorf(codes.PermissionDenied, "solution method %s denied: caller stamped no operation ceiling on the context; "+ From 3345819766ee5fd9a06e7754c06d7a72f37042eb Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 18:26:13 -0400 Subject: [PATCH 4/6] test: assert the no-ceiling denial names its remedy (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finding-2 fix changed the denial message to name solution.WithCeiling so a missing ceiling is not mistaken for an auth failure, but the test asserted only the PermissionDenied code — a regression to a bare, confusing message would have passed. Assert the message contains the remedy, which the original message did not, so the behavior the fix introduced is now guarded. Co-Authored-By: Claude Opus 4.8 --- solution/policy_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/solution/policy_test.go b/solution/policy_test.go index c67e4a1d..2b9b8e57 100644 --- a/solution/policy_test.go +++ b/solution/policy_test.go @@ -202,6 +202,9 @@ func TestEnforcingClientInterceptorDeniesOverCeilingBeforeTheWire(t *testing.T) _, err = client.Create(context.Background(), &solutionv0.CreateRequest{}) require.Equal(t, codes.PermissionDenied, status.Code(err)) require.Equal(t, 1, server.handled["Create"]) + // The denial must name the remedy so it is not mistaken for an auth failure: + // the missing ceiling, not the token, is what the caller must fix. + require.Contains(t, status.Convert(err).Message(), "solution.WithCeiling") } func TestEnforcingClientInterceptorPassesThroughNonSolutionCalls(t *testing.T) { From 32e482e518f14f974a3ce34f3723c9a8ab83c8ad Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 18:45:59 -0400 Subject: [PATCH 5/6] fix: address remaining solution-policy review items (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review items the prior rounds had deferred, now fixed: - Ceiling provenance / dead public surface: PolicyFor and Admits were exported but had no production caller — only the interceptor and tests used them, the same dead-surface class as CeilingFrom. Unexport both to policyFor/admits and move their unit tests into an internal (package solution) test file, so the public surface is just the interceptor, the ceiling constructors, WithCeiling, and the typed Client. - Read-only calls no longer require an explicit ceiling: a missing ceiling now defaults to the least-privilege ceiling (CeilingInspect) instead of denying every call. A caller that never declared its operation can still read a solution executor's advertisement, while every mutating RPC stays fail-closed until the host declares a higher ceiling. This is least-privilege-by-default, not a permissive default — the minimum real ceiling, not "allow". - Unforgeable ceiling obligation: add the typed solution.Client whose every method takes a Ceiling argument, so host code cannot dispatch a Solution RPC without declaring the operation it performs — the obligation is type-level rather than a convention a caller can forget. Tests: internal policyFor/admits/ceiling unit tests; wire tests now prove the least-privilege default (read admitted unstamped, mutation denied with the remedy named) and that the typed Client enforces the ceiling per call. Co-Authored-By: Claude Opus 4.8 --- solution/policy.go | 93 +++++++++++++++++----- solution/policy_internal_test.go | 82 ++++++++++++++++++++ solution/policy_test.go | 127 ++++++++++++------------------- 3 files changed, 205 insertions(+), 97 deletions(-) create mode 100644 solution/policy_internal_test.go diff --git a/solution/policy.go b/solution/policy.go index 2dce05bb..003524b5 100644 --- a/solution/policy.go +++ b/solution/policy.go @@ -72,6 +72,47 @@ func CeilingPublish() Ceiling { } } +// Client is the host-side Solution client that makes the operation ceiling a +// required argument of every call, so host code cannot dispatch a Solution RPC +// without declaring the operation it performs — the obligation is type-level, not +// a convention a caller can forget. Each method stamps the ceiling with +// WithCeiling and delegates to the generated client; EnforcingClientInterceptor, +// installed on the connection, is what actually gates the call. +type Client struct { + inner solutionv0.SolutionClient +} + +// NewClient wraps a connection whose dial installed EnforcingClientInterceptor +// (every agent connection from agents/manager.Load does). +func NewClient(conn grpc.ClientConnInterface) *Client { + return &Client{inner: solutionv0.NewSolutionClient(conn)} +} + +// GetSolutionInformation reads a solution executor's advertisement. +func (c *Client) GetSolutionInformation(ctx context.Context, ceiling Ceiling, in *solutionv0.GetSolutionInformationRequest, opts ...grpc.CallOption) (*solutionv0.GetSolutionInformationResponse, error) { + return c.inner.GetSolutionInformation(WithCeiling(ctx, ceiling), in, opts...) +} + +// Create scaffolds a new solution into a destination directory. +func (c *Client) Create(ctx context.Context, ceiling Ceiling, in *solutionv0.CreateRequest, opts ...grpc.CallOption) (*solutionv0.CreateResponse, error) { + return c.inner.Create(WithCeiling(ctx, ceiling), in, opts...) +} + +// Update reconciles an existing solution source with the executor's template. +func (c *Client) Update(ctx context.Context, ceiling Ceiling, in *solutionv0.UpdateRequest, opts ...grpc.CallOption) (*solutionv0.UpdateResponse, error) { + return c.inner.Update(WithCeiling(ctx, ceiling), in, opts...) +} + +// Package builds an OCI artifact from a solution source directory and pushes it. +func (c *Client) Package(ctx context.Context, ceiling Ceiling, in *solutionv0.PackageRequest, opts ...grpc.CallOption) (*solutionv0.PackageResponse, error) { + return c.inner.Package(WithCeiling(ctx, ceiling), in, opts...) +} + +// Render renders a packaged solution's manifests into a gitops destination. +func (c *Client) Render(ctx context.Context, ceiling Ceiling, in *solutionv0.RenderRequest, opts ...grpc.CallOption) (*solutionv0.RenderResponse, error) { + return c.inner.Render(WithCeiling(ctx, ceiling), in, opts...) +} + type ceilingContextKey struct{} // WithCeiling stamps the ceiling admitted for the current operation onto a @@ -79,8 +120,9 @@ type ceilingContextKey struct{} // (CeilingInspect/CeilingScaffold/CeilingPublish). The host sets it per call // because one solution-agent connection is long-lived and reused across // operations (see agents/manager.loader: AgentConn.GRPCConn), so the ceiling -// belongs to the call, not the dial. A Solution RPC issued without a ceiling in -// its context is refused by EnforcingClientInterceptor. +// belongs to the call, not the dial. A Solution RPC issued without a ceiling is +// gated against the least-privilege ceiling by EnforcingClientInterceptor, so +// only the read-only advertisement call succeeds unstamped. func WithCeiling(ctx context.Context, ceiling Ceiling) context.Context { return context.WithValue(ctx, ceilingContextKey{}, ceiling) } @@ -94,11 +136,13 @@ func ceilingFrom(ctx context.Context) (Ceiling, bool) { return ceiling, ok } -// PolicyFor returns the declared method policy for a full gRPC method name +// policyFor returns the declared method policy for a full gRPC method name // (e.g. "/codefly.services.solution.v0.Solution/Package"). The second result // reports whether the method belongs to the Solution service; a Solution method -// with no annotation returns (nil, true) so callers fail closed. -func PolicyFor(fullMethod string) (*solutionv0.SolutionMethodPolicy, bool) { +// with no annotation returns (nil, true) so callers fail closed. It is +// unexported: only the interceptor consults policies; a host uses the interceptor +// plus WithCeiling, never the policy lookup directly. +func policyFor(fullMethod string) (*solutionv0.SolutionMethodPolicy, bool) { method := methodDescriptor(fullMethod) if method == nil { return nil, false @@ -110,10 +154,11 @@ func PolicyFor(fullMethod string) (*solutionv0.SolutionMethodPolicy, bool) { return proto.GetExtension(options, solutionv0.E_SolutionMethodPolicy).(*solutionv0.SolutionMethodPolicy), true } -// Admits reports whether a method policy is within the ceiling. It fails closed: +// admits reports whether a method policy is within the ceiling. It fails closed: // a nil policy, an unspecified policy field, or an unspecified ceiling field is -// never admitted. -func Admits(policy *solutionv0.SolutionMethodPolicy, ceiling Ceiling) error { +// never admitted. It is unexported for the same reason as policyFor — it is the +// interceptor's internal check, not host-facing API. +func admits(policy *solutionv0.SolutionMethodPolicy, ceiling Ceiling) error { if policy == nil { return fmt.Errorf("no method policy declared") } @@ -143,9 +188,16 @@ func Admits(policy *solutionv0.SolutionMethodPolicy, ceiling Ceiling) error { // interceptor installed on every agent connection (agents/manager.loader). For // each outgoing Solution RPC it reads the declared policy and the ceiling // stamped on the call context (see WithCeiling) and refuses to dispatch a call -// whose declared ceiling exceeds the admitted ceiling, or that carries no -// ceiling at all. Calls to services other than Solution pass through untouched, -// so installing it universally does not affect non-solution agents. +// whose declared network or effect exceeds the admitted ceiling. Calls to +// services other than Solution pass through untouched, so installing it +// universally does not affect non-solution agents. +// +// A call with no ceiling on its context is admitted against the least-privilege +// ceiling (CeilingInspect): a caller that never declared its operation may still +// read a solution executor's advertisement, but every mutating RPC is refused +// until the host declares a higher ceiling with WithCeiling. Defaulting to the +// minimum — rather than denying even the harmless read — keeps inspection +// ergonomic while staying fail-closed for every effectful RPC. // // It covers unary RPCs only, which is complete because the Solution contract is // unary-only — an invariant TestSolutionContractIsUnaryOnly guards. A streaming @@ -153,18 +205,21 @@ func Admits(policy *solutionv0.SolutionMethodPolicy, ceiling Ceiling) error { // dispatch unchecked. func EnforcingClientInterceptor() grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - policy, isSolution := PolicyFor(method) + policy, isSolution := policyFor(method) if !isSolution { return invoker(ctx, method, req, reply, cc, opts...) } - ceiling, ok := ceilingFrom(ctx) - if !ok { - return status.Errorf(codes.PermissionDenied, - "solution method %s denied: caller stamped no operation ceiling on the context; "+ - "the host must declare one with solution.WithCeiling (e.g. CeilingInspect) before dispatch", - method) + ceiling, explicit := ceilingFrom(ctx) + if !explicit { + ceiling = CeilingInspect() } - if err := Admits(policy, ceiling); err != nil { + if err := admits(policy, ceiling); err != nil { + if !explicit { + return status.Errorf(codes.PermissionDenied, + "solution method %s denied under the default least-privilege ceiling: %v; "+ + "declare this operation's ceiling with solution.WithCeiling", + method, err) + } return status.Errorf(codes.PermissionDenied, "solution method %s denied: %v", method, err) } return invoker(ctx, method, req, reply, cc, opts...) diff --git a/solution/policy_internal_test.go b/solution/policy_internal_test.go new file mode 100644 index 00000000..9b006448 --- /dev/null +++ b/solution/policy_internal_test.go @@ -0,0 +1,82 @@ +package solution + +import ( + "testing" + + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" + "github.com/stretchr/testify/require" +) + +func TestPolicyForResolvesByFullMethodName(t *testing.T) { + policy, isSolution := policyFor(solutionv0.Solution_Package_FullMethodName) + require.True(t, isSolution) + require.Equal(t, solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, policy.GetNetwork()) + require.Equal(t, solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE, policy.GetEffect()) + + _, isSolution = policyFor("/grpc.health.v1.Health/Check") + require.False(t, isSolution) + + _, isSolution = policyFor("/codefly.services.solution.v0.Solution/DoesNotExist") + require.False(t, isSolution) +} + +func TestAdmitsEnforcesBothAxesAndFailsClosed(t *testing.T) { + create := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE} + pkg := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE} + + require.NoError(t, admits(create, CeilingScaffold())) + require.NoError(t, admits(create, CeilingPublish())) + // Package exceeds a scaffold ceiling on both axes. + require.Error(t, admits(pkg, CeilingScaffold())) + require.NoError(t, admits(pkg, CeilingPublish())) + + // Fail closed: nil policy, unspecified policy field, zero-value ceiling. + require.Error(t, admits(nil, CeilingPublish())) + require.Error(t, admits(&solutionv0.SolutionMethodPolicy{Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY}, CeilingPublish())) + require.Error(t, admits(create, Ceiling{})) +} + +// TestOperationCeilingsAdmitExactlyTheirRPCs pins the provenance chokepoint: each +// named operation ceiling admits exactly the Solution RPCs that operation is +// allowed to invoke and denies the rest. A drift in the intent→ceiling mapping +// (or an RPC's declared policy) surfaces here rather than silently widening what +// an operation can dispatch. +func TestOperationCeilingsAdmitExactlyTheirRPCs(t *testing.T) { + rpcPolicy := func(fullMethod string) *solutionv0.SolutionMethodPolicy { + policy, isSolution := policyFor(fullMethod) + require.True(t, isSolution, fullMethod) + return policy + } + all := map[string]*solutionv0.SolutionMethodPolicy{ + "GetSolutionInformation": rpcPolicy(solutionv0.Solution_GetSolutionInformation_FullMethodName), + "Create": rpcPolicy(solutionv0.Solution_Create_FullMethodName), + "Update": rpcPolicy(solutionv0.Solution_Update_FullMethodName), + "Package": rpcPolicy(solutionv0.Solution_Package_FullMethodName), + "Render": rpcPolicy(solutionv0.Solution_Render_FullMethodName), + } + cases := []struct { + name string + ceiling Ceiling + admits map[string]bool + }{ + {"inspect", CeilingInspect(), map[string]bool{"GetSolutionInformation": true}}, + {"scaffold", CeilingScaffold(), map[string]bool{ + "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, + }}, + {"publish", CeilingPublish(), map[string]bool{ + "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, "Package": true, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for rpc, policy := range all { + err := admits(policy, tc.ceiling) + if tc.admits[rpc] { + require.NoError(t, err, "%s must admit %s", tc.name, rpc) + } else { + require.Error(t, err, "%s must deny %s", tc.name, rpc) + } + } + }) + } +} diff --git a/solution/policy_test.go b/solution/policy_test.go index 2b9b8e57..f50c6e70 100644 --- a/solution/policy_test.go +++ b/solution/policy_test.go @@ -75,80 +75,6 @@ func TestSolutionMethodPolicyAxesAreCoherent(t *testing.T) { } } -func TestPolicyForResolvesByFullMethodName(t *testing.T) { - policy, isSolution := solution.PolicyFor(solutionv0.Solution_Package_FullMethodName) - require.True(t, isSolution) - require.Equal(t, solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, policy.GetNetwork()) - require.Equal(t, solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE, policy.GetEffect()) - - _, isSolution = solution.PolicyFor("/grpc.health.v1.Health/Check") - require.False(t, isSolution) - - _, isSolution = solution.PolicyFor("/codefly.services.solution.v0.Solution/DoesNotExist") - require.False(t, isSolution) -} - -func TestAdmitsEnforcesBothAxesAndFailsClosed(t *testing.T) { - create := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE} - pkg := &solutionv0.SolutionMethodPolicy{Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE} - - require.NoError(t, solution.Admits(create, solution.CeilingScaffold())) - require.NoError(t, solution.Admits(create, solution.CeilingPublish())) - // Package exceeds a scaffold ceiling on both axes. - require.Error(t, solution.Admits(pkg, solution.CeilingScaffold())) - require.NoError(t, solution.Admits(pkg, solution.CeilingPublish())) - - // Fail closed: nil policy, unspecified policy field, zero-value ceiling. - require.Error(t, solution.Admits(nil, solution.CeilingPublish())) - require.Error(t, solution.Admits(&solutionv0.SolutionMethodPolicy{Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_READ_ONLY}, solution.CeilingPublish())) - require.Error(t, solution.Admits(create, solution.Ceiling{})) -} - -// TestOperationCeilingsAdmitExactlyTheirRPCs pins the provenance chokepoint: each -// named operation ceiling admits exactly the Solution RPCs that operation is -// allowed to invoke and denies the rest. A drift in the intent→ceiling mapping -// (or an RPC's declared policy) surfaces here rather than silently widening what -// an operation can dispatch. -func TestOperationCeilingsAdmitExactlyTheirRPCs(t *testing.T) { - rpcPolicy := func(fullMethod string) *solutionv0.SolutionMethodPolicy { - policy, isSolution := solution.PolicyFor(fullMethod) - require.True(t, isSolution, fullMethod) - return policy - } - all := map[string]*solutionv0.SolutionMethodPolicy{ - "GetSolutionInformation": rpcPolicy(solutionv0.Solution_GetSolutionInformation_FullMethodName), - "Create": rpcPolicy(solutionv0.Solution_Create_FullMethodName), - "Update": rpcPolicy(solutionv0.Solution_Update_FullMethodName), - "Package": rpcPolicy(solutionv0.Solution_Package_FullMethodName), - "Render": rpcPolicy(solutionv0.Solution_Render_FullMethodName), - } - cases := []struct { - name string - ceiling solution.Ceiling - admits map[string]bool - }{ - {"inspect", solution.CeilingInspect(), map[string]bool{"GetSolutionInformation": true}}, - {"scaffold", solution.CeilingScaffold(), map[string]bool{ - "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, - }}, - {"publish", solution.CeilingPublish(), map[string]bool{ - "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, "Package": true, - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - for rpc, policy := range all { - err := solution.Admits(policy, tc.ceiling) - if tc.admits[rpc] { - require.NoError(t, err, "%s must admit %s", tc.name, rpc) - } else { - require.Error(t, err, "%s must deny %s", tc.name, rpc) - } - } - }) - } -} - // recordingSolutionServer is a real Solution server that records which RPCs it // handled so a test can assert a denied call never reached it. type recordingSolutionServer struct { @@ -156,6 +82,11 @@ type recordingSolutionServer struct { handled map[string]int } +func (s *recordingSolutionServer) GetSolutionInformation(context.Context, *solutionv0.GetSolutionInformationRequest) (*solutionv0.GetSolutionInformationResponse, error) { + s.handled["GetSolutionInformation"]++ + return &solutionv0.GetSolutionInformationResponse{}, nil +} + func (s *recordingSolutionServer) Create(context.Context, *solutionv0.CreateRequest) (*solutionv0.CreateResponse, error) { s.handled["Create"]++ return &solutionv0.CreateResponse{}, nil @@ -197,16 +128,56 @@ func TestEnforcingClientInterceptorDeniesOverCeilingBeforeTheWire(t *testing.T) require.Equal(t, codes.PermissionDenied, status.Code(err)) require.Equal(t, 0, server.handled["Package"]) - // A Solution RPC with no ceiling on its context fails closed — the same - // long-lived connection is reused, so a missing per-call ceiling must deny. + // No ceiling on the context defaults to least privilege: the read-only + // advertisement call is admitted and reaches the server... + _, err = client.GetSolutionInformation(context.Background(), &solutionv0.GetSolutionInformationRequest{}) + require.NoError(t, err) + require.Equal(t, 1, server.handled["GetSolutionInformation"]) + + // ...but a mutating RPC without a ceiling is still denied before the wire, + // and the denial names the remedy so it is not mistaken for an auth failure: + // the missing ceiling, not the token, is what the caller must fix. _, err = client.Create(context.Background(), &solutionv0.CreateRequest{}) require.Equal(t, codes.PermissionDenied, status.Code(err)) require.Equal(t, 1, server.handled["Create"]) - // The denial must name the remedy so it is not mistaken for an auth failure: - // the missing ceiling, not the token, is what the caller must fix. require.Contains(t, status.Convert(err).Message(), "solution.WithCeiling") } +// TestClientRequiresCeilingPerCall proves the typed Client makes the ceiling a +// mandatory, unforgeable argument: the same low ceiling admits Create and denies +// Package over the wire, so a caller cannot dispatch a Solution RPC without +// declaring the operation it performs. +func TestClientRequiresCeilingPerCall(t *testing.T) { + server := &recordingSolutionServer{handled: map[string]int{}} + listener := bufconn.Listen(1 << 20) + grpcServer := grpc.NewServer() + solutionv0.RegisterSolutionServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(grpcServer.Stop) + + conn, err := grpc.NewClient( + "passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithChainUnaryInterceptor(solution.EnforcingClientInterceptor()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client := solution.NewClient(conn) + + // Scaffold ceiling admits Create... + _, err = client.Create(context.Background(), solution.CeilingScaffold(), &solutionv0.CreateRequest{}) + require.NoError(t, err) + require.Equal(t, 1, server.handled["Create"]) + + // ...and denies Package, which the wrapper cannot dispatch without a + // publish-level ceiling. + _, err = client.Package(context.Background(), solution.CeilingScaffold(), &solutionv0.PackageRequest{}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Equal(t, 0, server.handled["Package"]) +} + func TestEnforcingClientInterceptorPassesThroughNonSolutionCalls(t *testing.T) { interceptor := solution.EnforcingClientInterceptor() invoked := false From 85699feaec74c74b47659121fe9db7ed9b55b141 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 18:50:38 -0400 Subject: [PATCH 6/6] fix: annotate Render as a registry read, not offline (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderRequest carries only artifact_reference (a string OCI reference) and a local destination — no artifact bytes — and a solution executor's network is unmediated (Package pushes directly). So Render must pull the packaged artifact from the registry itself: its true maximum network reach is a registry read, not OFFLINE. Annotating it OFFLINE understated it and left a real hole — an offline ceiling admitted a Render that dials the network. The two-value network enum could not express this, so add SOLUTION_NETWORK_MODE_REGISTRY_READ between OFFLINE and REGISTRY_WRITE (a wire-breaking renumber, safe: the contract has no consumers) and re-annotate Render's network as REGISTRY_READ. Add CeilingRender as the least-privilege ceiling that admits Render's pull without admitting Package's push; CeilingScaffold (offline) no longer admits Render, which is the correct consequence — a purely offline operation must not trigger a registry pull. Tests updated: contract policy pin, enum ordering, and the operation-ceiling matrix (Render moves from scaffold to the new render tier); the coherence-test comment now describes the registry-write pairing it actually guards. Co-Authored-By: Claude Opus 4.8 --- .../services/solution/v0/solution.pb.go | 25 ++++++++++++------- .../services/solution/v0/solution_grpc.pb.go | 6 +++-- .../solution/v0/v0connect/solution.connect.go | 6 +++-- .../services/solution/v0/solution.proto | 13 +++++++--- solution/policy.go | 14 +++++++++-- solution/policy_internal_test.go | 3 +++ solution/policy_test.go | 13 +++++----- 7 files changed, 55 insertions(+), 25 deletions(-) diff --git a/generated/go/codefly/services/solution/v0/solution.pb.go b/generated/go/codefly/services/solution/v0/solution.pb.go index 76ccba76..136f091a 100644 --- a/generated/go/codefly/services/solution/v0/solution.pb.go +++ b/generated/go/codefly/services/solution/v0/solution.pb.go @@ -27,7 +27,9 @@ const ( // SolutionNetworkMode declares the maximum network reach an RPC may use. // Values are ordered by increasing reach so a host ceiling admits every -// mode at or below it. +// mode at or below it. A solution executor's network is unmediated (unlike a +// provider, it dials directly): Package pushes the OCI artifact and Render +// pulls it from artifact_reference, so Render is a registry read, not offline. type SolutionNetworkMode int32 const ( @@ -35,8 +37,10 @@ const ( SolutionNetworkMode_SOLUTION_NETWORK_MODE_UNSPECIFIED SolutionNetworkMode = 0 // SOLUTION_NETWORK_MODE_OFFLINE forbids all network access. SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE SolutionNetworkMode = 1 + // SOLUTION_NETWORK_MODE_REGISTRY_READ permits pulling the packaged OCI artifact. + SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_READ SolutionNetworkMode = 2 // SOLUTION_NETWORK_MODE_REGISTRY_WRITE permits pushing to an OCI registry. - SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE SolutionNetworkMode = 2 + SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE SolutionNetworkMode = 3 ) // Enum value maps for SolutionNetworkMode. @@ -44,12 +48,14 @@ var ( SolutionNetworkMode_name = map[int32]string{ 0: "SOLUTION_NETWORK_MODE_UNSPECIFIED", 1: "SOLUTION_NETWORK_MODE_OFFLINE", - 2: "SOLUTION_NETWORK_MODE_REGISTRY_WRITE", + 2: "SOLUTION_NETWORK_MODE_REGISTRY_READ", + 3: "SOLUTION_NETWORK_MODE_REGISTRY_WRITE", } SolutionNetworkMode_value = map[string]int32{ "SOLUTION_NETWORK_MODE_UNSPECIFIED": 0, "SOLUTION_NETWORK_MODE_OFFLINE": 1, - "SOLUTION_NETWORK_MODE_REGISTRY_WRITE": 2, + "SOLUTION_NETWORK_MODE_REGISTRY_READ": 2, + "SOLUTION_NETWORK_MODE_REGISTRY_WRITE": 3, } ) @@ -1108,11 +1114,12 @@ const file_codefly_services_solution_v0_solution_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x92\x01\n" + "\x0eRenderResponse\x120\n" + "\x0erendered_paths\x18\x01 \x03(\tB\t\xbaH\x06\x92\x01\x03\x10\x90NR\rrenderedPaths\x12N\n" + - "\vdiagnostics\x18\x02 \x03(\v2\".codefly.base.v0.FailureDiagnosticB\b\xbaH\x05\x92\x01\x02\x10dR\vdiagnostics*\x89\x01\n" + + "\vdiagnostics\x18\x02 \x03(\v2\".codefly.base.v0.FailureDiagnosticB\b\xbaH\x05\x92\x01\x02\x10dR\vdiagnostics*\xb2\x01\n" + "\x13SolutionNetworkMode\x12%\n" + "!SOLUTION_NETWORK_MODE_UNSPECIFIED\x10\x00\x12!\n" + - "\x1dSOLUTION_NETWORK_MODE_OFFLINE\x10\x01\x12(\n" + - "$SOLUTION_NETWORK_MODE_REGISTRY_WRITE\x10\x02*\x95\x01\n" + + "\x1dSOLUTION_NETWORK_MODE_OFFLINE\x10\x01\x12'\n" + + "#SOLUTION_NETWORK_MODE_REGISTRY_READ\x10\x02\x12(\n" + + "$SOLUTION_NETWORK_MODE_REGISTRY_WRITE\x10\x03*\x95\x01\n" + "\x0eSolutionEffect\x12\x1f\n" + "\x1bSOLUTION_EFFECT_UNSPECIFIED\x10\x00\x12\x1d\n" + "\x19SOLUTION_EFFECT_READ_ONLY\x10\x01\x12\x1f\n" + @@ -1122,8 +1129,8 @@ const file_codefly_services_solution_v0_solution_proto_rawDesc = "" + "\x16GetSolutionInformation\x12;.codefly.services.solution.v0.GetSolutionInformationRequest\x1a<.codefly.services.solution.v0.GetSolutionInformationResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x01\x12m\n" + "\x06Create\x12+.codefly.services.solution.v0.CreateRequest\x1a,.codefly.services.solution.v0.CreateResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x02\x12m\n" + "\x06Update\x12+.codefly.services.solution.v0.UpdateRequest\x1a,.codefly.services.solution.v0.UpdateResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x02\x12p\n" + - "\aPackage\x12,.codefly.services.solution.v0.PackageRequest\x1a-.codefly.services.solution.v0.PackageResponse\"\b\xd2\xf3\x18\x04\b\x02\x10\x03\x12m\n" + - "\x06Render\x12+.codefly.services.solution.v0.RenderRequest\x1a,.codefly.services.solution.v0.RenderResponse\"\b\xd2\xf3\x18\x04\b\x01\x10\x02:\x8a\x01\n" + + "\aPackage\x12,.codefly.services.solution.v0.PackageRequest\x1a-.codefly.services.solution.v0.PackageResponse\"\b\xd2\xf3\x18\x04\b\x03\x10\x03\x12m\n" + + "\x06Render\x12+.codefly.services.solution.v0.RenderRequest\x1a,.codefly.services.solution.v0.RenderResponse\"\b\xd2\xf3\x18\x04\b\x02\x10\x02:\x8a\x01\n" + "\x16solution_method_policy\x12\x1e.google.protobuf.MethodOptions\x18\xba\x8e\x03 \x01(\v22.codefly.services.solution.v0.SolutionMethodPolicyR\x14solutionMethodPolicyB\x8c\x02\n" + " com.codefly.services.solution.v0B\rSolutionProtoP\x01ZEgithub.com/codefly-dev/core/generated/go/codefly/services/solution/v0\xa2\x02\x04CSSV\xaa\x02\x1cCodefly.Services.Solution.V0\xca\x02\x1cCodefly\\Services\\Solution\\V0\xe2\x02(Codefly\\Services\\Solution\\V0\\GPBMetadata\xea\x02\x1fCodefly::Services::Solution::V0b\x06proto3" diff --git a/generated/go/codefly/services/solution/v0/solution_grpc.pb.go b/generated/go/codefly/services/solution/v0/solution_grpc.pb.go index d3e75624..32ac638b 100644 --- a/generated/go/codefly/services/solution/v0/solution_grpc.pb.go +++ b/generated/go/codefly/services/solution/v0/solution_grpc.pb.go @@ -51,7 +51,8 @@ type SolutionClient interface { Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*UpdateResponse, error) // Package builds an OCI artifact from a solution source directory and pushes it. Package(ctx context.Context, in *PackageRequest, opts ...grpc.CallOption) (*PackageResponse, error) - // Render renders a packaged solution's manifests into a gitops destination. + // Render pulls the packaged solution from artifact_reference and writes its + // manifests into a gitops destination. Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) } @@ -137,7 +138,8 @@ type SolutionServer interface { Update(context.Context, *UpdateRequest) (*UpdateResponse, error) // Package builds an OCI artifact from a solution source directory and pushes it. Package(context.Context, *PackageRequest) (*PackageResponse, error) - // Render renders a packaged solution's manifests into a gitops destination. + // Render pulls the packaged solution from artifact_reference and writes its + // manifests into a gitops destination. Render(context.Context, *RenderRequest) (*RenderResponse, error) mustEmbedUnimplementedSolutionServer() } diff --git a/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go b/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go index 09f56716..87d0e3a1 100644 --- a/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go +++ b/generated/go/codefly/services/solution/v0/v0connect/solution.connect.go @@ -58,7 +58,8 @@ type SolutionClient interface { Update(context.Context, *connect.Request[v0.UpdateRequest]) (*connect.Response[v0.UpdateResponse], error) // Package builds an OCI artifact from a solution source directory and pushes it. Package(context.Context, *connect.Request[v0.PackageRequest]) (*connect.Response[v0.PackageResponse], error) - // Render renders a packaged solution's manifests into a gitops destination. + // Render pulls the packaged solution from artifact_reference and writes its + // manifests into a gitops destination. Render(context.Context, *connect.Request[v0.RenderRequest]) (*connect.Response[v0.RenderResponse], error) } @@ -151,7 +152,8 @@ type SolutionHandler interface { Update(context.Context, *connect.Request[v0.UpdateRequest]) (*connect.Response[v0.UpdateResponse], error) // Package builds an OCI artifact from a solution source directory and pushes it. Package(context.Context, *connect.Request[v0.PackageRequest]) (*connect.Response[v0.PackageResponse], error) - // Render renders a packaged solution's manifests into a gitops destination. + // Render pulls the packaged solution from artifact_reference and writes its + // manifests into a gitops destination. Render(context.Context, *connect.Request[v0.RenderRequest]) (*connect.Response[v0.RenderResponse], error) } diff --git a/proto/codefly/services/solution/v0/solution.proto b/proto/codefly/services/solution/v0/solution.proto index 76092c06..16ec46e4 100644 --- a/proto/codefly/services/solution/v0/solution.proto +++ b/proto/codefly/services/solution/v0/solution.proto @@ -8,14 +8,18 @@ import "google/protobuf/descriptor.proto"; // SolutionNetworkMode declares the maximum network reach an RPC may use. // Values are ordered by increasing reach so a host ceiling admits every -// mode at or below it. +// mode at or below it. A solution executor's network is unmediated (unlike a +// provider, it dials directly): Package pushes the OCI artifact and Render +// pulls it from artifact_reference, so Render is a registry read, not offline. enum SolutionNetworkMode { // SOLUTION_NETWORK_MODE_UNSPECIFIED is never admitted. SOLUTION_NETWORK_MODE_UNSPECIFIED = 0; // SOLUTION_NETWORK_MODE_OFFLINE forbids all network access. SOLUTION_NETWORK_MODE_OFFLINE = 1; + // SOLUTION_NETWORK_MODE_REGISTRY_READ permits pulling the packaged OCI artifact. + SOLUTION_NETWORK_MODE_REGISTRY_READ = 2; // SOLUTION_NETWORK_MODE_REGISTRY_WRITE permits pushing to an OCI registry. - SOLUTION_NETWORK_MODE_REGISTRY_WRITE = 2; + SOLUTION_NETWORK_MODE_REGISTRY_WRITE = 3; } // SolutionEffect declares the maximum state effect of one RPC. Values are @@ -86,10 +90,11 @@ service Solution { effect: SOLUTION_EFFECT_REGISTRY_WRITE }; } - // Render renders a packaged solution's manifests into a gitops destination. + // Render pulls the packaged solution from artifact_reference and writes its + // manifests into a gitops destination. rpc Render(RenderRequest) returns (RenderResponse) { option (solution_method_policy) = { - network: SOLUTION_NETWORK_MODE_OFFLINE + network: SOLUTION_NETWORK_MODE_REGISTRY_READ effect: SOLUTION_EFFECT_LOCAL_WRITE }; } diff --git a/solution/policy.go b/solution/policy.go index 003524b5..85ac0317 100644 --- a/solution/policy.go +++ b/solution/policy.go @@ -55,8 +55,8 @@ func CeilingInspect() Ceiling { } } -// CeilingScaffold admits offline local-filesystem RPCs — Create, Update, and -// Render — but not Package's registry push. +// CeilingScaffold admits offline local-filesystem RPCs — Create and Update — +// but not Render (which pulls the packaged artifact) or Package's registry push. func CeilingScaffold() Ceiling { return Ceiling{ network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, @@ -64,6 +64,16 @@ func CeilingScaffold() Ceiling { } } +// CeilingRender admits Render — a registry pull of the packaged artifact plus a +// local-filesystem write of its manifests — and every lower RPC, but not +// Package's registry push. +func CeilingRender() Ceiling { + return Ceiling{ + network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_READ, + effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE, + } +} + // CeilingPublish admits every Solution RPC, including Package's registry push. func CeilingPublish() Ceiling { return Ceiling{ diff --git a/solution/policy_internal_test.go b/solution/policy_internal_test.go index 9b006448..b42cbc92 100644 --- a/solution/policy_internal_test.go +++ b/solution/policy_internal_test.go @@ -61,6 +61,9 @@ func TestOperationCeilingsAdmitExactlyTheirRPCs(t *testing.T) { }{ {"inspect", CeilingInspect(), map[string]bool{"GetSolutionInformation": true}}, {"scaffold", CeilingScaffold(), map[string]bool{ + "GetSolutionInformation": true, "Create": true, "Update": true, + }}, + {"render", CeilingRender(), map[string]bool{ "GetSolutionInformation": true, "Create": true, "Update": true, "Render": true, }}, {"publish", CeilingPublish(), map[string]bool{ diff --git a/solution/policy_test.go b/solution/policy_test.go index f50c6e70..f4b1bbd3 100644 --- a/solution/policy_test.go +++ b/solution/policy_test.go @@ -24,7 +24,7 @@ func TestSolutionRPCMethodPolicyIsMachineEnforceable(t *testing.T) { "Create": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, "Update": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, "Package": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_WRITE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_REGISTRY_WRITE}, - "Render": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_OFFLINE, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, + "Render": {Network: solutionv0.SolutionNetworkMode_SOLUTION_NETWORK_MODE_REGISTRY_READ, Effect: solutionv0.SolutionEffect_SOLUTION_EFFECT_LOCAL_WRITE}, } service := solutionv0.File_codefly_services_solution_v0_solution_proto.Services().ByName("Solution") @@ -54,11 +54,11 @@ func TestSolutionContractIsUnaryOnly(t *testing.T) { } } -// TestSolutionMethodPolicyAxesAreCoherent guards the two policy axes against -// incoherent combinations. Network and effect are independent in the model (as -// in provider_method_policy), but for real methods a registry push is a remote -// write and vice versa: you cannot push to a registry while offline, and no -// non-registry effect needs registry network. This catches an annotation like +// TestSolutionMethodPolicyAxesAreCoherent guards the registry-write pairing: a +// remote push is both a REGISTRY_WRITE network reach and a REGISTRY_WRITE effect, +// so the two must agree. (A REGISTRY_READ network legitimately pairs with a +// local-write effect — Render pulls the artifact and writes manifests locally — +// so that pairing is not constrained here.) This catches an annotation like // {network: OFFLINE, effect: REGISTRY_WRITE} that the ordered ceiling checks // would otherwise silently accept. func TestSolutionMethodPolicyAxesAreCoherent(t *testing.T) { @@ -196,6 +196,7 @@ func TestSolutionPolicyEnumsAreOrderedAsCeilings(t *testing.T) { require.Equal(t, []protoreflect.Name{ "SOLUTION_NETWORK_MODE_UNSPECIFIED", "SOLUTION_NETWORK_MODE_OFFLINE", + "SOLUTION_NETWORK_MODE_REGISTRY_READ", "SOLUTION_NETWORK_MODE_REGISTRY_WRITE", }, enumNames(networks.Values()))