Skip to content

Commit 70b073b

Browse files
committed
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.
1 parent 254b9db commit 70b073b

16 files changed

Lines changed: 1278 additions & 6 deletions

connectrpc/authorize.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
package connectrpcsec
6+
7+
import (
8+
"context"
9+
10+
"connectrpc.com/connect"
11+
"github.com/hyperscale-stack/security"
12+
"go.opentelemetry.io/otel"
13+
)
14+
15+
// AuthorizationInterceptor is a [connect.Interceptor] that enforces a
16+
// [security.AccessDecisionManager] against the request's
17+
// [security.Authentication].
18+
//
19+
// Install it AFTER [NewAuthenticationInterceptor] in the
20+
// connect.WithInterceptors(...) list so the context already carries an
21+
// authentication: the first interceptor of the list is the outermost, so
22+
// connect.WithInterceptors(authn, authz) runs authn (which enriches the
23+
// context) before authz.
24+
//
25+
// On grant the handler runs; on deny the configured [ErrorMapper] translates
26+
// the decision (typically connect.CodePermissionDenied).
27+
type AuthorizationInterceptor struct {
28+
adm security.AccessDecisionManager
29+
attrs []security.Attribute
30+
cfg *config
31+
}
32+
33+
// NewAuthorizationInterceptor builds a [connect.Interceptor] that enforces adm
34+
// against attrs for every inbound unary and streaming RPC.
35+
func NewAuthorizationInterceptor(
36+
adm security.AccessDecisionManager,
37+
attrs []security.Attribute,
38+
opts ...Option,
39+
) *AuthorizationInterceptor {
40+
return &AuthorizationInterceptor{adm: adm, attrs: attrs, cfg: buildConfig(opts...)}
41+
}
42+
43+
// Compile-time check.
44+
var _ connect.Interceptor = (*AuthorizationInterceptor)(nil)
45+
46+
// WrapUnary implements [connect.Interceptor]. Outbound client calls are passed
47+
// through untouched; inbound handler calls are authorized.
48+
func (i *AuthorizationInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
49+
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
50+
if req.Spec().IsClient {
51+
return next(ctx, req) //nolint:wrapcheck // pass-through: the client error is the terminal value
52+
}
53+
54+
if err := decide(ctx, i.adm, i.attrs); err != nil {
55+
return nil, i.cfg.errorMapper.Map(ctx, err)
56+
}
57+
58+
return next(ctx, req) //nolint:wrapcheck // the handler / connect error is the terminal wire value
59+
}
60+
}
61+
62+
// WrapStreamingHandler implements [connect.Interceptor]. It runs the access
63+
// decision before the handler runs.
64+
func (i *AuthorizationInterceptor) WrapStreamingHandler(
65+
next connect.StreamingHandlerFunc,
66+
) connect.StreamingHandlerFunc {
67+
return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
68+
if err := decide(ctx, i.adm, i.attrs); err != nil {
69+
return i.cfg.errorMapper.Map(ctx, err)
70+
}
71+
72+
return next(ctx, conn) //nolint:wrapcheck // the handler error is the terminal wire value
73+
}
74+
}
75+
76+
// WrapStreamingClient implements [connect.Interceptor] as a pass-through; the
77+
// access decision is server-side only.
78+
func (i *AuthorizationInterceptor) WrapStreamingClient(
79+
next connect.StreamingClientFunc,
80+
) connect.StreamingClientFunc {
81+
return next
82+
}
83+
84+
// decide pulls the Authentication from ctx and runs the ADM, wrapping the call
85+
// in a "connectrpcsec.Authorize" span.
86+
func decide(ctx context.Context, adm security.AccessDecisionManager, attrs []security.Attribute) error {
87+
ctx, span := otel.Tracer(tracerName).Start(ctx, "connectrpcsec.Authorize")
88+
defer span.End()
89+
90+
auth, _ := security.FromContext(ctx)
91+
92+
if err := adm.Decide(ctx, auth, attrs); err != nil {
93+
return err //nolint:wrapcheck // security.* sentinels pass through to the ErrorMapper
94+
}
95+
96+
return nil
97+
}

