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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions encoding/protobuf/actor_uuid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package protobuf

import (
"context"

"go.uber.org/yarpc/yarpcerrors"
)

// ActorUUIDValidator validates the actor UUIDs extracted from a protobuf
// request's uber.auth.annotations.actor_uuid-annotated fields. It is
// invoked by generated server code after the request is decoded but
// before the user's handler runs. A non-nil error short-circuits the
// request and is returned to the caller as the handler's response error.
//
// The slice mirrors what the generated ActorUUID() accessor returns: one
// entry per annotated field reachable from the request, in declaration
// order, with container fields (repeated / map) contributing every
// element or value they hold. Callers that want to support unannotated
// handlers should treat an empty slice (or empty strings) as "not
// provided" rather than as a failure.
//
Comment thread
mthakoreuber marked this conversation as resolved.
// Mirrors the ActorUUIDValidator handled by thriftrw-plugin-yarpc (see
// encoding/thrift/options.go), adapted to protobuf's []string accessor.
type ActorUUIDValidator func(ctx context.Context, actorUUIDs []string) error
Comment thread
mthakoreuber marked this conversation as resolved.

// registerConfig holds the settings collected from RegisterOptions.
type registerConfig struct {
ActorUUIDValidator ActorUUIDValidator
}

// RegisterOption customizes the behavior of a protobuf handler during
// registration. Unlike ClientOption it applies to the server side only.
type RegisterOption interface {
Comment thread
mthakoreuber marked this conversation as resolved.
applyRegisterOption(*registerConfig)
}

type actorUUIDValidatorOption struct{ validator ActorUUIDValidator }

func (a actorUUIDValidatorOption) applyRegisterOption(c *registerConfig) {
c.ActorUUIDValidator = a.validator
}

// WithActorUUIDValidator returns a RegisterOption that installs the given
// ActorUUIDValidator on the generated server. When unset, generated
// handlers skip the validator call entirely, so existing services keep
// their pre-validator behaviour.
//
// procedures := examplepb.BuildExampleYARPCProcedures(
// handler, protobuf.WithActorUUIDValidator(validator))
func WithActorUUIDValidator(v ActorUUIDValidator) RegisterOption {
return actorUUIDValidatorOption{validator: v}
}

// ActorUUIDValidatorFromOptions returns the ActorUUIDValidator installed
// by WithActorUUIDValidator in the given slice of RegisterOptions, or nil
// if none was installed. It exists so code generated by
// protoc-gen-yarpc-go can pull the validator out of the user-supplied
// options; user code should not need to call it directly.
func ActorUUIDValidatorFromOptions(opts []RegisterOption) ActorUUIDValidator {
var c registerConfig
for _, o := range opts {
o.applyRegisterOption(&c)
}
return c.ActorUUIDValidator
}

// ValidateActorUUID runs validator against actorUUIDs when validator is
// non-nil, wrapping a rejection in a PermissionDenied YARPC error that
// names the service and procedure while preserving the original error in
// the errors.Is chain (via %w). A nil validator is a no-op, keeping
// services registered without WithActorUUIDValidator backwards
// compatible.
//
// It is called by code generated by protoc-gen-yarpc-go; user code should
// not need to call it directly.
func ValidateActorUUID(ctx context.Context, validator ActorUUIDValidator, actorUUIDs []string, serviceName string, methodName string) error {
if validator == nil {
return nil
}
if err := validator(ctx, actorUUIDs); err != nil {
return yarpcerrors.PermissionDeniedErrorf(
Comment thread
mthakoreuber marked this conversation as resolved.
"actor UUID validation failed for service %q procedure %q: %w", serviceName, methodName, err)
}
return nil
}
101 changes: 101 additions & 0 deletions encoding/protobuf/actor_uuid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package protobuf

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/yarpc/yarpcerrors"
)

func TestActorUUIDValidatorFromOptions(t *testing.T) {
t.Run("none installed returns nil", func(t *testing.T) {
assert.Nil(t, ActorUUIDValidatorFromOptions(nil))
assert.Nil(t, ActorUUIDValidatorFromOptions([]RegisterOption{}))
})

t.Run("returns the installed validator", func(t *testing.T) {
var got []string
validator := func(_ context.Context, uuids []string) error {
got = uuids
return nil
}

out := ActorUUIDValidatorFromOptions([]RegisterOption{WithActorUUIDValidator(validator)})
require.NotNil(t, out, "the installed validator must be returned")

require.NoError(t, out(context.Background(), []string{"alice"}))
assert.Equal(t, []string{"alice"}, got, "the returned func must be the one we installed")
})

t.Run("last option wins", func(t *testing.T) {
first := func(context.Context, []string) error { return errors.New("first") }
second := func(context.Context, []string) error { return errors.New("second") }

out := ActorUUIDValidatorFromOptions([]RegisterOption{
WithActorUUIDValidator(first),
WithActorUUIDValidator(second),
})
require.NotNil(t, out)
assert.EqualError(t, out(context.Background(), nil), "second")
})
}

