From a48c1502d2955373320460b295627ea2da8a94c7 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Thu, 21 May 2026 15:48:20 +0200 Subject: [PATCH 1/3] feat(connectrpc): add ConnectRPC transport adapter Add a connectrpc/ module (package connectrpcsec) that adapts the transport-agnostic security core to the ConnectRPC framework, mirroring the gRPC adapter. ConnectRPC has a single connect.Interceptor interface covering unary and streaming RPCs, so the adapter exposes two interceptors instead of the four gRPC-style constructors: - NewAuthenticationInterceptor runs the Engine against the request headers and enriches the context; client-side calls pass through. - NewAuthorizationInterceptor enforces an AccessDecisionManager. It also ships a Carrier over http.Header, an ErrorMapper translating security sentinels to connect.Code (Unauthenticated / PermissionDenied / InvalidArgument), and OTel spans connectrpcsec.Authenticate / connectrpcsec.Authorize. Module tests pass with -race at 100% coverage; golangci-lint is clean. --- connectrpc/authorize.go | 97 +++++++++ connectrpc/authorize_test.go | 135 +++++++++++++ connectrpc/carrier.go | 69 +++++++ connectrpc/carrier_test.go | 56 ++++++ connectrpc/doc.go | 24 +++ connectrpc/error_mapper.go | 74 +++++++ connectrpc/error_mapper_test.go | 54 +++++ connectrpc/example_test.go | 52 +++++ connectrpc/go.mod | 25 +++ connectrpc/go.sum | 44 ++++ connectrpc/interceptor.go | 139 +++++++++++++ connectrpc/interceptor_test.go | 311 +++++++++++++++++++++++++++++ connectrpc/options.go | 44 ++++ connectrpc/testing_helpers_test.go | 150 ++++++++++++++ go.work | 1 + go.work.sum | 9 +- 16 files changed, 1278 insertions(+), 6 deletions(-) create mode 100644 connectrpc/authorize.go create mode 100644 connectrpc/authorize_test.go create mode 100644 connectrpc/carrier.go create mode 100644 connectrpc/carrier_test.go create mode 100644 connectrpc/doc.go create mode 100644 connectrpc/error_mapper.go create mode 100644 connectrpc/error_mapper_test.go create mode 100644 connectrpc/example_test.go create mode 100644 connectrpc/go.mod create mode 100644 connectrpc/go.sum create mode 100644 connectrpc/interceptor.go create mode 100644 connectrpc/interceptor_test.go create mode 100644 connectrpc/options.go create mode 100644 connectrpc/testing_helpers_test.go diff --git a/connectrpc/authorize.go b/connectrpc/authorize.go new file mode 100644 index 0000000..26ae413 --- /dev/null +++ b/connectrpc/authorize.go @@ -0,0 +1,97 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "context" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + "go.opentelemetry.io/otel" +) + +// AuthorizationInterceptor is a [connect.Interceptor] that enforces a +// [security.AccessDecisionManager] against the request's +// [security.Authentication]. +// +// Install it AFTER [NewAuthenticationInterceptor] in the +// connect.WithInterceptors(...) list so the context already carries an +// authentication: the first interceptor of the list is the outermost, so +// connect.WithInterceptors(authn, authz) runs authn (which enriches the +// context) before authz. +// +// On grant the handler runs; on deny the configured [ErrorMapper] translates +// the decision (typically connect.CodePermissionDenied). +type AuthorizationInterceptor struct { + adm security.AccessDecisionManager + attrs []security.Attribute + cfg *config +} + +// NewAuthorizationInterceptor builds a [connect.Interceptor] that enforces adm +// against attrs for every inbound unary and streaming RPC. +func NewAuthorizationInterceptor( + adm security.AccessDecisionManager, + attrs []security.Attribute, + opts ...Option, +) *AuthorizationInterceptor { + return &AuthorizationInterceptor{adm: adm, attrs: attrs, cfg: buildConfig(opts...)} +} + +// Compile-time check. +var _ connect.Interceptor = (*AuthorizationInterceptor)(nil) + +// WrapUnary implements [connect.Interceptor]. Outbound client calls are passed +// through untouched; inbound handler calls are authorized. +func (i *AuthorizationInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if req.Spec().IsClient { + return next(ctx, req) //nolint:wrapcheck // pass-through: the client error is the terminal value + } + + if err := decide(ctx, i.adm, i.attrs); err != nil { + return nil, i.cfg.errorMapper.Map(ctx, err) + } + + return next(ctx, req) //nolint:wrapcheck // the handler / connect error is the terminal wire value + } +} + +// WrapStreamingHandler implements [connect.Interceptor]. It runs the access +// decision before the handler runs. +func (i *AuthorizationInterceptor) WrapStreamingHandler( + next connect.StreamingHandlerFunc, +) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + if err := decide(ctx, i.adm, i.attrs); err != nil { + return i.cfg.errorMapper.Map(ctx, err) + } + + return next(ctx, conn) //nolint:wrapcheck // the handler error is the terminal wire value + } +} + +// WrapStreamingClient implements [connect.Interceptor] as a pass-through; the +// access decision is server-side only. +func (i *AuthorizationInterceptor) WrapStreamingClient( + next connect.StreamingClientFunc, +) connect.StreamingClientFunc { + return next +} + +// decide pulls the Authentication from ctx and runs the ADM, wrapping the call +// in a "connectrpcsec.Authorize" span. +func decide(ctx context.Context, adm security.AccessDecisionManager, attrs []security.Attribute) error { + ctx, span := otel.Tracer(tracerName).Start(ctx, "connectrpcsec.Authorize") + defer span.End() + + auth, _ := security.FromContext(ctx) + + if err := adm.Decide(ctx, auth, attrs); err != nil { + return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper + } + + return nil +} diff --git a/connectrpc/authorize_test.go b/connectrpc/authorize_test.go new file mode 100644 index 0000000..73f84ba --- /dev/null +++ b/connectrpc/authorize_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/hyperscale-stack/security/voter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// adminADM grants only when the principal holds the ADMIN role. +func adminADM() security.AccessDecisionManager { + return security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) +} + +var adminAttrs = []security.Attribute{security.Role("ADMIN")} + +// chainUnary composes the authentication and authorization interceptors the +// same way connect.WithInterceptors(authn, authz) would: authn is the +// outermost, so it enriches the context before authz reads it. +func chainUnary( + authn *connectrpcsec.AuthenticationInterceptor, + authz *connectrpcsec.AuthorizationInterceptor, + handler connect.UnaryFunc, +) connect.UnaryFunc { + return authn.WrapUnary(authz.WrapUnary(handler)) +} + +func TestUnaryAuthorizeGrantsWhenRolePresent(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := chainUnary( + connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_ADMIN")), + connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs), + spy.fn, + ) + + resp, err := wrapped(context.Background(), unaryReq("letmein")) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.True(t, spy.called) +} + +func TestUnaryAuthorizeDeniesWhenRoleMissing(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := chainUnary( + connectrpcsec.NewAuthenticationInterceptor(newEngine()), + connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs), + spy.fn, + ) + + _, err := wrapped(context.Background(), unaryReq("letmein")) + require.Error(t, err) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + assert.False(t, spy.called) +} + +func TestUnaryAuthorizeDeniesAnonymous(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + // No authentication in the context: the voter denies the anonymous caller. + wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), unaryReq("letmein")) + require.Error(t, err) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + assert.False(t, spy.called) +} + +func TestUnaryAuthorizeSkipsClientCall(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), clientRequest{connect.NewRequest(&struct{}{})}) + require.NoError(t, err) + assert.True(t, spy.called) +} + +func TestStreamAuthorizeGrantsWhenRolePresent(t *testing.T) { + t.Parallel() + + spy := &recordingStream{} + authn := connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_ADMIN")) + authz := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs) + wrapped := authn.WrapStreamingHandler(authz.WrapStreamingHandler(spy.fn)) + + err := wrapped(context.Background(), newStreamConn(bearerHeader("letmein"))) + require.NoError(t, err) + assert.True(t, spy.called) +} + +func TestStreamAuthorizeDeniesWhenRoleMissing(t *testing.T) { + t.Parallel() + + spy := &recordingStream{} + authn := connectrpcsec.NewAuthenticationInterceptor(newEngine()) + authz := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs) + wrapped := authn.WrapStreamingHandler(authz.WrapStreamingHandler(spy.fn)) + + err := wrapped(context.Background(), newStreamConn(bearerHeader("letmein"))) + require.Error(t, err) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + assert.False(t, spy.called) +} + +func TestStreamAuthorizeIsPassThroughForClient(t *testing.T) { + t.Parallel() + + called := false + + next := func(_ context.Context, _ connect.Spec) connect.StreamingClientConn { + called = true + + return nil + } + + wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapStreamingClient(next) + _ = wrapped(context.Background(), connect.Spec{}) + + assert.True(t, called) +} diff --git a/connectrpc/carrier.go b/connectrpc/carrier.go new file mode 100644 index 0000000..5360b75 --- /dev/null +++ b/connectrpc/carrier.go @@ -0,0 +1,69 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "net/http" + + "github.com/hyperscale-stack/security" +) + +// Carrier adapts a ConnectRPC request to [security.Carrier]. +// +// Reads consult the request header. ConnectRPC speaks net/http, so the keys +// follow http.Header semantics (case-insensitive, canonicalised via +// textproto.CanonicalMIMEHeaderKey); the conventional "Authorization" +// spelling works directly with no manual case folding. +// +// Writes accumulate in a private staged header that the interceptor flushes +// onto the live response header once one is available — immediately for a +// streaming handler (conn.ResponseHeader() is live) and after the handler +// runs for a unary call (the response Header()). This lets an ErrorMapper or +// an extractor attach, e.g., a diagnostic header alongside the response. +// +// Carrier is NOT safe for concurrent use; one instance per RPC. +type Carrier struct { + in http.Header + out http.Header +} + +// NewCarrier builds a Carrier from a request header. When h is nil (a unit +// test, a non-Connect caller) the read side is simply empty. +func NewCarrier(h http.Header) *Carrier { + if h == nil { + h = http.Header{} + } + + return &Carrier{in: h, out: http.Header{}} +} + +// Get implements [security.Carrier]. Returns the first value for key. +func (c *Carrier) Get(key string) string { + return c.in.Get(key) +} + +// Values implements [security.Carrier]. +func (c *Carrier) Values(key string) []string { + return c.in.Values(key) +} + +// Set implements [security.Carrier]. The value is staged in the response +// header; the interceptor flushes it onto the live response. +func (c *Carrier) Set(key, value string) { + c.out.Set(key, value) +} + +// Add implements [security.Carrier]. +func (c *Carrier) Add(key, value string) { + c.out.Add(key, value) +} + +// ResponseHeader returns the staged response header. The interceptor calls it +// after the engine / handler run and, when non-empty, copies it onto the live +// response header. +func (c *Carrier) ResponseHeader() http.Header { return c.out } + +// Compile-time check. +var _ security.Carrier = (*Carrier)(nil) diff --git a/connectrpc/carrier_test.go b/connectrpc/carrier_test.go new file mode 100644 index 0000000..f32c453 --- /dev/null +++ b/connectrpc/carrier_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "net/http" + "testing" + + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/stretchr/testify/assert" +) + +func TestCarrierReads(t *testing.T) { + t.Parallel() + + hdr := http.Header{"Authorization": {"Bearer one", "Bearer two"}} + carrier := connectrpcsec.NewCarrier(hdr) + + // Get returns the first value; lookups are case-insensitive. + assert.Equal(t, "Bearer one", carrier.Get("Authorization")) + assert.Equal(t, "Bearer one", carrier.Get("authorization")) + + // Values returns every value. + assert.Equal(t, []string{"Bearer one", "Bearer two"}, carrier.Values("authorization")) + + // Absent keys yield the zero values. + assert.Empty(t, carrier.Get("X-Absent")) + assert.Nil(t, carrier.Values("X-Absent")) +} + +func TestCarrierWritesResponseHeader(t *testing.T) { + t.Parallel() + + carrier := connectrpcsec.NewCarrier(http.Header{}) + + carrier.Set("X-Trace", "first") + carrier.Set("X-Trace", "second") // Set replaces. + carrier.Add("X-Trace", "third") // Add appends. + + assert.Equal(t, []string{"second", "third"}, carrier.ResponseHeader().Values("X-Trace")) +} + +func TestCarrierNilHeader(t *testing.T) { + t.Parallel() + + carrier := connectrpcsec.NewCarrier(nil) + + assert.Empty(t, carrier.Get("Authorization")) + assert.Nil(t, carrier.Values("Authorization")) + + // Writes still work against the staged header. + carrier.Set("X-Trace", "value") + assert.Equal(t, "value", carrier.ResponseHeader().Get("X-Trace")) +} diff --git a/connectrpc/doc.go b/connectrpc/doc.go new file mode 100644 index 0000000..5cff6c4 --- /dev/null +++ b/connectrpc/doc.go @@ -0,0 +1,24 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// Package connectrpcsec is the ConnectRPC transport adapter for the security +// core. +// +// It exposes connect.Interceptor values that hand the request headers (the +// Carrier) to the core Engine and map security errors to the appropriate +// Connect error codes (connect.CodeUnauthenticated, connect.CodePermissionDenied, +// …). +// +// ConnectRPC has a single Interceptor interface covering both unary and +// streaming RPCs, installed once via connect.WithInterceptors. The adapter +// therefore exposes two interceptors instead of the four gRPC-style +// constructors: NewAuthenticationInterceptor authenticates every inbound RPC +// and NewAuthorizationInterceptor enforces an access decision manager. +// +// Allowed dependencies: +// - github.com/hyperscale-stack/security (core) +// - connectrpc.com/connect +// - go.opentelemetry.io/otel +// - stdlib only +package connectrpcsec diff --git a/connectrpc/error_mapper.go b/connectrpc/error_mapper.go new file mode 100644 index 0000000..5db71c7 --- /dev/null +++ b/connectrpc/error_mapper.go @@ -0,0 +1,74 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" +) + +// ErrorMapper translates a security error into a Connect error. Custom +// mappers can localize messages or attach metadata; the default mapper covers +// the canonical security sentinels. +// +// Implementations MUST be safe for concurrent use. +type ErrorMapper interface { + // Map returns the Connect error for err. It MUST return a non-nil error + // (callers only invoke it on a failure path). + Map(ctx context.Context, err error) error +} + +// DefaultErrorMapper returns the canonical mapper: +// +// - connect.CodeInvalidArgument for [security.ErrUnsupportedCredential] +// - connect.CodePermissionDenied for [security.ErrAccessDenied] and +// [security.ErrInsufficientScope] +// - connect.CodeUnauthenticated for ErrInvalidCredentials, +// ErrClientSecretMismatch, ErrTokenExpired, ErrTokenNotFound, +// ErrAuthenticatorRefused, and any other unclassified error +// +// The message is intentionally terse — Connect clients branch on the code, +// not the string. +func DefaultErrorMapper() ErrorMapper { return defaultErrorMapper{} } + +type defaultErrorMapper struct{} + +// Map implements [ErrorMapper]. The returned Connect error is the final wire +// value — not a wrapping of err — so wrapcheck is silenced here. +func (defaultErrorMapper) Map(_ context.Context, err error) error { + code, msg := classify(err) + + return connect.NewError(code, errors.New(msg)) //nolint:wrapcheck // connect error is the terminal wire value +} + +func classify(err error) (connect.Code, string) { + switch { + case errors.Is(err, security.ErrUnsupportedCredential): + return connect.CodeInvalidArgument, "unsupported credential" + + case errors.Is(err, security.ErrAccessDenied): + return connect.CodePermissionDenied, "access denied" + + case errors.Is(err, security.ErrInsufficientScope): + return connect.CodePermissionDenied, "insufficient scope" + + case errors.Is(err, security.ErrTokenExpired): + return connect.CodeUnauthenticated, "token expired" + + case errors.Is(err, security.ErrTokenNotFound): + return connect.CodeUnauthenticated, "token not found" + + case errors.Is(err, security.ErrInvalidCredentials), + errors.Is(err, security.ErrClientSecretMismatch), + errors.Is(err, security.ErrAuthenticatorRefused): + return connect.CodeUnauthenticated, "invalid credentials" + + default: + return connect.CodeUnauthenticated, "unauthenticated" + } +} diff --git a/connectrpc/error_mapper_test.go b/connectrpc/error_mapper_test.go new file mode 100644 index 0000000..d36571c --- /dev/null +++ b/connectrpc/error_mapper_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "context" + "errors" + "testing" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultErrorMapperClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want connect.Code + }{ + {"unsupported_credential", security.ErrUnsupportedCredential, connect.CodeInvalidArgument}, + {"access_denied", security.ErrAccessDenied, connect.CodePermissionDenied}, + {"insufficient_scope", security.ErrInsufficientScope, connect.CodePermissionDenied}, + {"token_expired", security.ErrTokenExpired, connect.CodeUnauthenticated}, + {"token_not_found", security.ErrTokenNotFound, connect.CodeUnauthenticated}, + {"invalid_credentials", security.ErrInvalidCredentials, connect.CodeUnauthenticated}, + {"client_secret_mismatch", security.ErrClientSecretMismatch, connect.CodeUnauthenticated}, + {"authenticator_refused", security.ErrAuthenticatorRefused, connect.CodeUnauthenticated}, + {"unknown_error", errors.New("boom"), connect.CodeUnauthenticated}, + { + "wrapped_access_denied", + errors.Join(errors.New("ctx"), security.ErrAccessDenied), + connect.CodePermissionDenied, + }, + } + + mapper := connectrpcsec.DefaultErrorMapper() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := mapper.Map(context.Background(), tc.err) + require.Error(t, got) + assert.Equal(t, tc.want, connect.CodeOf(got)) + }) + } +} diff --git a/connectrpc/example_test.go b/connectrpc/example_test.go new file mode 100644 index 0000000..6c2e85a --- /dev/null +++ b/connectrpc/example_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/hyperscale-stack/security/voter" +) + +// Example wires the Bearer-token engine into a ConnectRPC service: the +// authentication interceptor validates the token, the authorization +// interceptor enforces a role, and the call only reaches the handler when +// both pass. +func Example() { + engine := security.NewEngine( + security.NewManager(tokenAuthenticator{authorities: []string{"ROLE_ADMIN"}}), + tokenExtractor{}, + ) + adm := security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN")) + + // In a real server: + // + // connect.WithInterceptors( + // connectrpcsec.NewAuthenticationInterceptor(engine), + // connectrpcsec.NewAuthorizationInterceptor(adm, []security.Attribute{security.Role("ADMIN")}), + // ) + // + // Here we just demonstrate the error mapping the interceptors apply. + _ = engine + _ = adm + + mapper := connectrpcsec.DefaultErrorMapper() + for _, err := range []error{ + security.ErrInvalidCredentials, + security.ErrAccessDenied, + security.ErrUnsupportedCredential, + } { + fmt.Println(connect.CodeOf(mapper.Map(context.Background(), err))) + } + + // Output: + // unauthenticated + // permission_denied + // invalid_argument +} diff --git a/connectrpc/go.mod b/connectrpc/go.mod new file mode 100644 index 0000000..c52365a --- /dev/null +++ b/connectrpc/go.mod @@ -0,0 +1,25 @@ +module github.com/hyperscale-stack/security/connectrpc + +go 1.26 + +require ( + connectrpc.com/connect v1.20.0 + github.com/hyperscale-stack/security v0.0.0-00010101000000-000000000000 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.43.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/hyperscale-stack/security => ../ diff --git a/connectrpc/go.sum b/connectrpc/go.sum new file mode 100644 index 0000000..924be70 --- /dev/null +++ b/connectrpc/go.sum @@ -0,0 +1,44 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/connectrpc/interceptor.go b/connectrpc/interceptor.go new file mode 100644 index 0000000..ccbbe1c --- /dev/null +++ b/connectrpc/interceptor.go @@ -0,0 +1,139 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +import ( + "context" + "errors" + "fmt" + "net/http" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" +) + +const tracerName = "github.com/hyperscale-stack/security/connectrpc" + +// flushHeader copies the staged carrier writes into dst, a live response +// header. In the common case the engine writes nothing and this is a no-op. +func flushHeader(dst, staged http.Header) { + for key, values := range staged { + for _, value := range values { + dst.Add(key, value) + } + } +} + +// authenticate runs the engine against the RPC request header and returns the +// enriched context together with the Carrier (so the caller can flush staged +// response headers). It is shared by the unary and streaming handlers. +func authenticate( + ctx context.Context, + engine security.Engine, + cfg *config, + procedure string, + header http.Header, +) (context.Context, *Carrier, error) { + ctx, span := otel.Tracer(tracerName).Start(ctx, "connectrpcsec.Authenticate") + defer span.End() + + span.SetAttributes(attribute.String("rpc.method", procedure)) + + carrier := NewCarrier(header) + + newCtx, auth, err := engine.Process(ctx, carrier) + if err != nil { + // "no extractor configured" is tolerated only when the caller + // opted into anonymous fallback; every other error is fatal. + tolerated := cfg.anonymousFallback && errors.Is(err, security.ErrNoExtractor) + if !tolerated { + return ctx, carrier, fmt.Errorf("connectrpcsec: authenticate: %w", err) + } + } + + if !auth.IsAuthenticated() && !cfg.anonymousFallback { + return ctx, carrier, security.ErrInvalidCredentials + } + + span.SetAttributes(attribute.Bool("security.authenticated", auth.IsAuthenticated())) + + return newCtx, carrier, nil +} + +// AuthenticationInterceptor is a [connect.Interceptor] that authenticates +// every inbound RPC against a [security.Engine]. On success the handler runs +// with the request context enriched via [security.WithAuthentication]; on +// failure the configured [ErrorMapper] turns the security error into a +// Connect error and the handler is not invoked. +// +// It opens a "connectrpcsec.Authenticate" span but deliberately does NOT open +// an "rpc" span — that belongs to otelconnect, which users compose alongside +// this interceptor. +type AuthenticationInterceptor struct { + engine security.Engine + cfg *config +} + +// NewAuthenticationInterceptor builds a [connect.Interceptor] that +// authenticates every inbound unary and streaming RPC against engine. Install +// it with connect.WithInterceptors(...). Client-side calls are passed through +// untouched. +func NewAuthenticationInterceptor(engine security.Engine, opts ...Option) *AuthenticationInterceptor { + return &AuthenticationInterceptor{engine: engine, cfg: buildConfig(opts...)} +} + +// Compile-time check. +var _ connect.Interceptor = (*AuthenticationInterceptor)(nil) + +// WrapUnary implements [connect.Interceptor]. Outbound client calls are passed +// through untouched; inbound handler calls are authenticated. +func (i *AuthenticationInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if req.Spec().IsClient { + return next(ctx, req) //nolint:wrapcheck // pass-through: the client error is the terminal value + } + + newCtx, carrier, err := authenticate(ctx, i.engine, i.cfg, req.Spec().Procedure, req.Header()) + if err != nil { + return nil, i.cfg.errorMapper.Map(ctx, err) + } + + resp, err := next(newCtx, req) + if resp != nil { + flushHeader(resp.Header(), carrier.ResponseHeader()) + } + + return resp, err //nolint:wrapcheck // the handler / connect error is the terminal wire value + } +} + +// WrapStreamingHandler implements [connect.Interceptor]. It authenticates the +// stream before the handler runs and exposes the enriched context through the +// handler's context argument. +func (i *AuthenticationInterceptor) WrapStreamingHandler( + next connect.StreamingHandlerFunc, +) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + newCtx, carrier, err := authenticate(ctx, i.engine, i.cfg, conn.Spec().Procedure, conn.RequestHeader()) + if err != nil { + return i.cfg.errorMapper.Map(ctx, err) + } + + flushHeader(conn.ResponseHeader(), carrier.ResponseHeader()) + + return next(newCtx, conn) //nolint:wrapcheck // the handler error is the terminal wire value + } +} + +// WrapStreamingClient implements [connect.Interceptor] as a pass-through. +// [connect.StreamingClientFunc] exposes only a [connect.Spec] and returns no +// error, so the security engine — which is server-side — cannot run here. +func (i *AuthenticationInterceptor) WrapStreamingClient( + next connect.StreamingClientFunc, +) connect.StreamingClientFunc { + return next +} diff --git a/connectrpc/interceptor_test.go b/connectrpc/interceptor_test.go new file mode 100644 index 0000000..8f7e83b --- /dev/null +++ b/connectrpc/interceptor_test.go @@ -0,0 +1,311 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "context" + "sync" + "testing" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// unaryReq builds a server-side unary request carrying the Bearer token, or +// no Authorization header when token is empty. +func unaryReq(token string) connect.AnyRequest { + req := connect.NewRequest(&struct{}{}) + if token != "" { + req.Header().Set("Authorization", "Bearer "+token) + } + + return req +} + +// recordingUnary is a connect.UnaryFunc spy: it records its invocation and the +// authentication carried by the context it received. +type recordingUnary struct { + called bool + auth security.Authentication +} + +func (s *recordingUnary) fn(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + s.called = true + s.auth, _ = security.FromContext(ctx) + + return connect.NewResponse(&struct{}{}), nil +} + +// recordingStream is a connect.StreamingHandlerFunc spy. +type recordingStream struct { + called bool + auth security.Authentication +} + +func (s *recordingStream) fn(ctx context.Context, _ connect.StreamingHandlerConn) error { + s.called = true + s.auth, _ = security.FromContext(ctx) + + return nil +} + +func TestWrapUnaryAllowsAuthenticatedCall(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapUnary(spy.fn) + + resp, err := wrapped(context.Background(), unaryReq("letmein")) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.True(t, spy.called) + assert.True(t, spy.auth.IsAuthenticated()) + assert.Equal(t, "alice", spy.auth.Name()) +} + +func TestWrapUnaryRejectsMissingCredential(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), unaryReq("")) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.False(t, spy.called) +} + +func TestWrapUnaryRejectsBadToken(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), unaryReq("wrong")) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.False(t, spy.called) +} + +func TestWrapUnaryAnonymousFallbackLetsCallThrough(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor( + newEngine(), + connectrpcsec.WithAnonymousFallback(true), + ).WrapUnary(spy.fn) + + resp, err := wrapped(context.Background(), unaryReq("")) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.True(t, spy.called) + assert.False(t, spy.auth.IsAuthenticated()) +} + +func TestWrapUnarySkipsClientCall(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapUnary(spy.fn) + + // A client-side call carries no credential yet still reaches next. + _, err := wrapped(context.Background(), clientRequest{connect.NewRequest(&struct{}{})}) + require.NoError(t, err) + assert.True(t, spy.called) +} + +func TestWrapUnaryFlushesResponseHeader(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(tokenAuthenticator{}), + writingExtractor{}, + ) + + var got connect.AnyResponse + + next := func(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + _ = ctx + got = connect.NewResponse(&struct{}{}) + + return got, nil + } + + wrapped := connectrpcsec.NewAuthenticationInterceptor(engine).WrapUnary(next) + + _, err := wrapped(context.Background(), unaryReq("letmein")) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "applied", got.Header().Get("X-Authn-Trace")) +} + +func TestWrapStreamingHandlerAllowsAuthenticatedStream(t *testing.T) { + t.Parallel() + + spy := &recordingStream{} + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapStreamingHandler(spy.fn) + + err := wrapped(context.Background(), newStreamConn(bearerHeader("letmein"))) + require.NoError(t, err) + assert.True(t, spy.called) + assert.True(t, spy.auth.IsAuthenticated()) +} + +func TestWrapStreamingHandlerRejectsMissingCredential(t *testing.T) { + t.Parallel() + + spy := &recordingStream{} + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapStreamingHandler(spy.fn) + + err := wrapped(context.Background(), newStreamConn(nil)) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) + assert.False(t, spy.called) +} + +func TestWrapStreamingHandlerAnonymousFallback(t *testing.T) { + t.Parallel() + + spy := &recordingStream{} + wrapped := connectrpcsec.NewAuthenticationInterceptor( + newEngine(), + connectrpcsec.WithAnonymousFallback(true), + ).WrapStreamingHandler(spy.fn) + + err := wrapped(context.Background(), newStreamConn(nil)) + require.NoError(t, err) + assert.True(t, spy.called) + assert.False(t, spy.auth.IsAuthenticated()) +} + +func TestWrapStreamingHandlerFlushesResponseHeader(t *testing.T) { + t.Parallel() + + engine := security.NewEngine( + security.NewManager(tokenAuthenticator{}), + writingExtractor{}, + ) + + spy := &recordingStream{} + conn := newStreamConn(bearerHeader("letmein")) + wrapped := connectrpcsec.NewAuthenticationInterceptor(engine).WrapStreamingHandler(spy.fn) + + err := wrapped(context.Background(), conn) + require.NoError(t, err) + assert.Equal(t, "applied", conn.ResponseHeader().Get("X-Authn-Trace")) +} + +func TestWrapStreamingClientIsPassThrough(t *testing.T) { + t.Parallel() + + called := false + + next := func(_ context.Context, _ connect.Spec) connect.StreamingClientConn { + called = true + + return nil + } + + wrapped := connectrpcsec.NewAuthenticationInterceptor(newEngine()).WrapStreamingClient(next) + _ = wrapped(context.Background(), connect.Spec{}) + + assert.True(t, called) +} + +func TestInterceptorCustomErrorMapper(t *testing.T) { + t.Parallel() + + mapper := &recordingMapper{ErrorMapper: connectrpcsec.DefaultErrorMapper()} + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor( + newEngine(), + connectrpcsec.WithErrorMapper(mapper), + ).WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), unaryReq("")) + require.Error(t, err) + assert.True(t, mapper.called.Load()) +} + +// WithErrorMapper(nil) keeps the default mapper. +func TestWithErrorMapperNilKeepsDefault(t *testing.T) { + t.Parallel() + + spy := &recordingUnary{} + wrapped := connectrpcsec.NewAuthenticationInterceptor( + newEngine(), + connectrpcsec.WithErrorMapper(nil), + ).WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), unaryReq("")) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) +} + +func TestWrapUnaryIsRaceSafe(t *testing.T) { + t.Parallel() + + interceptor := connectrpcsec.NewAuthenticationInterceptor(newEngine()) + + var wg sync.WaitGroup + + for range 50 { + wg.Go(func() { + spy := &recordingUnary{} + wrapped := interceptor.WrapUnary(spy.fn) + + _, err := wrapped(context.Background(), unaryReq("letmein")) + assert.NoError(t, err) + }) + } + + wg.Wait() +} + +type recordingMapper struct { + connectrpcsec.ErrorMapper + called atomicBool +} + +func (m *recordingMapper) Map(ctx context.Context, err error) error { + m.called.Store(true) + + return m.ErrorMapper.Map(ctx, err) +} + +type atomicBool struct { + mu sync.Mutex + v bool +} + +func (a *atomicBool) Store(b bool) { a.mu.Lock(); a.v = b; a.mu.Unlock() } +func (a *atomicBool) Load() bool { a.mu.Lock(); defer a.mu.Unlock(); return a.v } + +// writingExtractor reads the Bearer token like tokenExtractor but also stages +// a response header on the carrier, exercising the interceptor's header flush. +type writingExtractor struct{} + +func (writingExtractor) Extract( + _ context.Context, + c security.Carrier, +) (security.Authentication, error) { + v := c.Get("authorization") + if v == "" { + return nil, nil + } + + c.Set("X-Authn-Trace", "applied") + + const prefix = "Bearer " + if len(v) <= len(prefix) { + return nil, nil + } + + return pendingAuth{token: v[len(prefix):]}, nil +} diff --git a/connectrpc/options.go b/connectrpc/options.go new file mode 100644 index 0000000..aaac8b4 --- /dev/null +++ b/connectrpc/options.go @@ -0,0 +1,44 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec + +// config is the consolidated interceptor configuration, built from the +// applied [Option] values. +type config struct { + errorMapper ErrorMapper + anonymousFallback bool +} + +// Option configures an interceptor. +type Option func(*config) + +// WithErrorMapper overrides the [ErrorMapper] used to translate security +// errors into Connect errors. Defaults to [DefaultErrorMapper]. +func WithErrorMapper(m ErrorMapper) Option { + return func(c *config) { + if m != nil { + c.errorMapper = m + } + } +} + +// WithAnonymousFallback controls what happens when no extractor finds a +// credential. With true, the RPC proceeds carrying the anonymous +// [security.Authentication] and downstream authorisation interceptors are +// responsible for rejecting it. Default: false (reject with +// connect.CodeUnauthenticated immediately). +func WithAnonymousFallback(allow bool) Option { + return func(c *config) { c.anonymousFallback = allow } +} + +// buildConfig applies opts onto the default config. +func buildConfig(opts ...Option) *config { + cfg := &config{errorMapper: DefaultErrorMapper()} + for _, o := range opts { + o(cfg) + } + + return cfg +} diff --git a/connectrpc/testing_helpers_test.go b/connectrpc/testing_helpers_test.go new file mode 100644 index 0000000..55b7262 --- /dev/null +++ b/connectrpc/testing_helpers_test.go @@ -0,0 +1,150 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package connectrpcsec_test + +import ( + "context" + "io" + "net/http" + + "connectrpc.com/connect" + "github.com/hyperscale-stack/security" +) + +// bearerHeader builds a request header carrying a Bearer token. +func bearerHeader(token string) http.Header { + h := http.Header{} + h.Set("Authorization", "Bearer "+token) + + return h +} + +// newEngine builds an Engine pairing tokenExtractor with tokenAuthenticator, +// minting an authentication carrying the given authorities. +func newEngine(authorities ...string) security.Engine { + return security.NewEngine( + security.NewManager(tokenAuthenticator{authorities: authorities}), + tokenExtractor{}, + ) +} + +// --- fakes mirroring the core test doubles ------------------------------ + +type fakePrincipal struct{ sub string } + +func (p fakePrincipal) Subject() string { return p.sub } + +type fakeAuth struct { + pr security.Principal + authorities []string + authenticated bool +} + +func newAuth(sub string, authorities ...string) fakeAuth { + return fakeAuth{pr: fakePrincipal{sub: sub}, authorities: authorities, authenticated: true} +} + +func (a fakeAuth) Principal() security.Principal { return a.pr } +func (a fakeAuth) Credentials() any { return nil } +func (a fakeAuth) Authorities() []string { return a.authorities } +func (a fakeAuth) IsAuthenticated() bool { return a.authenticated } +func (a fakeAuth) Name() string { return a.pr.Subject() } + +// tokenExtractor reads the "authorization" header and produces a pending +// bearer-like authentication carrying the raw token. +type tokenExtractor struct{} + +func (tokenExtractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) { + v := c.Get("authorization") + if v == "" { + return nil, nil + } + + const prefix = "Bearer " + if len(v) <= len(prefix) { + return nil, nil + } + + return pendingAuth{token: v[len(prefix):]}, nil +} + +// pendingAuth is the un-validated authentication produced by tokenExtractor. +type pendingAuth struct{ token string } + +func (a pendingAuth) Principal() security.Principal { return security.AnonymousPrincipal } +func (a pendingAuth) Credentials() any { return a.token } +func (a pendingAuth) Authorities() []string { return nil } +func (a pendingAuth) IsAuthenticated() bool { return false } +func (a pendingAuth) Name() string { return "pending" } + +// tokenAuthenticator accepts the magic token "letmein" and rejects the rest. +type tokenAuthenticator struct{ authorities []string } + +func (tokenAuthenticator) Supports(a security.Authentication) bool { + _, ok := a.(pendingAuth) + + return ok +} + +func (ta tokenAuthenticator) Authenticate( + _ context.Context, + a security.Authentication, +) (security.Authentication, error) { + p, ok := a.(pendingAuth) + if !ok { + return a, security.ErrUnsupportedCredential + } + + if p.token != "letmein" { + return a, security.ErrInvalidCredentials + } + + return newAuth("alice", ta.authorities...), nil +} + +// --- ConnectRPC test doubles -------------------------------------------- + +// fakeStreamingHandlerConn is a controllable connect.StreamingHandlerConn so +// the streaming interceptors can be exercised without a generated service. +type fakeStreamingHandlerConn struct { + spec connect.Spec + reqHdr http.Header + respHdr http.Header + trailer http.Header +} + +func newStreamConn(reqHdr http.Header) *fakeStreamingHandlerConn { + if reqHdr == nil { + reqHdr = http.Header{} + } + + return &fakeStreamingHandlerConn{ + spec: connect.Spec{Procedure: "/test.v1.Service/Stream", StreamType: connect.StreamTypeServer}, + reqHdr: reqHdr, + respHdr: http.Header{}, + trailer: http.Header{}, + } +} + +func (c *fakeStreamingHandlerConn) Spec() connect.Spec { return c.spec } +func (c *fakeStreamingHandlerConn) Peer() connect.Peer { return connect.Peer{} } +func (c *fakeStreamingHandlerConn) Receive(any) error { return io.EOF } +func (c *fakeStreamingHandlerConn) Send(any) error { return nil } +func (c *fakeStreamingHandlerConn) RequestHeader() http.Header { return c.reqHdr } +func (c *fakeStreamingHandlerConn) ResponseHeader() http.Header { return c.respHdr } +func (c *fakeStreamingHandlerConn) ResponseTrailer() http.Header { return c.trailer } + +var _ connect.StreamingHandlerConn = (*fakeStreamingHandlerConn)(nil) + +// clientRequest wraps a connect.Request and reports itself as a client-side +// call, so the WrapUnary client-skip branch can be exercised. Embedding the +// real *connect.Request promotes the unexported AnyRequest methods. +type clientRequest struct { + *connect.Request[struct{}] +} + +func (clientRequest) Spec() connect.Spec { + return connect.Spec{Procedure: "/test.v1.Service/Unary", IsClient: true} +} diff --git a/go.work b/go.work index 9a0e8cb..e5b899a 100644 --- a/go.work +++ b/go.work @@ -4,6 +4,7 @@ use ( . ./basic ./bearer + ./connectrpc ./examples ./grpc ./http diff --git a/go.work.sum b/go.work.sum index 843e673..8a7d377 100644 --- a/go.work.sum +++ b/go.work.sum @@ -7,6 +7,7 @@ cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1F cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= cloud.google.com/go/monitoring v1.21.2/go.mod h1:hS3pXvaG8KgWTSz+dAdyzPrGUYmi2Q+WFX8g2hqVEZU= cloud.google.com/go/storage v1.49.0/go.mod h1:k1eHhhpLvrPjVGfo0mOUPEJ4Y2+a/Hv5PiwehZI9qGU= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE= @@ -17,7 +18,6 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/envoyproxy/go-control-plane v0.13.1/go.mod h1:X45hY0mufo6Fd0KW3rqsGvQMw58jvjymeCzBU3mWyHw= github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= @@ -32,15 +32,10 @@ github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= @@ -74,4 +69,6 @@ google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7Qf google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= From f4b0c79ec47e08eac98236171a06bcdeb56853ed Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Thu, 21 May 2026 15:50:33 +0200 Subject: [PATCH 2/3] feat(examples): add connectrpc-bearer demo Add a runnable ConnectRPC Bearer-token example mirroring grpc-bearer: it serves the gRPC-style health service (connectrpc.com/grpchealth) behind the connectrpcsec authentication and authorization interceptors, and mints a demo JWT at start-up. The end-to-end test serves the handler over httptest and asserts the Connect protocol HTTP status mapping: a valid scoped token yields 200, a missing or garbage token 401, and a token without the scope 403. --- examples/connectrpc-bearer/main.go | 148 ++++++++++++++++++++++++ examples/connectrpc-bearer/main_test.go | 80 +++++++++++++ examples/doc.go | 11 +- examples/go.mod | 7 +- examples/go.sum | 8 +- go.work.sum | 5 +- 6 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 examples/connectrpc-bearer/main.go create mode 100644 examples/connectrpc-bearer/main_test.go diff --git a/examples/connectrpc-bearer/main.go b/examples/connectrpc-bearer/main.go new file mode 100644 index 0000000..9eb73de --- /dev/null +++ b/examples/connectrpc-bearer/main.go @@ -0,0 +1,148 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// Command connectrpc-bearer is a runnable ConnectRPC Bearer-token demo. +// +// It exposes the gRPC-style health service behind two ConnectRPC interceptors: +// one authenticates every RPC against a JWT, the other authorizes it against +// an OAuth2 scope. The process also mints a demo token at start-up. +// +// Run: +// +// go run ./connectrpc-bearer +// +// The server logs a ready-to-use token. Probe it with curl over the Connect +// protocol: +// +// curl -H "Authorization: Bearer " \ +// -H "Content-Type: application/json" \ +// -d '{}' http://localhost:9091/grpc.health.v1.Health/Check +// +// Without the token the call fails with connect.CodeUnauthenticated (HTTP +// 401); with a token that lacks the "health:read" scope it fails with +// connect.CodePermissionDenied (HTTP 403). +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "log" + "net" + "net/http" + "time" + + "connectrpc.com/connect" + "connectrpc.com/grpchealth" + "github.com/hyperscale-stack/security" + "github.com/hyperscale-stack/security/bearer" + connectrpcsec "github.com/hyperscale-stack/security/connectrpc" + jwtsec "github.com/hyperscale-stack/security/jwt" + "github.com/hyperscale-stack/security/voter" +) + +const ( + issuer = "https://issuer.example" + audience = "https://connect.example" + keyID = "demo-key" + scope = "health:read" + addr = ":9091" +) + +// minter signs a demo JWT carrying the requested scope. +type minter func(scope string) (string, error) + +// newServer builds the ConnectRPC handler with the security interceptors and +// returns a token minter sharing the server's signing key. It is separate +// from main so the end-to-end test can serve it over httptest. +func newServer() (http.Handler, minter, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate key: %w", err) + } + + signer := jwtsec.NewSigner(jwtsec.PrivateKey{ + KeyID: keyID, + Algorithm: jwtsec.EdDSA, + Key: priv, + }) + + jwks := jwtsec.NewStaticJWKS([]jwtsec.PublicKey{{ + KeyID: keyID, + Algorithm: jwtsec.EdDSA, + Key: pub, + }}) + + verifier := jwtsec.NewVerifier(jwks, + jwtsec.WithIssuer(issuer), + jwtsec.WithAudience(audience), + ) + + engine := security.NewEngine( + security.NewManager(bearer.NewAuthenticator(jwtsec.BearerVerifier(verifier, nil))), + bearer.NewExtractor(), + ) + + adm := security.NewAffirmativeDecisionManager(voter.HasScope(scope)) + + path, handler := grpchealth.NewHandler( + grpchealth.NewStaticChecker(grpchealth.HealthV1ServiceName), + connect.WithInterceptors( + connectrpcsec.NewAuthenticationInterceptor(engine), + connectrpcsec.NewAuthorizationInterceptor(adm, []security.Attribute{security.Scope(scope)}), + ), + ) + + mux := http.NewServeMux() + mux.Handle(path, handler) + + mint := func(grant string) (string, error) { + now := time.Now() + + token, err := signer.Sign(context.Background(), &jwtsec.StandardClaims{ + Issuer: issuer, + Subject: "demo-user", + Audience: jwtsec.Audience{audience}, + IssuedAt: jwtsec.NewNumericDate(now), + ExpiresAt: jwtsec.NewNumericDate(now.Add(time.Hour)), + Scope: grant, + }) + if err != nil { + return "", fmt.Errorf("mint token: %w", err) + } + + return token, nil + } + + return mux, mint, nil +} + +func main() { + handler, mint, err := newServer() + if err != nil { + log.Fatalf("connectrpc-bearer: %v", err) + } + + token, err := mint(scope) + if err != nil { + log.Fatalf("connectrpc-bearer: %v", err) + } + + var lc net.ListenConfig + + lis, err := lc.Listen(context.Background(), "tcp", addr) //nolint:gosec // G102: demo server, binding to all interfaces is intentional + if err != nil { + log.Fatalf("connectrpc-bearer: listen: %v", err) + } + + srv := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("connectrpc-bearer: listening on %s", addr) + log.Printf("connectrpc-bearer: demo token: %s", token) + log.Fatal(srv.Serve(lis)) +} diff --git a/examples/connectrpc-bearer/main_test.go b/examples/connectrpc-bearer/main_test.go new file mode 100644 index 0000000..a09b5a5 --- /dev/null +++ b/examples/connectrpc-bearer/main_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConnectRPCBearerExample(t *testing.T) { + t.Parallel() + + handler, mint, err := newServer() + require.NoError(t, err) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + goodToken, err := mint(scope) + require.NoError(t, err) + + wrongScopeToken, err := mint("other:read") + require.NoError(t, err) + + // check performs a Connect unary call against the health Check endpoint + // and returns the HTTP status code. The Connect protocol maps the error + // code onto the HTTP status (Unauthenticated -> 401, PermissionDenied -> + // 403), so the status alone tells the outcome apart. + check := func(t *testing.T, token string) int { + t.Helper() + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + srv.URL+"/grpc.health.v1.Health/Check", + strings.NewReader("{}"), + ) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + return resp.StatusCode + } + + t.Run("valid token with the right scope succeeds", func(t *testing.T) { + t.Parallel() + assert.Equal(t, http.StatusOK, check(t, goodToken)) + }) + + t.Run("missing token is unauthorized", func(t *testing.T) { + t.Parallel() + assert.Equal(t, http.StatusUnauthorized, check(t, "")) + }) + + t.Run("garbage token is unauthorized", func(t *testing.T) { + t.Parallel() + assert.Equal(t, http.StatusUnauthorized, check(t, "not-a-jwt")) + }) + + t.Run("valid token without the scope is forbidden", func(t *testing.T) { + t.Parallel() + assert.Equal(t, http.StatusForbidden, check(t, wrongScopeToken)) + }) +} diff --git a/examples/doc.go b/examples/doc.go index 281652c..0f25588 100644 --- a/examples/doc.go +++ b/examples/doc.go @@ -12,9 +12,10 @@ // // Available examples: // -// - basic-http — HTTP Basic authentication + role-based authorization. -// - bearer-jwt — JWT issuance and Bearer-token validation, scope gating. -// - grpc-bearer — gRPC unary interceptors authenticating a Bearer JWT. -// - session-web — cookie-session login form with a CSRF-protected logout. -// - oauth2 — OAuth2 authorization server + Bearer resource server. +// - basic-http — HTTP Basic authentication + role-based authorization. +// - bearer-jwt — JWT issuance and Bearer-token validation, scope gating. +// - grpc-bearer — gRPC unary interceptors authenticating a Bearer JWT. +// - connectrpc-bearer — ConnectRPC interceptors authenticating a Bearer JWT. +// - session-web — cookie-session login form with a CSRF-protected logout. +// - oauth2 — OAuth2 authorization server + Bearer resource server. package examples diff --git a/examples/go.mod b/examples/go.mod index 3daa4e9..afaa174 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -9,6 +9,8 @@ replace github.com/hyperscale-stack/security/http => ../http replace github.com/hyperscale-stack/security/grpc => ../grpc +replace github.com/hyperscale-stack/security/connectrpc => ../connectrpc + replace github.com/hyperscale-stack/security/basic => ../basic replace github.com/hyperscale-stack/security/bearer => ../bearer @@ -22,9 +24,12 @@ replace github.com/hyperscale-stack/security/session => ../session replace github.com/hyperscale-stack/security/oauth2 => ../oauth2 require ( + connectrpc.com/connect v1.20.0 + connectrpc.com/grpchealth v1.4.0 github.com/hyperscale-stack/security v0.0.0-00010101000000-000000000000 github.com/hyperscale-stack/security/basic v0.0.0-00010101000000-000000000000 github.com/hyperscale-stack/security/bearer v0.0.0-00010101000000-000000000000 + github.com/hyperscale-stack/security/connectrpc v0.0.0-00010101000000-000000000000 github.com/hyperscale-stack/security/grpc v0.0.0-00010101000000-000000000000 github.com/hyperscale-stack/security/http v0.0.0-00010101000000-000000000000 github.com/hyperscale-stack/security/jwt v0.0.0-00010101000000-000000000000 @@ -51,6 +56,6 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect - google.golang.org/protobuf v1.36.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index a629241..5684fec 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,3 +1,7 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +connectrpc.com/grpchealth v1.4.0 h1:MJC96JLelARPgZTiRF9KRfY/2N9OcoQvF2EWX07v2IE= +connectrpc.com/grpchealth v1.4.0/go.mod h1:WhW6m1EzTmq3Ky1FE8EfkIpSDc6TfUx2M2KqZO3ts/Q= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -49,8 +53,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/go.work.sum b/go.work.sum index 8a7d377..b6f4ffa 100644 --- a/go.work.sum +++ b/go.work.sum @@ -7,7 +7,8 @@ cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1F cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= cloud.google.com/go/monitoring v1.21.2/go.mod h1:hS3pXvaG8KgWTSz+dAdyzPrGUYmi2Q+WFX8g2hqVEZU= cloud.google.com/go/storage v1.49.0/go.mod h1:k1eHhhpLvrPjVGfo0mOUPEJ4Y2+a/Hv5PiwehZI9qGU= -connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +connectrpc.com/grpchealth v1.4.0 h1:MJC96JLelARPgZTiRF9KRfY/2N9OcoQvF2EWX07v2IE= +connectrpc.com/grpchealth v1.4.0/go.mod h1:WhW6m1EzTmq3Ky1FE8EfkIpSDc6TfUx2M2KqZO3ts/Q= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE= @@ -69,6 +70,4 @@ google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7Qf google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= From e03f2f8ec9adcca6afcaf2c16ed9601b8feae0e0 Mon Sep 17 00:00:00 2001 From: Axel Etcheverry Date: Thu, 21 May 2026 16:03:57 +0200 Subject: [PATCH 3/3] docs(connectrpc): document the ConnectRPC adapter module Add the connectrpc/ module to the workspace layout tables, the dependency policy, the README module list, the CHANGELOG, and the OTel span catalog (connectrpcsec.Authenticate / connectrpcsec.Authorize). grpc/go.mod picks up the workspace-aligned google.golang.org/protobuf v1.36.11 via go work sync. --- CHANGELOG.md | 6 +++++- CLAUDE.md | 13 +++++++------ MIGRATION.md | 6 ++++-- README.md | 7 ++++--- docs/architecture.md | 17 ++++++++++++----- docs/observability.md | 25 ++++++++++++++++++------- grpc/go.mod | 2 +- grpc/go.sum | 3 +-- 8 files changed, 52 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee446a..37ae71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ legacy packages (`authentication/`, `authorization/`, the in-tree - **gRPC adapter** (`grpcsec`): unary and stream server interceptors, `UnaryAuthorize`/`StreamAuthorize`, a `metadata.MD` carrier, and an `ErrorMapper` to `codes.Code`. +- **ConnectRPC adapter** (`connectrpcsec`): `NewAuthenticationInterceptor` + and `NewAuthorizationInterceptor` returning `connect.Interceptor` values + (unary + streaming), an `http.Header` carrier, and an `ErrorMapper` to + `connect.Code`. - **Schemes**: `basic` (HTTP Basic extractor + authenticator) and `bearer` (Bearer extractor + pluggable `TokenVerifier`). - **Password hashing** (`password`): `Hasher` interface with bcrypt and @@ -56,7 +60,7 @@ legacy packages (`authentication/`, `authorization/`, the in-tree rotation, a `Manager` (Login/Get/Touch/Rotate/Logout), and a synchronizer-token CSRF helper. - **Observability**: OpenTelemetry spans emitted directly by the core, - `httpsec`, `grpcsec`, `jwtsec`, and `session`. See + `httpsec`, `grpcsec`, `connectrpcsec`, `jwtsec`, and `session`. See [docs/observability.md](docs/observability.md). - **Documentation**: `docs/architecture.md`, `docs/observability.md`, `docs/security-considerations.md`, `docs/migration-from-v0.md`, and a diff --git a/CLAUDE.md b/CLAUDE.md index 63b525d..6b5c574 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ is intentionally skipped in CI. | `.` | `security` | Core transport-agnostic primitives | | `./http` | `.../http` → `httpsec` | `net/http` adapter (middleware, `Authorize`, carrier) | | `./grpc` | `.../grpc` → `grpcsec` | gRPC unary/stream interceptors + `Authorize` | +| `./connectrpc` | `.../connectrpc` → `connectrpcsec` | ConnectRPC auth + authorize interceptors | | `./basic` | `.../basic` | HTTP Basic extractor + authenticator | | `./bearer` | `.../bearer` | Bearer extractor + `TokenVerifier` authenticator | | `./password` | `.../password` | BCrypt + Argon2id hashers (`NeedsRehash`) | @@ -93,7 +94,7 @@ is intentionally skipped in CI. | `./oauth2/storage/memory` | `.../oauth2/storage/memory` | In-memory `oauth2.Storage` — **package of `oauth2`** | | `./oauth2/store/sql` | `.../oauth2/store/sql` | Production storage on `database/sql` (PG/MySQL/SQLite) | | `./oauth2/store/redis` | `.../oauth2/store/redis` | Production storage on Redis (Lua atomicity) | -| `./examples` | `.../examples` | Runnable demos: basic-http, bearer-jwt, grpc-bearer, session-web, oauth2 | +| `./examples` | `.../examples` | Runnable demos: basic-http, bearer-jwt, grpc-bearer, connectrpc-bearer, session-web, oauth2 | | `./internal/integrations` | (private) | Cross-module end-to-end tests | `oauth2/storage/memory` is **not** a standalone module — it is a sub-package @@ -102,8 +103,8 @@ of `oauth2`. The other rows are independent modules (own `go.mod`). **The dependency direction is a hard rule** (enforced by review, see `MIGRATION.md`): the **core (`.`) must depend only on stdlib + `go.opentelemetry.io/otel`** (+ `testify` in its own tests). It MUST NOT -import gRPC, JOSE/JWT libs, OAuth2, Redis, SQL drivers, HTTP routers, or -concrete loggers. Adapters depend on the core, never the reverse. The +import gRPC, ConnectRPC, JOSE/JWT libs, OAuth2, Redis, SQL drivers, HTTP +routers, or concrete loggers. Adapters depend on the core, never the reverse. The `oauth2` module has **no hard dependency on `jwt`** — JWT access tokens are wired via an adapter (`jwt` depends on `oauth2`, not the other way). When adding code, check the allowed-dependency list in `MIGRATION.md` before @@ -163,7 +164,7 @@ Conventions baked into the core: - **OTel spans live directly in each module** — there is intentionally no `EventSink` abstraction and no separate `otel/` module. The core uses scope `github.com/hyperscale-stack/security`; each instrumented module - (`httpsec`, `grpcsec`, `jwtsec`, `session`) uses its own. See + (`httpsec`, `grpcsec`, `connectrpcsec`, `jwtsec`, `session`) uses its own. See `docs/observability.md` for the span catalog. ## OAuth2 server (`oauth2/`) @@ -187,8 +188,8 @@ configurable via `ServerConfig.RoutePrefix`). the shared `oauth2/storetest` conformance suite. `examples/oauth2/main.go` is the canonical wiring example for the v2 stack; -`examples/` also has `basic-http`, `bearer-jwt`, `grpc-bearer`, and -`session-web` demos. +`examples/` also has `basic-http`, `bearer-jwt`, `grpc-bearer`, +`connectrpc-bearer`, and `session-web` demos. ## Tooling caveats diff --git a/MIGRATION.md b/MIGRATION.md index ad696a6..0fbc407 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,6 +12,7 @@ released on its own cadence. | `.` | `github.com/hyperscale-stack/security` | Core: transport-agnostic primitives (Authentication, Engine, Voter…) | | `./http` | `github.com/hyperscale-stack/security/http` | `httpsec` — `net/http` adapter | | `./grpc` | `github.com/hyperscale-stack/security/grpc` | `grpcsec` — gRPC unary/stream interceptors | +| `./connectrpc` | `github.com/hyperscale-stack/security/connectrpc` | `connectrpcsec` — ConnectRPC auth + authorize interceptors | | `./basic` | `github.com/hyperscale-stack/security/basic` | HTTP Basic extractor + authenticator | | `./bearer` | `github.com/hyperscale-stack/security/bearer` | Bearer extractor + `TokenVerifier`-based authenticator | | `./password` | `github.com/hyperscale-stack/security/password` | BCrypt + Argon2id hashers | @@ -39,6 +40,7 @@ own tests). core (.) ← stdlib + go.opentelemetry.io/otel http/ ← core + otel grpc/ ← core + otel + google.golang.org/grpc +connectrpc/ ← core + otel + connectrpc.com/connect basic/ ← core + password bearer/ ← core password/ ← golang.org/x/crypto @@ -52,8 +54,8 @@ examples/ ← may depend on every module above (`oauth2/storage/memory` is a sub-package of the `oauth2` module.) ``` -The core MUST NOT depend on: gRPC, JWT/JOSE libs, OAuth2, Redis, SQL drivers, -HTTP routers, or concrete loggers. Its direct dependency set is exactly +The core MUST NOT depend on: gRPC, ConnectRPC, JWT/JOSE libs, OAuth2, Redis, +SQL drivers, HTTP routers, or concrete loggers. Its direct dependency set is exactly stdlib + `go.opentelemetry.io/otel` (+ `stretchr/testify` scoped to its own tests). The policy is enforced by review. diff --git a/README.md b/README.md index 0bceb39..9ec5a32 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Hyperscale security [![Last release](https://img.shields.io/github/release/hyper | master | [![Build Status](https://github.com/hyperscale-stack/security/workflows/Go/badge.svg?branch=master)](https://github.com/hyperscale-stack/security/actions?query=workflow%3AGo) | [![Coveralls](https://img.shields.io/coveralls/hyperscale-stack/security/master.svg)](https://coveralls.io/github/hyperscale-stack/security?branch=master) | A transport-agnostic authentication and authorization toolkit for Go — -HTTP and gRPC, OAuth2, JWT, sessions, and a composable Voter-based access -model. It is shipped as a multi-module workspace so you import only what -you need. +HTTP, gRPC and ConnectRPC, OAuth2, JWT, sessions, and a composable +Voter-based access model. It is shipped as a multi-module workspace so you +import only what you need. ## Modules @@ -19,6 +19,7 @@ you need. | `github.com/hyperscale-stack/security` | Core: `Authentication`, `Engine`, `Manager`, `Voter`, ADM | | `…/security/http` | `httpsec` — `net/http` middleware + authorization | | `…/security/grpc` | `grpcsec` — unary/stream interceptors | +| `…/security/connectrpc` | `connectrpcsec` — ConnectRPC auth + authorize interceptors | | `…/security/basic` | HTTP Basic extractor + authenticator | | `…/security/bearer` | Bearer extractor + `TokenVerifier` authenticator | | `…/security/password` | BCrypt + Argon2id hashers (`NeedsRehash`) | diff --git a/docs/architecture.md b/docs/architecture.md index 0f23b3b..85f5ee0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ transitive dependencies. ## Design goals - **Transport-agnostic core.** The authentication pipeline knows nothing - about `net/http` or gRPC. Transports are thin adapters. + about `net/http`, gRPC, or ConnectRPC. Transports are thin adapters. - **Small, immutable interfaces.** `Authentication` is read-only; state changes produce new values. No mutable `interface{}` credential bag. - **Composable authorization.** A Voter / `AccessDecisionManager` model @@ -26,6 +26,7 @@ transitive dependencies. | `.` | `github.com/hyperscale-stack/security` | Core: `Authentication`, `Engine`, `Manager`, `Voter`, `AccessDecisionManager` | | `./http` | `…/security/http` | `httpsec` — `net/http` middleware + carrier | | `./grpc` | `…/security/grpc` | `grpcsec` — unary/stream interceptors + carrier | +| `./connectrpc` | `…/security/connectrpc` | `connectrpcsec` — ConnectRPC auth + authorize interceptors | | `./basic` | `…/security/basic` | HTTP Basic extractor + authenticator | | `./bearer` | `…/security/bearer` | Bearer extractor + `TokenVerifier`-based authenticator | | `./password` | `…/security/password` | BCrypt + Argon2id hashers (`NeedsRehash`) | @@ -46,6 +47,7 @@ separate module) — it ships an in-memory `oauth2.Storage` for dev and tests. core (.) ← stdlib + go.opentelemetry.io/otel http/ ← core + otel grpc/ ← core + otel + google.golang.org/grpc +connectrpc/ ← core + otel + connectrpc.com/connect basic/ ← core + password bearer/ ← core password/ ← golang.org/x/crypto @@ -57,9 +59,9 @@ oauth2/store/redis/ ← oauth2 + github.com/redis/go-redis/v9 examples/ ← may depend on every module above ``` -The core MUST NOT depend on gRPC, JOSE libraries, OAuth2, Redis, SQL -drivers, HTTP routers, or concrete loggers. This boundary is what keeps the -core importable from any transport. +The core MUST NOT depend on gRPC, ConnectRPC, JOSE libraries, OAuth2, +Redis, SQL drivers, HTTP routers, or concrete loggers. This boundary is +what keeps the core importable from any transport. ## The authentication pipeline @@ -80,7 +82,8 @@ Carrier ──▶ Extractor ──▶ Authentication (pending) - **`Carrier`** abstracts a transport message — read credentials, write challenges. `httpsec.Carrier` wraps `*http.Request`/`http.ResponseWriter`; - `grpcsec.Carrier` wraps `metadata.MD`. + `grpcsec.Carrier` wraps `metadata.MD`; `connectrpcsec.Carrier` wraps + `http.Header`. - **`Extractor`** pulls raw, unauthenticated credentials from a `Carrier`. Returns `(nil, nil)` when its scheme is absent. - **`Authenticator`** validates a pending `Authentication` and returns an @@ -128,6 +131,10 @@ and a `Carrier`, then map security errors to transport responses. - **`grpcsec`** — `UnaryServerInterceptor` / `StreamServerInterceptor` authenticate every RPC; `UnaryAuthorize` / `StreamAuthorize` enforce an ADM. `ErrorMapper` turns sentinels into `codes.Code`. +- **`connectrpcsec`** — `NewAuthenticationInterceptor` authenticates every + RPC; `NewAuthorizationInterceptor` enforces an ADM. Both return a + `connect.Interceptor` covering unary and streaming. `ErrorMapper` turns + sentinels into `connect.Code`. ## OAuth2 diff --git a/docs/observability.md b/docs/observability.md index eed0f34..b2555d4 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -10,13 +10,14 @@ application; the library uses the global provider via `otel.Tracer`. Each module reports under a stable instrumentation scope (the tracer name): -| Module | Instrumentation scope | -| --------- | ---------------------------------------------- | -| core | `github.com/hyperscale-stack/security` | -| `httpsec` | `github.com/hyperscale-stack/security/http` | -| `grpcsec` | `github.com/hyperscale-stack/security/grpc` | -| `jwtsec` | `github.com/hyperscale-stack/security/jwt` | -| `session` | `github.com/hyperscale-stack/security/session` | +| Module | Instrumentation scope | +| --------------- | ------------------------------------------------- | +| core | `github.com/hyperscale-stack/security` | +| `httpsec` | `github.com/hyperscale-stack/security/http` | +| `grpcsec` | `github.com/hyperscale-stack/security/grpc` | +| `connectrpcsec` | `github.com/hyperscale-stack/security/connectrpc` | +| `jwtsec` | `github.com/hyperscale-stack/security/jwt` | +| `session` | `github.com/hyperscale-stack/security/session` | The `basic`, `bearer`, `password` and `oauth2` modules do not open spans of their own — keeping them free of a direct `go.opentelemetry.io/otel` @@ -60,6 +61,16 @@ it delegates to `security.AccessDecisionManager.Decide`. `grpcsec` deliberately does **not** open an `rpc` span — that belongs to `otelgrpc`, which you compose alongside these interceptors. +### ConnectRPC — `github.com/hyperscale-stack/security/connectrpc` + +| Span | When | Attributes | Error status | +| ---------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | ----------------------- | +| `connectrpcsec.Authenticate` | Per RPC, unary and streaming interceptors | `rpc.method` (string), `security.authenticated` (bool) | inherited from the core | +| `connectrpcsec.Authorize` | The authorization interceptor | none directly — delegates to `security.AccessDecisionManager.Decide` | inherited from the core | + +`connectrpcsec` deliberately does **not** open an `rpc` span — that belongs +to `otelconnect`, which you compose alongside these interceptors. + ### JWT — `github.com/hyperscale-stack/security/jwt` | Span | When | Attributes | Error status | diff --git a/grpc/go.mod b/grpc/go.mod index 688ca4f..473e787 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -23,7 +23,7 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect - google.golang.org/protobuf v1.36.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/grpc/go.sum b/grpc/go.sum index 87bdc7d..8122732 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -45,8 +45,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=