connectrpc/authorize_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
package connectrpcsec_test
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"connectrpc.com/connect"
12+
"github.com/hyperscale-stack/security"
13+
connectrpcsec "github.com/hyperscale-stack/security/connectrpc"
14+
"github.com/hyperscale-stack/security/voter"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// adminADM grants only when the principal holds the ADMIN role.
20+
func adminADM() security.AccessDecisionManager {
21+
return security.NewAffirmativeDecisionManager(voter.HasRole("ADMIN"))
22+
}
23+
24+
var adminAttrs = []security.Attribute{security.Role("ADMIN")}
25+
26+
// chainUnary composes the authentication and authorization interceptors the
27+
// same way connect.WithInterceptors(authn, authz) would: authn is the
28+
// outermost, so it enriches the context before authz reads it.
29+
func chainUnary(
30+
authn *connectrpcsec.AuthenticationInterceptor,
31+
authz *connectrpcsec.AuthorizationInterceptor,
32+
handler connect.UnaryFunc,
33+
) connect.UnaryFunc {
34+
return authn.WrapUnary(authz.WrapUnary(handler))
35+
}
36+
37+
func TestUnaryAuthorizeGrantsWhenRolePresent(t *testing.T) {
38+
t.Parallel()
39+
40+
spy := &recordingUnary{}
41+
wrapped := chainUnary(
42+
connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_ADMIN")),
43+
connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs),
44+
spy.fn,
45+
)
46+
47+
resp, err := wrapped(context.Background(), unaryReq("letmein"))
48+
require.NoError(t, err)
49+
assert.NotNil(t, resp)
50+
assert.True(t, spy.called)
51+
}
52+
53+
func TestUnaryAuthorizeDeniesWhenRoleMissing(t *testing.T) {
54+
t.Parallel()
55+
56+
spy := &recordingUnary{}
57+
wrapped := chainUnary(
58+
connectrpcsec.NewAuthenticationInterceptor(newEngine()),
59+
connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs),
60+
spy.fn,
61+
)
62+
63+
_, err := wrapped(context.Background(), unaryReq("letmein"))
64+
require.Error(t, err)
65+
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
66+
assert.False(t, spy.called)
67+
}
68+
69+
func TestUnaryAuthorizeDeniesAnonymous(t *testing.T) {
70+
t.Parallel()
71+
72+
spy := &recordingUnary{}
73+
// No authentication in the context: the voter denies the anonymous caller.
74+
wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapUnary(spy.fn)
75+
76+
_, err := wrapped(context.Background(), unaryReq("letmein"))
77+
require.Error(t, err)
78+
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
79+
assert.False(t, spy.called)
80+
}
81+
82+
func TestUnaryAuthorizeSkipsClientCall(t *testing.T) {
83+
t.Parallel()
84+
85+
spy := &recordingUnary{}
86+
wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapUnary(spy.fn)
87+
88+
_, err := wrapped(context.Background(), clientRequest{connect.NewRequest(&struct{}{})})
89+
require.NoError(t, err)
90+
assert.True(t, spy.called)
91+
}
92+
93+
func TestStreamAuthorizeGrantsWhenRolePresent(t *testing.T) {
94+
t.Parallel()
95+
96+
spy := &recordingStream{}
97+
authn := connectrpcsec.NewAuthenticationInterceptor(newEngine("ROLE_ADMIN"))
98+
authz := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs)
99+
wrapped := authn.WrapStreamingHandler(authz.WrapStreamingHandler(spy.fn))
100+
101+
err := wrapped(context.Background(), newStreamConn(bearerHeader("letmein")))
102+
require.NoError(t, err)
103+
assert.True(t, spy.called)
104+
}
105+
106+
func TestStreamAuthorizeDeniesWhenRoleMissing(t *testing.T) {
107+
t.Parallel()
108+
109+
spy := &recordingStream{}
110+
authn := connectrpcsec.NewAuthenticationInterceptor(newEngine())
111+
authz := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs)
112+
wrapped := authn.WrapStreamingHandler(authz.WrapStreamingHandler(spy.fn))
113+
114+
err := wrapped(context.Background(), newStreamConn(bearerHeader("letmein")))
115+
require.Error(t, err)
116+
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
117+
assert.False(t, spy.called)
118+
}
119+
120+
func TestStreamAuthorizeIsPassThroughForClient(t *testing.T) {
121+
t.Parallel()
122+
123+
called := false
124+
125+
next := func(_ context.Context, _ connect.Spec) connect.StreamingClientConn {
126+
called = true
127+
128+
return nil
129+
}
130+
131+
wrapped := connectrpcsec.NewAuthorizationInterceptor(adminADM(), adminAttrs).WrapStreamingClient(next)
132+
_ = wrapped(context.Background(), connect.Spec{})
133+
134+
assert.True(t, called)
135+
}