func TestValidateActorUUID(t *testing.T) {
t.Run("nil validator is a no-op", func(t *testing.T) {
assert.NoError(t, ValidateActorUUID(context.Background(), nil, []string{"alice"}, "svc", "Method"))
})

t.Run("validator receives ctx and uuids and its nil result passes", func(t *testing.T) {
type ctxKey struct{}
ctx := context.WithValue(context.Background(), ctxKey{}, "v")

var gotUUIDs []string
var gotCtxValue interface{}
validator := func(c context.Context, uuids []string) error {
gotCtxValue = c.Value(ctxKey{})
gotUUIDs = uuids
return nil
}

require.NoError(t, ValidateActorUUID(ctx, validator, []string{"alice", "bob"}, "svc", "Method"))
assert.Equal(t, []string{"alice", "bob"}, gotUUIDs)
assert.Equal(t, "v", gotCtxValue, "the caller's context must be threaded through")
})

t.Run("rejection is wrapped as PermissionDenied naming service and procedure", func(t *testing.T) {
denied := errors.New("validator denied")
validator := func(context.Context, []string) error { return denied }

err := ValidateActorUUID(context.Background(), validator, []string{"alice"}, "uber.example.UserService", "DeleteUser")
require.Error(t, err)

assert.Equal(t, yarpcerrors.CodePermissionDenied, yarpcerrors.FromError(err).Code())
assert.ErrorIs(t, err, denied, "the original error must stay in the errors.Is chain")
msg := err.Error()
assert.Contains(t, msg, "uber.example.UserService")
assert.Contains(t, msg, "DeleteUser")
})
}
39 changes: 28 additions & 11 deletions encoding/protobuf/protoc-gen-yarpc-go/internal/lib/lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ import (
"go.uber.org/yarpc/internal/protoplugin"
)

const tmpl = `{{$packagePath := .GoPackage.Path}}{{$packageName := .GoPackage.Name}}
// tmpl is the text/template that renders a .proto file's generated
// .pb.yarpc.go: its clients, server handlers, and Fx providers.
const tmpl = `{{$packagePath := .GoPackage.Path}}{{$packageName := .GoPackage.Name}}{{$info := .}}
Comment thread
mthakoreuber marked this conversation as resolved.
// Code generated by protoc-gen-yarpc-go. DO NOT EDIT.
// source: {{.GetName}}

Expand Down Expand Up @@ -142,10 +144,11 @@ type {{$service.GetName}}Service{{$method.GetName}}YARPCServer interface {
type build{{$service.GetName}}YARPCProceduresParams struct {
Server {{$service.GetName}}YARPCServer
AnyResolver jsonpb.AnyResolver
}
{{if serviceHasActorUUID $info $service}} ActorUUIDValidator protobuf.ActorUUIDValidator
{{end}}}

func build{{$service.GetName}}YARPCProcedures(params build{{$service.GetName}}YARPCProceduresParams) []transport.Procedure {
handler := &_{{$service.GetName}}YARPCHandler{params.Server}
Comment thread
mthakoreuber marked this conversation as resolved.
{{if serviceHasActorUUID $info $service}}handler := &_{{$service.GetName}}YARPCHandler{server: params.Server, actorUUIDValidator: params.ActorUUIDValidator}{{else}}handler := &_{{$service.GetName}}YARPCHandler{params.Server}{{end}}
return protobuf.BuildProcedures(
protobuf.BuildProceduresParams{
ServiceName: "{{trimPrefixPeriod $service.FQSN}}",
Expand Down Expand Up @@ -208,8 +211,8 @@ func build{{$service.GetName}}YARPCProcedures(params build{{$service.GetName}}YA
}

// Build{{$service.GetName}}YARPCProcedures prepares an implementation of the {{$service.GetName}} service for YARPC registration.
func Build{{$service.GetName}}YARPCProcedures(server {{$service.GetName}}YARPCServer) []transport.Procedure {
return build{{$service.GetName}}YARPCProcedures(build{{$service.GetName}}YARPCProceduresParams{Server:server})
func Build{{$service.GetName}}YARPCProcedures(server {{$service.GetName}}YARPCServer{{if serviceHasActorUUID $info $service}}, options ...protobuf.RegisterOption{{end}}) []transport.Procedure {
return build{{$service.GetName}}YARPCProcedures(build{{$service.GetName}}YARPCProceduresParams{Server:server{{if serviceHasActorUUID $info $service}}, ActorUUIDValidator: protobuf.ActorUUIDValidatorFromOptions(options){{end}}})
}

// Fx{{$service.GetName}}YARPCClientParams defines the input
Expand Down Expand Up @@ -271,7 +274,8 @@ type Fx{{$service.GetName}}YARPCProceduresParams struct {

Server {{$service.GetName}}YARPCServer
AnyResolver jsonpb.AnyResolver ` + "`" + `name:"yarpcfx" optional:"true"` + "`" + `
}
{{if serviceHasActorUUID $info $service}} ActorUUIDValidator protobuf.ActorUUIDValidator ` + "`" + `optional:"true"` + "`" + `
{{end}}}

// Fx{{$service.GetName}}YARPCProceduresResult defines the output
// of NewFx{{$service.GetName}}YARPCProcedures. It provides
Expand Down Expand Up @@ -299,7 +303,8 @@ func NewFx{{$service.GetName}}YARPCProcedures() interface{} {
Procedures: build{{$service.GetName}}YARPCProcedures(build{{$service.GetName}}YARPCProceduresParams{
Server: params.Server,
AnyResolver: params.AnyResolver,
}),
{{if serviceHasActorUUID $info $service}} ActorUUIDValidator: params.ActorUUIDValidator,
{{end}} }),
ReflectionMeta: {{$service.GetName}}ReflectionMeta,
}
}
Expand Down Expand Up @@ -369,7 +374,8 @@ func (c *_{{$service.GetName}}YARPCCaller) {{$method.GetName}}(ctx context.Conte

type _{{$service.GetName}}YARPCHandler struct {
server {{$service.GetName}}YARPCServer
}
{{if serviceHasActorUUID $info $service}} actorUUIDValidator protobuf.ActorUUIDValidator
{{end}}}

{{range $method := unaryMethods $service}}
func (h *_{{$service.GetName}}YARPCHandler) {{$method.GetName}}(ctx context.Context, requestMessage proto.Message) (proto.Message, error) {
Expand All @@ -381,7 +387,10 @@ func (h *_{{$service.GetName}}YARPCHandler) {{$method.GetName}}(ctx context.Cont
return nil, protobuf.CastError(empty{{$service.GetName}}Service{{$method.GetName}}YARPCRequest, requestMessage)
}
}
response, err := h.server.{{$method.GetName}}(ctx, request)
{{if methodHasActorUUID $info $method}} if err := protobuf.ValidateActorUUID(ctx, h.actorUUIDValidator, request.ActorUUID(), "{{trimPrefixPeriod $service.FQSN}}", "{{$method.GetName}}"); err != nil {
return nil, err
}
{{end}} response, err := h.server.{{$method.GetName}}(ctx, request)
if response == nil {
return nil, err
}
Expand All @@ -398,7 +407,10 @@ func (h *_{{$service.GetName}}YARPCHandler) {{$method.GetName}}(ctx context.Cont
return protobuf.CastError(empty{{$service.GetName}}Service{{$method.GetName}}YARPCRequest, requestMessage)
}
}
return h.server.{{$method.GetName}}(ctx, request)
{{if methodHasActorUUID $info $method}} if err := protobuf.ValidateActorUUID(ctx, h.actorUUIDValidator, request.ActorUUID(), "{{trimPrefixPeriod $service.FQSN}}", "{{$method.GetName}}"); err != nil {
return err
}
{{end}} return h.server.{{$method.GetName}}(ctx, request)
}
{{end}}
{{range $method := clientStreamingMethods $service}}
Expand All @@ -421,7 +433,10 @@ func (h *_{{$service.GetName}}YARPCHandler) {{$method.GetName}}(serverStream *pr
if !ok {
return protobuf.CastError(empty{{$service.GetName}}Service{{$method.GetName}}YARPCRequest, requestMessage)
}
return h.server.{{$method.GetName}}(request, &_{{$service.GetName}}Service{{$method.GetName}}YARPCServer{serverStream: serverStream})
{{if methodHasActorUUID $info $method}} if err := protobuf.ValidateActorUUID(serverStream.Context(), h.actorUUIDValidator, request.ActorUUID(), "{{trimPrefixPeriod $service.FQSN}}", "{{$method.GetName}}"); err != nil {
return err
}
{{end}} return h.server.{{$method.GetName}}(request, &_{{$service.GetName}}Service{{$method.GetName}}YARPCServer{serverStream: serverStream})
}
{{end}}
{{range $method := clientServerStreamingMethods $service}}
Expand Down Expand Up @@ -652,6 +667,8 @@ var Runner = protoplugin.NewRunner(
"fileDescriptorClosureVarName": fileDescriptorClosureVarName,
"trimPrefixPeriod": trimPrefixPeriod,
"actorUUIDMethods": actorUUIDMethods,
"serviceHasActorUUID": serviceHasActorUUID,
"methodHasActorUUID": methodHasActorUUID,
}).Parse(tmpl)),
nil,
[]string{
Expand Down
59 changes: 59 additions & 0 deletions encoding/protobuf/protoc-gen-yarpc-go/internal/lib/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,65 @@ func TestRunnerEmitsActorUUIDAccessor(t *testing.T) {
"sanity check: existing client/server emission still works")
}

