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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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/`)
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`) |
Expand Down
97 changes: 97 additions & 0 deletions connectrpc/authorize.go
Original file line number Diff line number Diff line change
@@ -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
}
135 changes: 135 additions & 0 deletions connectrpc/authorize_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading