fix(cordis): fail-closed auth guards for user/message_gateway/admin (#1)

* autoresearch iter 23: fail-closed auth guarding for user/message_gateway

Both plugins resolve contracts.AuthService in Apply to build their route
middleware, but declared only DBService in Inject(). The kernel gates a
plugin's Apply solely on declared deps, and cmd/app.go registers user
before auth, so user mounted first, core.Inject failed, and loginMW
silently degraded to a pass-through closure — leaving /api/v1/user
change-password, profile and access-tokens unguarded. message_gateway
was saved only by its later list position.

Declare AuthService in Inject() for both, and pin the property with a
reconcile-level test that mirrors production registration order and
asserts the real auth middleware reaches the route table.

* autoresearch iter 24: make auth middleware fallbacks fail closed

user, message_gateway and admin each fell back to a c.Next() closure when
contracts.AuthService could not be resolved, so a route would be served as
if authenticated. For admin this is reachable at runtime: OnDispose calls
service.ResetServices(), which nils the global the per-request guard reads,
so requests still in flight during dispose bypass authorization entirely.

Add ginutil.AuthUnavailable() and bind every fallback to it, with a test
that drives each plugin's registered guard without an auth service present
and asserts the request is aborted rather than passed through.

* chore(autoresearch): log iter 23 (fail-open auth ordering, proven)

* autoresearch iter 24 follow-up: let staticcheck infer the auth guard type

* docs(autoresearch): log iters 24-25 and lessons 9-11 (declared-dep bug class, gate discipline)
This commit is contained in:
Ryan
2026-08-29 11:21:04 +08:00
committed by GitHub
parent b624de9620
commit 53ae3007d0
7 changed files with 340 additions and 7 deletions
+4 -2
View File
@@ -8,6 +8,7 @@ import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"Wavelet/plugins/domain/admin/handler"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
@@ -142,6 +143,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
})
// 0a. Dynamic Auth Middlewares
denyAuth := ginutil.AuthUnavailable()
var loginMW gin.HandlerFunc = func(c *gin.Context) {
if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil {
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
@@ -149,7 +151,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return
}
}
c.Next()
denyAuth(c)
}
var adminMW gin.HandlerFunc = func(c *gin.Context) {
if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil {
@@ -158,7 +160,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return
}
}
c.Next()
denyAuth(c)
}
// 0b. Register migrations
@@ -0,0 +1,219 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package domain_test
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/user"
)
// The kernel only gates a plugin's Apply on the services it DECLARES in Inject,
// so a plugin that consumes contracts.AuthService without declaring it can be
// mounted before auth exists. Those plugins fall back to a pass-through
// middleware, which silently un-guards their routes.
func sentinelLogin(c *gin.Context) { c.Next() }
func sentinelAdmin(c *gin.Context) { c.Next() }
func sentinelNoToken(c *gin.Context) { c.Next() }
type stubAuthService struct{ contracts.AuthService }
func (stubAuthService) RequireAuthMiddleware() any { return gin.HandlerFunc(sentinelLogin) }
func (stubAuthService) RequireAdminMiddleware() any { return gin.HandlerFunc(sentinelAdmin) }
func (stubAuthService) DisallowTokenAuthMiddleware() any { return gin.HandlerFunc(sentinelNoToken) }
type stubDBService struct{ contracts.DBService }
// providerPlugin publishes a contract into the container at Apply time, the way
// the real infra and domain plugins do.
type providerPlugin struct {
name string
provide func(*core.Context) error
}
func (p providerPlugin) Name() string { return p.name }
func (p providerPlugin) Apply(ctx *core.Context) error { return p.provide(ctx) }
func dbProvider() core.Plugin {
return providerPlugin{name: "stub-database", provide: func(ctx *core.Context) error {
core.Provide[contracts.DBService](ctx, stubDBService{})
return nil
}}
}
func authProvider() core.Plugin {
return providerPlugin{name: "stub-auth", provide: func(ctx *core.Context) error {
core.Provide[contracts.AuthService](ctx, stubAuthService{})
return nil
}}
}
func findRoute(routes []extpoints.RouteDefinition, method, path string) (extpoints.RouteDefinition, bool) {
for _, rd := range routes {
if rd.Method == method && rd.Path == path {
return rd, true
}
}
return extpoints.RouteDefinition{}, false
}
// codePointer resolves the function code pointer of a registered handler so
// identity can be compared without depending on gin internals.
func codePointer(handler any) uintptr {
switch fn := handler.(type) {
case gin.HandlerFunc:
return reflect.ValueOf(fn).Pointer()
case func(*gin.Context):
return reflect.ValueOf(fn).Pointer()
default:
return 0
}
}
func assertsMiddleware(t *testing.T, routes []extpoints.RouteDefinition, method, path string, want func(*gin.Context)) {
t.Helper()
rd, ok := findRoute(routes, method, path)
if !ok {
t.Fatalf("route %s %s was never registered", method, path)
}
wantPtr := codePointer(gin.HandlerFunc(want))
for _, h := range rd.Handlers {
if codePointer(h) == wantPtr {
return
}
}
for _, m := range rd.Middlewares {
if codePointer(m) == wantPtr {
return
}
}
t.Errorf("route %s %s is not guarded by the auth middleware: handlers=%d middlewares=%d",
method, path, len(rd.Handlers), len(rd.Middlewares))
}
// TestRoutesMountedBeforeAuthServiceAreGuarded 回归:cmd/app.go 把 user/message_gateway
// 注册在 auth 之前,若插件未在 Inject 中声明 contracts.AuthService,reconcile 会先
// Apply 它们,导致鉴权中间件退化为透传闭包,路由完全不受保护。
func TestRoutesMountedBeforeAuthServiceAreGuarded(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
// Registration order mirrors cmd/app.go: the auth provider comes last.
app := core.NewApp(
core.WithContext(ctx),
core.WithPlugins(
dbProvider(),
user.New(),
message_gateway.New(),
authProvider(),
),
)
require.NoError(t, app.ApplyPlugins())
routes := ctx.Router().Routes()
assertsMiddleware(t, routes, "POST", "/api/v1/user/change-password", sentinelLogin)
assertsMiddleware(t, routes, "PUT", "/api/v1/user/profile", sentinelLogin)
assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelLogin)
assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelNoToken)
assertsMiddleware(t, routes, "GET", "/api/v1/message-gateway/channels", sentinelLogin)
assertsMiddleware(t, routes, "GET", "/api/v1/admin/message-gateway/channels", sentinelAdmin)
}
// TestAuthConsumersDeclareAuthDependency 是同一缺陷的架构面:声明依赖是内核排序的唯一
// 依据,漏声明会让正确性取决于注册表顺序。
func TestAuthConsumersDeclareAuthDependency(t *testing.T) {
want := reflect.TypeFor[contracts.AuthService]()
for _, tc := range []struct {
name string
deps []reflect.Type
}{
{"user", user.New().Inject()},
{"message_gateway", message_gateway.New().Inject()},
} {
t.Run(tc.name, func(t *testing.T) {
assert.Contains(t, tc.deps, want,
"%s resolves contracts.AuthService in Apply, so it must declare it in Inject", tc.name)
})
}
}
// firstGuard returns the outermost middleware registered for the first route
// matching method and path prefix — the auth guard the plugin resolved at Apply.
func firstGuard(t *testing.T, routes []extpoints.RouteDefinition, method, prefix string) gin.HandlerFunc {
t.Helper()
for _, rd := range routes {
if rd.Method != method || !strings.HasPrefix(rd.Path, prefix) {
continue
}
for _, candidate := range rd.Middlewares {
if mw, ok := candidate.(gin.HandlerFunc); ok {
return mw
}
}
for _, candidate := range rd.Handlers {
if mw, ok := candidate.(gin.HandlerFunc); ok {
return mw
}
}
t.Fatalf("route %s %s has no inspectable guard", method, rd.Path)
}
t.Fatalf("no route registered for %s %s*", method, prefix)
return nil
}
// TestAuthGuardFailsClosed 回归:鉴权服务无法解析时兜底必须是拒绝。旧实现兜底为
// c.Next(),所以任何装配缺失——例如 admin 的 OnDispose 调用 service.ResetServices
// 把全局 authService 置 nil——都会让路由以“已登录”的姿态直达业务处理函数。
func TestAuthGuardFailsClosed(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
apply func(*core.Context) error
method string
prefix string
}{
{"user", user.New().Apply, http.MethodPost, "/api/v1/user/change-password"},
{"message_gateway", message_gateway.New().Apply, http.MethodGet, "/api/v1/message-gateway"},
{"admin", admin.New().Apply, http.MethodGet, "/api/v1/admin"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := core.NewContext(context.Background())
require.NoError(t, tc.apply(ctx))
guard := firstGuard(t, ctx.Router().Routes(), tc.method, tc.prefix)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(tc.method, tc.prefix, nil)
guard(c)
assert.True(t, c.IsAborted(),
"%s guard must reject the request when contracts.AuthService is unavailable", tc.name)
assert.NotEmpty(t, c.Errors,
"%s guard must record why the request was rejected", tc.name)
})
}
}
@@ -8,6 +8,7 @@ import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/handler"
"Wavelet/plugins/domain/message_gateway/model"
@@ -59,6 +60,10 @@ func (p *Plugin) Name() string {
func (p *Plugin) Inject() []reflect.Type {
return []reflect.Type{
reflect.TypeFor[contracts.DBService](),
// AuthService is captured as a middleware value in Apply, so it cannot
// be late-bound with core.When like the other services below; the
// kernel must mount auth first or the routes get a pass-through guard.
reflect.TypeFor[contracts.AuthService](),
}
}
@@ -130,8 +135,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
})
// 0. Resolve auth service for middleware (via IoC, not direct import)
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
var adminMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
denyAuth := ginutil.AuthUnavailable()
loginMW := denyAuth
adminMW := denyAuth
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
loginMW = mw
+8 -2
View File
@@ -8,6 +8,7 @@ import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"context"
"embed"
"reflect"
@@ -56,6 +57,10 @@ func (p *Plugin) Name() string {
func (p *Plugin) Inject() []reflect.Type {
return []reflect.Type{
reflect.TypeFor[contracts.DBService](),
// Apply resolves AuthService to build the route auth middleware. The
// kernel only gates Apply on declared deps, so leaving this out lets
// user mount before auth and fall back to a pass-through middleware.
reflect.TypeFor[contracts.AuthService](),
}
}
@@ -85,8 +90,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
})
// 0.1 Resolve auth service for middleware (via IoC, not direct import)
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
var noTokenMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
denyAuth := ginutil.AuthUnavailable()
loginMW := denyAuth
noTokenMW := denyAuth
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
loginMW = mw