// TestRunnerWiresActorUUIDValidator asserts the server template injects the
// ActorUUID validator plumbing for a service with an annotated request
// type: the handler carries a validator field, Build/registration accept
// protobuf.RegisterOptions, and the per-method handler calls
// protobuf.ValidateActorUUID with the request's ActorUUID() accessor.
func TestRunnerWiresActorUUIDValidator(t *testing.T) {
req := &plugin_go.CodeGeneratorRequest{
FileToGenerate: []string{"svc/foo.proto"},
ProtoFile: []*descriptor.FileDescriptorProto{
optionsFileDescriptor(),
targetFileDescriptor(t),
},
}

resp := Runner.Run(req)
require.Nil(t, resp.Error, "plugin returned error: %v", resp.GetError())
require.Len(t, resp.File, 1)

out := resp.File[0].GetContent()
assert.Contains(t, out, "actorUUIDValidator protobuf.ActorUUIDValidator",
"handler struct must carry a validator field; output was:\n%s", out)
assert.Contains(t, out, "func BuildUserServiceYARPCProcedures(server UserServiceYARPCServer, options ...protobuf.RegisterOption)",
"Build entry point must accept register options; output was:\n%s", out)
assert.Contains(t, out, "protobuf.ActorUUIDValidatorFromOptions(options)",
"Build entry point must extract the validator from options; output was:\n%s", out)
assert.Contains(t, out, `protobuf.ValidateActorUUID(ctx, h.actorUUIDValidator, request.ActorUUID(), "svc.UserService", "DeleteUser")`,
"the annotated method must call the validator with its ActorUUID(); output was:\n%s", out)
}

// TestRunnerSkipsValidatorForUnannotatedService asserts that a service
// whose request types carry no annotation keeps its original, option-free
// signature and gets no validator plumbing, preserving backwards
// compatibility.
func TestRunnerSkipsValidatorForUnannotatedService(t *testing.T) {
target := targetFileDescriptor(t)
// Drop the annotation from the request's first field.
target.MessageType[0].Field[0].Options = nil

req := &plugin_go.CodeGeneratorRequest{
FileToGenerate: []string{"svc/foo.proto"},
ProtoFile: []*descriptor.FileDescriptorProto{
optionsFileDescriptor(),
target,
},
}

resp := Runner.Run(req)
require.Nil(t, resp.Error, "plugin returned error: %v", resp.GetError())
require.Len(t, resp.File, 1)

out := resp.File[0].GetContent()
assert.NotContains(t, out, "actorUUIDValidator",
"an unannotated service must not get validator plumbing; output was:\n%s", out)
assert.NotContains(t, out, "ValidateActorUUID",
"an unannotated service must not call the validator; output was:\n%s", out)
assert.Contains(t, out, "func BuildUserServiceYARPCProcedures(server UserServiceYARPCServer) []transport.Procedure",
"an unannotated service keeps its original option-free signature; output was:\n%s", out)
}

// TestRunnerCollectsEveryAnnotationOnMultiplyAnnotatedRequest asserts the
// "collect all" rule: a request type with two actor_uuid-annotated fields
// generates exactly one ActorUUID() accessor whose []string body returns
Expand Down
Loading