connectrpc/carrier.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
package connectrpcsec
6+
7+
import (
8+
"net/http"
9+
10+
"github.com/hyperscale-stack/security"
11+
)
12+
13+
// Carrier adapts a ConnectRPC request to [security.Carrier].
14+
//
15+
// Reads consult the request header. ConnectRPC speaks net/http, so the keys
16+
// follow http.Header semantics (case-insensitive, canonicalised via
17+
// textproto.CanonicalMIMEHeaderKey); the conventional "Authorization"
18+
// spelling works directly with no manual case folding.
19+
//
20+
// Writes accumulate in a private staged header that the interceptor flushes
21+
// onto the live response header once one is available — immediately for a
22+
// streaming handler (conn.ResponseHeader() is live) and after the handler
23+
// runs for a unary call (the response Header()). This lets an ErrorMapper or
24+
// an extractor attach, e.g., a diagnostic header alongside the response.
25+
//
26+
// Carrier is NOT safe for concurrent use; one instance per RPC.
27+
type Carrier struct {
28+
in http.Header
29+
out http.Header
30+
}
31+
32+
// NewCarrier builds a Carrier from a request header. When h is nil (a unit
33+
// test, a non-Connect caller) the read side is simply empty.
34+
func NewCarrier(h http.Header) *Carrier {
35+
if h == nil {
36+
h = http.Header{}
37+
}
38+
39+
return &Carrier{in: h, out: http.Header{}}
40+
}
41+
42+
// Get implements [security.Carrier]. Returns the first value for key.
43+
func (c *Carrier) Get(key string) string {
44+
return c.in.Get(key)
45+
}
46+
47+
// Values implements [security.Carrier].
48+
func (c *Carrier) Values(key string) []string {
49+
return c.in.Values(key)
50+
}
51+
52+
// Set implements [security.Carrier]. The value is staged in the response
53+
// header; the interceptor flushes it onto the live response.
54+
func (c *Carrier) Set(key, value string) {
55+
c.out.Set(key, value)
56+
}
57+
58+
// Add implements [security.Carrier].
59+
func (c *Carrier) Add(key, value string) {
60+
c.out.Add(key, value)
61+
}
62+
63+
// ResponseHeader returns the staged response header. The interceptor calls it
64+
// after the engine / handler run and, when non-empty, copies it onto the live
65+
// response header.
66+
func (c *Carrier) ResponseHeader() http.Header { return c.out }
67+
68+
// Compile-time check.
69+
var _ security.Carrier = (*Carrier)(nil)

connectrpc/carrier_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
package connectrpcsec_test
6+
7+
import (
8+
"net/http"
9+
"testing"
10+
11+
connectrpcsec "github.com/hyperscale-stack/security/connectrpc"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestCarrierReads(t *testing.T) {
16+
t.Parallel()
17+
18+
hdr := http.Header{"Authorization": {"Bearer one", "Bearer two"}}
19+
carrier := connectrpcsec.NewCarrier(hdr)
20+
21+
// Get returns the first value; lookups are case-insensitive.
22+
assert.Equal(t, "Bearer one", carrier.Get("Authorization"))
23+
assert.Equal(t, "Bearer one", carrier.Get("authorization"))
24+
25+
// Values returns every value.
26+
assert.Equal(t, []string{"Bearer one", "Bearer two"}, carrier.Values("authorization"))
27+
28+
// Absent keys yield the zero values.
29+
assert.Empty(t, carrier.Get("X-Absent"))
30+
assert.Nil(t, carrier.Values("X-Absent"))
31+
}
32+
33+
func TestCarrierWritesResponseHeader(t *testing.T) {
34+
t.Parallel()
35+
36+
carrier := connectrpcsec.NewCarrier(http.Header{})
37+
38+
carrier.Set("X-Trace", "first")
39+
carrier.Set("X-Trace", "second") // Set replaces.
40+
carrier.Add("X-Trace", "third") // Add appends.
41+
42+
assert.Equal(t, []string{"second", "third"}, carrier.ResponseHeader().Values("X-Trace"))
43+
}
44+
45+
func TestCarrierNilHeader(t *testing.T) {
46+
t.Parallel()
47+
48+
carrier := connectrpcsec.NewCarrier(nil)
49+
50+
assert.Empty(t, carrier.Get("Authorization"))
51+
assert.Nil(t, carrier.Values("Authorization"))
52+
53+
// Writes still work against the staged header.
54+
carrier.Set("X-Trace", "value")
55+
assert.Equal(t, "value", carrier.ResponseHeader().Get("X-Trace"))
56+
}

connectrpc/doc.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
// Package connectrpcsec is the ConnectRPC transport adapter for the security
6+
// core.
7+
//
8+
// It exposes connect.Interceptor values that hand the request headers (the
9+
// Carrier) to the core Engine and map security errors to the appropriate
10+
// Connect error codes (connect.CodeUnauthenticated, connect.CodePermissionDenied,
11+
// …).
12+
//
13+
// ConnectRPC has a single Interceptor interface covering both unary and
14+
// streaming RPCs, installed once via connect.WithInterceptors. The adapter
15+
// therefore exposes two interceptors instead of the four gRPC-style
16+
// constructors: NewAuthenticationInterceptor authenticates every inbound RPC
17+
// and NewAuthorizationInterceptor enforces an access decision manager.
18+
//
19+
// Allowed dependencies:
20+
// - github.com/hyperscale-stack/security (core)
21+
// - connectrpc.com/connect
22+
// - go.opentelemetry.io/otel
23+
// - stdlib only
24+
package connectrpcsec

0 commit comments

Comments
 (0)