WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
package middleware
import (
"context"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/config"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// access.go centralises the tenant-access checks that previously lived
// scattered across handlers and the various RBAC middlewares:
//
// - "is the caller an org-level superuser" — used by the role guards
// in rbac.go, the X-Tenant-ID branch in auth.go, the tenant
// handler's authorizeTenantAccess, and the tenant member handler's
// resolveTenantIDFromPath.
//
// - "can the caller access target tenant X" — used by the
// X-Tenant-ID branch in auth.go to decide whether to honour the
// header. Originally allowed only superusers; now also allows
// ordinary multi-tenant members who have an active row in the
// target tenant's tenant_members table.
//
// - "must the URL :id match the active tenant" — used by every
// tenant-scoped route. Was duplicated in tenant.go (4 callers) and
// tenant_member.go (1 caller) before; now lives here as a route
// middleware so the route declaration is the single source of
// truth.
//
// All of these honour cfg.Tenant.EnableCrossTenantAccess: the
// CanAccessAllTenants attribute on a User row is meaningful only when
// the cluster-wide flag is on. Reading the attribute alone (without the
// flag) would let a dormant config grant escalation, which is exactly
// what bit us during the PR 3 review.
//
// Rejections are reported via c.Error(*apperrors.AppError) + c.Abort(),
// not c.JSON: this keeps the response shape consistent with the
// {success,error:{code,message,details}} envelope that ErrorHandler
// renders for the rest of the API. The handler-level checks these
// guards replaced (authorizeTenantAccess, resolveTenantIDFromPath,
// ListAllTenants/SearchTenants if-blocks) all used that envelope, so
// preserving it avoids breaking SDK / frontend consumers that key off
// `error.code`.
// IsCrossTenantSuperuser reports whether ctx carries a user that is
// authorised for cross-tenant access at this moment. Both the user
// attribute and the cluster-wide flag must be true; either alone is
// not enough.
func IsCrossTenantSuperuser(ctx context.Context, cfg *config.Config) bool {
if cfg == nil || cfg.Tenant == nil || !cfg.Tenant.EnableCrossTenantAccess {
return false
}
u, ok := ctx.Value(types.UserContextKey).(*types.User)
if !ok || u == nil {
return false
}
return u.CanAccessAllTenants
}
// IsTenantAccessible reports whether `user` is allowed to operate inside
// `targetTenantID`. The decision order is:
//
// 1. Home tenant (user.TenantID == targetTenantID): always.
// 2. Cross-tenant superuser: governed by IsCrossTenantSuperuser.
// 3. Multi-tenant member: an active tenant_members row in the target
// tenant grants access (this is what makes cross-tenant browsing
// work for non-superusers added via PR 3's member management).
//
// Lookup errors are treated as "not a member" — the safest fallback
// that doesn't expose other tenants on a transient DB hiccup.
func IsTenantAccessible(
ctx context.Context,
user *types.User,
targetTenantID uint64,
memberService interfaces.TenantMemberService,
cfg *config.Config,
) bool {
if user == nil || targetTenantID == 0 {
return false
}
if user.TenantID == targetTenantID {
return true
}
if cfg != nil && cfg.Tenant != nil && cfg.Tenant.EnableCrossTenantAccess && user.CanAccessAllTenants {
return true
}
if memberService == nil {
return false
}
m, err := memberService.GetMembership(ctx, user.ID, targetTenantID)
if err != nil || m == nil {
return false
}
return m.Status == types.TenantMemberStatusActive
}
// RequireCrossTenantAccess gates a route on the caller being an
// org-level superuser. Used by /tenants/all, /tenants/search,
// POST /tenants, GET /tenants — endpoints that operate across tenants
// rather than inside one.
//
// Unlike RequireRole this is NOT modulated by cfg.Tenant.EnableRBAC:
// cross-workspace operations are always sensitive regardless of whether
// per-tenant RBAC is being enforced, so we never log-and-pass.
func RequireCrossTenantAccess(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok && scope.IsPlatform() {
c.Next()
return
}
// First the cluster-wide flag — if it's off, nobody gets through,
// not even users with CanAccessAllTenants=true. This mirrors the
// "must require BOTH" rule that previously lived in tenant.go.
if cfg == nil || cfg.Tenant == nil || !cfg.Tenant.EnableCrossTenantAccess {
uid, _ := types.UserIDFromContext(ctx)
logger.Warnf(ctx,
"[rbac] cross-tenant route blocked (EnableCrossTenantAccess=false): user=%s path=%s",
uid, c.Request.URL.Path)
_ = c.Error(apperrors.NewForbiddenError("Cross-workspace access is disabled"))
c.Abort()
return
}
u, ok := ctx.Value(types.UserContextKey).(*types.User)
if !ok || u == nil || !u.CanAccessAllTenants {
uid, _ := types.UserIDFromContext(ctx)
logger.Warnf(ctx,
"[rbac] cross-tenant route blocked (not a superuser): user=%s path=%s",
uid, c.Request.URL.Path)
_ = c.Error(apperrors.NewForbiddenError(
"Insufficient permissions for cross-workspace operation"))
c.Abort()
return
}
c.Next()
}
}
// RequirePathTenantMatch gates a route on the URL :id matching the
// caller's active tenant context. Cross-tenant superusers bypass the
// match because their X-Tenant-ID switch was already vetted by the
// auth middleware.
//
// The router places this on the /tenants/:id group so every per-tenant
// endpoint (GetTenant / UpdateTenant / DeleteTenant /
// member management / leave) shares the same check, replacing what was
// previously a copy-pasted block in each handler.
//
// Reads :id off c.Param. If the param is missing or non-numeric the
// request is rejected as 400 — the route can't have meaningfully
// matched without it.
func RequirePathTenantMatch(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
_ = c.Error(apperrors.NewValidationError("workspace id is required"))
c.Abort()
return
}
pathTenantID, err := strconv.ParseUint(raw, 10, 64)
if err != nil || pathTenantID == 0 {
_ = c.Error(apperrors.NewValidationError("workspace id must be a positive integer"))
c.Abort()
return
}
ctxTenantID, ok := types.TenantIDFromContext(ctx)
if !ok || ctxTenantID == 0 {
// Auth middleware should always have set this; if not we
// fail closed rather than silently treating "no context" as
// a match.
logger.Warnf(ctx, "[rbac] path-tenant-match: no tenant in ctx, path=%s", c.Request.URL.Path)
_ = c.Error(apperrors.NewUnauthorizedError("workspace context missing"))
c.Abort()
return
}
if pathTenantID == ctxTenantID {
c.Next()
return
}
if IsCrossTenantSuperuser(ctx, cfg) {
c.Next()
return
}
uid, _ := types.UserIDFromContext(ctx)
logger.Warnf(ctx,
"[rbac] path-tenant-match rejected: user=%s ctx_tenant=%d path_tenant=%d path=%s",
uid, ctxTenantID, pathTenantID, c.Request.URL.Path)
_ = c.Error(apperrors.NewForbiddenError(
"Access denied: URL workspace does not match the active workspace"))
c.Abort()
}
}
+354
View File
@@ -0,0 +1,354 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/config"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// access_test.go covers the four exported helpers in access.go:
// IsCrossTenantSuperuser, IsTenantAccessible, RequireCrossTenantAccess,
// RequirePathTenantMatch. The shapes are similar — feature-flag +
// per-user attribute combinations — so the tests are organised around
// the truth-table for each helper rather than per-handler scenarios.
// cfgCrossTenant returns a config with the cluster-wide
// EnableCrossTenantAccess flag set as requested. EnableRBAC is left at
// its zero value because none of these helpers consult it directly —
// the role middleware does.
func cfgCrossTenant(enabled bool) *config.Config {
return &config.Config{Tenant: &config.TenantConfig{EnableCrossTenantAccess: enabled}}
}
// ---------- IsCrossTenantSuperuser ----------
func TestIsCrossTenantSuperuser_NilCfgRejects(t *testing.T) {
ctx := context.WithValue(context.Background(), types.UserContextKey,
&types.User{ID: "u1", CanAccessAllTenants: true})
if IsCrossTenantSuperuser(ctx, nil) {
t.Fatalf("nil cfg must reject (no flag implies no cross-tenant access)")
}
}
func TestIsCrossTenantSuperuser_FlagOffRejectsEvenWithAttribute(t *testing.T) {
// The user attribute alone is not enough — by design. This is the
// "stale claim revocation" guarantee: flipping the cluster flag off
// disables every CanAccessAllTenants user without a re-issue.
ctx := context.WithValue(context.Background(), types.UserContextKey,
&types.User{ID: "u1", CanAccessAllTenants: true})
if IsCrossTenantSuperuser(ctx, cfgCrossTenant(false)) {
t.Fatalf("flag off must reject even when User.CanAccessAllTenants=true")
}
}
func TestIsCrossTenantSuperuser_FlagOnNoAttributeRejects(t *testing.T) {
ctx := context.WithValue(context.Background(), types.UserContextKey,
&types.User{ID: "u1", CanAccessAllTenants: false})
if IsCrossTenantSuperuser(ctx, cfgCrossTenant(true)) {
t.Fatalf("ordinary user must not pass even when flag is on")
}
}
func TestIsCrossTenantSuperuser_BothAllow(t *testing.T) {
ctx := context.WithValue(context.Background(), types.UserContextKey,
&types.User{ID: "u1", CanAccessAllTenants: true})
if !IsCrossTenantSuperuser(ctx, cfgCrossTenant(true)) {
t.Fatalf("flag on + attribute true must allow")
}
}
func TestIsCrossTenantSuperuser_NoUserInCtxRejects(t *testing.T) {
// Defensive: a context lacking UserContextKey should never be
// promoted. Returning false rather than panicking keeps the caller
// (auth.go fast paths) safe to use even before user resolution.
if IsCrossTenantSuperuser(context.Background(), cfgCrossTenant(true)) {
t.Fatalf("missing user must reject")
}
}
// ---------- IsTenantAccessible ----------
func TestIsTenantAccessible_HomeTenantAlwaysAllows(t *testing.T) {
user := &types.User{ID: "u1", TenantID: 1}
// Even with no member service and the cross-tenant flag off, a user
// reaching their own tenant is fine. Home tenant is the cheapest
// fast path.
if !IsTenantAccessible(context.Background(), user, 1, nil, cfgCrossTenant(false)) {
t.Fatalf("home tenant must allow regardless of other inputs")
}
}
func TestIsTenantAccessible_NilUserRejects(t *testing.T) {
if IsTenantAccessible(context.Background(), nil, 1, nil, cfgCrossTenant(true)) {
t.Fatalf("nil user must reject (defensive)")
}
}
func TestIsTenantAccessible_ZeroTargetRejects(t *testing.T) {
user := &types.User{ID: "u1", TenantID: 1}
if IsTenantAccessible(context.Background(), user, 0, nil, cfgCrossTenant(true)) {
t.Fatalf("zero target tenant must reject")
}
}
func TestIsTenantAccessible_SuperuserPathRequiresFlag(t *testing.T) {
user := &types.User{ID: "u1", TenantID: 1, CanAccessAllTenants: true}
// With flag OFF, the superuser attribute alone must NOT grant
// cross-tenant access — same revocation rule as
// IsCrossTenantSuperuser.
if IsTenantAccessible(context.Background(), user, 99, nil, cfgCrossTenant(false)) {
t.Fatalf("superuser without cluster flag must reject cross-tenant target")
}
// With the flag ON, no membership lookup is needed — the user can
// reach any tenant.
if !IsTenantAccessible(context.Background(), user, 99, nil, cfgCrossTenant(true)) {
t.Fatalf("superuser with cluster flag must allow without membership lookup")
}
}
func TestIsTenantAccessible_ActiveMembershipAllows(t *testing.T) {
user := &types.User{ID: "u1", TenantID: 1}
ms := newFakeMemberService()
ms.seedActive("u1", 99, types.TenantRoleContributor)
if !IsTenantAccessible(context.Background(), user, 99, ms, cfgCrossTenant(false)) {
t.Fatalf("active membership must allow even with flag off and no superuser")
}
}
func TestIsTenantAccessible_NoMembershipRejects(t *testing.T) {
user := &types.User{ID: "u1", TenantID: 1}
ms := newFakeMemberService() // empty
if IsTenantAccessible(context.Background(), user, 99, ms, cfgCrossTenant(true)) {
t.Fatalf("no membership and not superuser must reject")
}
}
func TestIsTenantAccessible_LookupErrorRejects(t *testing.T) {
// A DB hiccup must NOT silently grant access; the safest behaviour
// is "treat as no membership". The X-Tenant-ID gate in auth.go
// relies on this — failing closed prevents tenant-bleed during
// transient outages.
user := &types.User{ID: "u1", TenantID: 1}
ms := newFakeMemberService()
ms.failGet = errors.New("db down")
if IsTenantAccessible(context.Background(), user, 99, ms, cfgCrossTenant(true)) {
t.Fatalf("lookup error must reject (fail closed)")
}
}
func TestIsTenantAccessible_NilMemberServiceRejectsNonHome(t *testing.T) {
user := &types.User{ID: "u1", TenantID: 1}
if IsTenantAccessible(context.Background(), user, 99, nil, cfgCrossTenant(false)) {
t.Fatalf("no member service + non-home tenant must reject")
}
}
// ---------- RequireCrossTenantAccess ----------
func runCrossTenantHandler(cfg *config.Config, user *types.User) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
r := gin.New()
// ErrorHandler renders c.Error() into the standard envelope and
// sets the status from AppError.HTTPCode. Mounting it here mirrors
// the production router so the tests assert what real clients see.
r.Use(ErrorHandler())
r.Use(func(c *gin.Context) {
if user != nil {
ctx := context.WithValue(c.Request.Context(), types.UserContextKey, user)
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
c.Request = c.Request.WithContext(ctx)
}
c.Next()
})
r.GET("/cross", RequireCrossTenantAccess(cfg), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/cross", nil)
r.ServeHTTP(w, req)
return w
}
func TestRequireCrossTenantAccess_FlagOffBlocksEveryone(t *testing.T) {
w := runCrossTenantHandler(cfgCrossTenant(false),
&types.User{ID: "u1", CanAccessAllTenants: true})
if w.Code != http.StatusForbidden {
t.Fatalf("flag off must 403 even for superuser, got %d", w.Code)
}
}
func TestRequireCrossTenantAccess_FlagOnNoAttributeBlocks(t *testing.T) {
w := runCrossTenantHandler(cfgCrossTenant(true),
&types.User{ID: "u1", CanAccessAllTenants: false})
if w.Code != http.StatusForbidden {
t.Fatalf("ordinary user must 403 even with flag on, got %d", w.Code)
}
}
func TestRequireCrossTenantAccess_BothAllow(t *testing.T) {
w := runCrossTenantHandler(cfgCrossTenant(true),
&types.User{ID: "u1", CanAccessAllTenants: true})
if w.Code != http.StatusOK {
t.Fatalf("flag + superuser must allow, got %d", w.Code)
}
}
func TestRequireCrossTenantAccess_NoUserBlocks(t *testing.T) {
w := runCrossTenantHandler(cfgCrossTenant(true), nil)
if w.Code != http.StatusForbidden {
t.Fatalf("missing user must 403, got %d", w.Code)
}
}
// ---------- RequirePathTenantMatch ----------
func runPathTenantHandler(
cfg *config.Config, ctxTenantID uint64, user *types.User, urlPath string,
) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(ErrorHandler()) // see runCrossTenantHandler for rationale
r.Use(func(c *gin.Context) {
ctx := c.Request.Context()
if ctxTenantID != 0 {
ctx = context.WithValue(ctx, types.TenantIDContextKey, ctxTenantID)
}
if user != nil {
ctx = context.WithValue(ctx, types.UserContextKey, user)
ctx = context.WithValue(ctx, types.UserIDContextKey, user.ID)
}
c.Request = c.Request.WithContext(ctx)
c.Next()
})
r.GET("/tenants/:id", RequirePathTenantMatch(cfg), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
r.ServeHTTP(w, req)
return w
}
func TestRequirePathTenantMatch_MatchAllows(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(false), 7, nil, "/tenants/7")
if w.Code != http.StatusOK {
t.Fatalf("matching :id must allow, got %d", w.Code)
}
}
func TestRequirePathTenantMatch_MismatchRejects(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(false), 7, nil, "/tenants/9")
if w.Code != http.StatusForbidden {
t.Fatalf("mismatch must 403, got %d", w.Code)
}
}
func TestRequirePathTenantMatch_SuperuserBypassesMismatch(t *testing.T) {
// Pinned regression: this is the same carve-out
// resolveTenantIDFromPath used to enforce in tenant_member.go.
// Both flag and attribute must be set; either alone must reject
// (covered by the next test).
w := runPathTenantHandler(cfgCrossTenant(true), 7,
&types.User{ID: "su1", CanAccessAllTenants: true},
"/tenants/9")
if w.Code != http.StatusOK {
t.Fatalf("superuser must bypass mismatch with flag on, got %d", w.Code)
}
}
func TestRequirePathTenantMatch_SuperuserNeedsClusterFlag(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(false), 7,
&types.User{ID: "su1", CanAccessAllTenants: true},
"/tenants/9")
if w.Code != http.StatusForbidden {
t.Fatalf("superuser without flag must 403, got %d", w.Code)
}
}
func TestRequirePathTenantMatch_MissingCtxTenantRejects(t *testing.T) {
// If the auth middleware never set TenantIDContextKey we fail
// closed — silently treating "no ctx" as a match would be a footgun
// the next time someone refactors the auth chain.
w := runPathTenantHandler(cfgCrossTenant(true), 0, nil, "/tenants/7")
if w.Code != http.StatusUnauthorized {
t.Fatalf("missing tenant ctx must 401, got %d", w.Code)
}
}
func TestRequirePathTenantMatch_NonNumericIDRejects(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(true), 7, nil, "/tenants/abc")
if w.Code != http.StatusBadRequest {
t.Fatalf("non-numeric :id must 400, got %d", w.Code)
}
}
func TestRequirePathTenantMatch_ZeroIDRejects(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(true), 7, nil, "/tenants/0")
if w.Code != http.StatusBadRequest {
t.Fatalf(":id=0 must 400, got %d", w.Code)
}
}
// TestAccessMiddleware_ResponseEnvelope pins the wire format produced
// by RequireCrossTenantAccess and RequirePathTenantMatch when they
// reject. Both used to live in handlers that called
// c.Error(apperrors.NewForbiddenError(...)) and rendered through
// ErrorHandler — clients (frontend axios interceptor, Go SDK) key off
// `success` and `error.code`. If a future change drops c.Error in
// favour of a raw c.JSON, this test catches the regression.
func TestAccessMiddleware_ResponseEnvelope(t *testing.T) {
t.Run("RequireCrossTenantAccess_FlagOff", func(t *testing.T) {
w := runCrossTenantHandler(cfgCrossTenant(false),
&types.User{ID: "u1", CanAccessAllTenants: true})
assertEnvelope(t, w, http.StatusForbidden, apperrors.ErrForbidden)
})
t.Run("RequirePathTenantMatch_Mismatch", func(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(false), 7, nil, "/tenants/9")
assertEnvelope(t, w, http.StatusForbidden, apperrors.ErrForbidden)
})
t.Run("RequirePathTenantMatch_NonNumeric", func(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(true), 7, nil, "/tenants/abc")
// NewValidationError uses ErrValidation, not ErrBadRequest — both
// surface as HTTP 400 but the code in the body is ErrValidation.
assertEnvelope(t, w, http.StatusBadRequest, apperrors.ErrValidation)
})
t.Run("RequirePathTenantMatch_NoCtxTenant", func(t *testing.T) {
w := runPathTenantHandler(cfgCrossTenant(true), 0, nil, "/tenants/7")
assertEnvelope(t, w, http.StatusUnauthorized, apperrors.ErrUnauthorized)
})
}
func assertEnvelope(t *testing.T, w *httptest.ResponseRecorder, wantStatus int, wantCode apperrors.ErrorCode) {
t.Helper()
if w.Code != wantStatus {
t.Fatalf("status: got %d, want %d (body=%s)", w.Code, wantStatus, w.Body.String())
}
var body struct {
Success bool `json:"success"`
Error struct {
Code apperrors.ErrorCode `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not the standard envelope: %v (body=%s)", err, w.Body.String())
}
if body.Success {
t.Fatalf("envelope success must be false on rejection, got body=%s", w.Body.String())
}
if body.Error.Code != wantCode {
t.Fatalf("envelope error.code: got %d, want %d (body=%s)",
body.Error.Code, wantCode, w.Body.String())
}
if body.Error.Message == "" {
t.Fatalf("envelope error.message must be non-empty, got body=%s", w.Body.String())
}
}
+218
View File
@@ -0,0 +1,218 @@
package middleware
import (
stderrors "errors"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
var errTenantAPIKeyScopeForbidden = stderrors.New("workspace API key scope forbidden")
// APIKeyRoutePolicy declares how an X-API-Key caller may use a single route.
//
// Design: API-key authorization is a SEPARATE authority from the JWT
// role/ownership guards. Ownership ("creator OR Admin+") is a human concept
// and never applies to a machine principal; instead every API-key-accessible
// route declares one policy here, and the APIKeyGate is the single place that
// enforces it. Routes that declare no policy are denied for API keys by
// default (fail-closed), which removes the old "remember to add APIKeyDeny"
// footgun.
type APIKeyRoutePolicy struct {
// PlatformOnly rejects workspace-bound keys even when they are full-access.
// It is used for control-plane routes under /system/admin and cross-workspace
// tenant lifecycle APIs.
PlatformOnly bool
// RequireFullAccess admits only full-access tenant API keys unless one of
// the listed capabilities also matches. Routes with neither full-access
// requirement nor capabilities are open to any valid API key.
RequireFullAccess bool
// Capabilities is an any-of allow-list for scoped API keys. A capability
// never widens which knowledge bases a key may touch; KB scoping is
// enforced downstream by KBAccess guards and handler scope checks.
Capabilities []types.APIKeyCapability
}
// WithCapability returns a copy of the policy that additionally admits keys
// carrying capability c. Multiple calls accumulate (any-of semantics);
// duplicates are ignored.
func (p APIKeyRoutePolicy) WithCapability(c types.APIKeyCapability) APIKeyRoutePolicy {
for _, existing := range p.Capabilities {
if existing == c {
return p
}
}
// Copy the slice so mutating the returned policy never aliases the
// receiver's backing array.
next := make([]types.APIKeyCapability, len(p.Capabilities), len(p.Capabilities)+1)
copy(next, p.Capabilities)
p.Capabilities = append(next, c)
return p
}
// APIKeyRouteAuthorizer is the registry of per-route API-key policies. It is
// populated at router-construction time (single-threaded) and only read at
// request time, so it needs no locking.
type APIKeyRouteAuthorizer struct {
// policies is keyed by HTTP method, then by the gin full-path
// template (e.g. "/api/v1/knowledge-bases/:id/knowledge/file").
policies map[string]map[string]APIKeyRoutePolicy
}
// NewAPIKeyRouteAuthorizer returns an empty authorizer.
func NewAPIKeyRouteAuthorizer() *APIKeyRouteAuthorizer {
return &APIKeyRouteAuthorizer{policies: map[string]map[string]APIKeyRoutePolicy{}}
}
// Register records the API-key policy for (method, fullPath). fullPath MUST be
// the same string gin reports via c.FullPath() for the route, otherwise the
// gate lookup will miss and the route will be denied for API keys. Router-side
// helpers build fullPath from the group's BasePath so the two stay in sync.
func (a *APIKeyRouteAuthorizer) Register(method, fullPath string, policy APIKeyRoutePolicy) {
method = strings.ToUpper(strings.TrimSpace(method))
fullPath = normalizeRoutePath(fullPath)
if a.policies[method] == nil {
a.policies[method] = map[string]APIKeyRoutePolicy{}
}
a.policies[method][fullPath] = policy
}
// Lookup returns the policy for (method, fullPath) if one was declared.
func (a *APIKeyRouteAuthorizer) Lookup(method, fullPath string) (APIKeyRoutePolicy, bool) {
byPath, ok := a.policies[strings.ToUpper(method)]
if !ok {
return APIKeyRoutePolicy{}, false
}
policy, ok := byPath[normalizeRoutePath(fullPath)]
return policy, ok
}
// RegisteredRoutes returns every (method, fullPath) pair the authorizer knows
// about. Used by the router startup self-check to detect stale path templates.
func (a *APIKeyRouteAuthorizer) RegisteredRoutes() map[string][]string {
out := make(map[string][]string, len(a.policies))
for method, byPath := range a.policies {
paths := make([]string, 0, len(byPath))
for path := range byPath {
paths = append(paths, path)
}
out[method] = paths
}
return out
}
// Middleware returns the gate middleware. It runs as the first handler on the
// authenticated API group, after routing (so c.FullPath() is populated). JWT
// principals pass straight through; API-key principals are authorized purely
// from the declared policy table.
func (a *APIKeyRouteAuthorizer) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context())
if !ok {
c.Next()
return
}
if err := a.authorize(scope, c.Request.Method, c.FullPath()); err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "Forbidden: API key scope does not allow this operation",
})
return
}
c.Next()
}
}
// authorize applies the declared policy to an API-key scope. Absent policy =>
// default deny.
func (a *APIKeyRouteAuthorizer) authorize(scope types.TenantAPIKeyScope, method, fullPath string) error {
policy, ok := a.Lookup(method, fullPath)
if !ok {
return errTenantAPIKeyScopeForbidden
}
if policy.PlatformOnly && !scope.IsPlatform() {
return errTenantAPIKeyScopeForbidden
}
if policy.PlatformOnly && len(policy.Capabilities) == 0 {
return errTenantAPIKeyScopeForbidden
}
if scope.FullAccess {
return nil
}
for _, cap := range policy.Capabilities {
if cap != "" && scope.HasCapability(cap) {
return nil
}
}
if !policy.RequireFullAccess && len(policy.Capabilities) == 0 {
return nil
}
return errTenantAPIKeyScopeForbidden
}
// DenyAPIKeyPrincipal returns a middleware that rejects any X-API-Key
// principal outright. Use it on routes registered directly on the engine
// (outside the /api/v1 group) where the APIKeyRouteAuthorizer.Middleware
// gate does NOT run — the JWT role guards (RequireRole / RequireSystemAdmin
// / RequireOwnershipOrRole) short-circuit API-key principals on the
// assumption the gate already authorized them, so an ungated route would
// otherwise let any valid key through. JWT sessions pass straight through.
func DenyAPIKeyPrincipal() gin.HandlerFunc {
return func(c *gin.Context) {
if _, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context()); ok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "Forbidden: API keys cannot access this endpoint",
})
return
}
c.Next()
}
}
// AllowFileServeAPIKey guards the tenant-scoped file-proxy routes (/files and
// the KB-scoped image proxy) for X-API-Key callers. Those routes serve an
// arbitrary storage path that only carries a tenant segment — there is no KB
// id in the path a KB-restricted key's allow-list could be checked against —
// so a KB-restricted key is denied outright (it must download KB content via
// the KB-scoped routes such as /knowledge/:id/download, which DO enforce the
// allow-list). A key passes when it is full-access, or when it is NOT
// KB-restricted and carries the retrieve capability: that is exactly the class
// of key that can already read any of the tenant's KB content, so exposing the
// tenant-bounded raw file path (the handler still enforces
// ValidateStoragePathTenant / the KB owner tenant) grants it nothing new. JWT
// sessions carry no API-key scope and pass straight through.
func AllowFileServeAPIKey() gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context())
if !ok {
c.Next()
return
}
if scope.FullAccess ||
(!scope.IsKnowledgeBaseRestricted() &&
scope.HasCapability(types.APIKeyCapabilityRetrieve)) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "Forbidden: API key scope does not allow this operation",
})
}
}
// normalizeRoutePath collapses duplicate slashes and trims a trailing slash so
// helper-built paths (BasePath()+rel) match gin's c.FullPath() exactly.
func normalizeRoutePath(p string) string {
if p == "" {
return ""
}
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
if len(p) > 1 {
p = strings.TrimSuffix(p, "/")
}
return p
}
+327
View File
@@ -0,0 +1,327 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func newTestAuthorizer() *APIKeyRouteAuthorizer {
a := NewAPIKeyRouteAuthorizer()
a.Register(http.MethodGet, "/api/v1/auth/me", APIKeyRoutePolicy{})
a.Register(http.MethodPost, "/api/v1/knowledge-bases/:id/knowledge/file",
APIKeyRoutePolicy{RequireFullAccess: true}.WithCapability(types.APIKeyCapabilityIngest))
a.Register(http.MethodGet, "/api/v1/models",
APIKeyRoutePolicy{RequireFullAccess: true}.WithCapability(types.APIKeyCapabilityManageModels))
a.Register(http.MethodPut, "/api/v1/tenants/kv/:key",
APIKeyRoutePolicy{RequireFullAccess: true}.WithCapability(types.APIKeyCapabilityManageTenantSettings))
return a
}
// runGate exercises the gate middleware with a given scope, method and route
// full-path, returning whether the request was allowed to proceed.
func runGate(t *testing.T, a *APIKeyRouteAuthorizer, scope *types.TenantAPIKeyScope, method, fullPath string) bool {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) {
if scope != nil {
c.Request = c.Request.WithContext(types.WithTenantAPIKeyScope(c.Request.Context(), *scope))
}
c.Next()
})
engine.Use(a.Middleware())
allowed := false
engine.Handle(method, fullPath, func(c *gin.Context) {
allowed = true
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(method, concretePath(fullPath), nil))
return allowed && w.Code == http.StatusOK
}
// concretePath substitutes gin :params with literals so httptest can hit it.
func concretePath(tmpl string) string {
switch tmpl {
case "/api/v1/knowledge-bases/:id":
return "/api/v1/knowledge-bases/kb-1"
case "/api/v1/knowledge-bases/:id/knowledge/file":
return "/api/v1/knowledge-bases/kb-1/knowledge/file"
case "/api/v1/tenants/kv/:key":
return "/api/v1/tenants/kv/some-key"
default:
return tmpl
}
}
func TestGateJWTPassesThrough(t *testing.T) {
a := newTestAuthorizer()
// No scope => JWT principal => always allowed, even on a full-access route.
if !runGate(t, a, nil, http.MethodGet, "/api/v1/models") {
t.Fatal("JWT principal must pass the gate")
}
}
func TestGateDefaultDeny(t *testing.T) {
a := newTestAuthorizer()
full := &types.TenantAPIKeyScope{FullAccess: true}
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(types.WithTenantAPIKeyScope(c.Request.Context(), *full))
c.Next()
})
engine.Use(a.Middleware())
engine.POST("/api/v1/agents", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("undeclared route should default-deny even a full-access key: status=%d", w.Code)
}
}
func TestGateAnyPolicyAllowsScopedKey(t *testing.T) {
a := newTestAuthorizer()
scoped := &types.TenantAPIKeyScope{}
if !runGate(t, a, scoped, http.MethodGet, "/api/v1/auth/me") {
t.Fatal("scoped key should call a declared route with an empty policy")
}
}
func TestGateFullAccessAndCapabilityPolicies(t *testing.T) {
a := newTestAuthorizer()
scoped := &types.TenantAPIKeyScope{}
full := &types.TenantAPIKeyScope{FullAccess: true}
ingest := &types.TenantAPIKeyScope{Capabilities: types.StringArray{"ingest"}}
models := &types.TenantAPIKeyScope{Capabilities: types.StringArray{"manage_models"}}
if runGate(t, a, scoped, http.MethodPost, "/api/v1/knowledge-bases/:id/knowledge/file") {
t.Fatal("plain scoped key must not write content")
}
if !runGate(t, a, full, http.MethodPost, "/api/v1/knowledge-bases/:id/knowledge/file") {
t.Fatal("full-access key should write content")
}
if !runGate(t, a, ingest, http.MethodPost, "/api/v1/knowledge-bases/:id/knowledge/file") {
t.Fatal("ingest capability should write content")
}
if runGate(t, a, ingest, http.MethodGet, "/api/v1/models") {
t.Fatal("ingest capability must not read model management routes")
}
if !runGate(t, a, models, http.MethodGet, "/api/v1/models") {
t.Fatal("manage_models capability should read model management routes")
}
}
func TestGateAnyOfCapabilities(t *testing.T) {
a := NewAPIKeyRouteAuthorizer()
a.Register(http.MethodPost, "/api/v1/sessions",
APIKeyRoutePolicy{RequireFullAccess: true}.WithCapability(types.APIKeyCapabilityChat))
a.Register(http.MethodGet, "/api/v1/agents",
APIKeyRoutePolicy{RequireFullAccess: true}.
WithCapability(types.APIKeyCapabilityChat).
WithCapability(types.APIKeyCapabilityManageAgents))
a.Register(http.MethodPost, "/api/v1/agents",
APIKeyRoutePolicy{RequireFullAccess: true}.WithCapability(types.APIKeyCapabilityManageAgents))
a.Register(http.MethodPut, "/api/v1/knowledge-bases/:id",
APIKeyRoutePolicy{RequireFullAccess: true}.WithCapability(types.APIKeyCapabilityManageKnowledgeBases))
chat := &types.TenantAPIKeyScope{Capabilities: types.StringArray{"chat"}}
manage := &types.TenantAPIKeyScope{Capabilities: types.StringArray{"manage_agents"}}
manageKBs := &types.TenantAPIKeyScope{Capabilities: types.StringArray{"manage_kbs"}}
// Either capability satisfies the any-of read route.
if !runGate(t, a, chat, http.MethodGet, "/api/v1/agents") {
t.Fatal("chat should read agents (any-of)")
}
if !runGate(t, a, manage, http.MethodGet, "/api/v1/agents") {
t.Fatal("manage_agents should read agents (any-of)")
}
// Only manage_agents may author agents.
if runGate(t, a, chat, http.MethodPost, "/api/v1/agents") {
t.Fatal("chat must not author agents")
}
if !runGate(t, a, manage, http.MethodPost, "/api/v1/agents") {
t.Fatal("manage_agents should author agents")
}
// Only manage_kbs may manage KB metadata/config.
if runGate(t, a, manage, http.MethodPut, "/api/v1/knowledge-bases/:id") {
t.Fatal("manage_agents must not manage knowledge bases")
}
if !runGate(t, a, manageKBs, http.MethodPut, "/api/v1/knowledge-bases/:id") {
t.Fatal("manage_kbs should manage knowledge bases")
}
}
func TestGateKBScopeDoesNotBlockDataPlane(t *testing.T) {
a := newTestAuthorizer()
// A KB-restricted key is NOT blocked by the gate on data-plane routes;
// its KB allow-list is enforced downstream by KBAccess/handler checks.
restricted := &types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
Capabilities: types.StringArray{"ingest"},
}
if !runGate(t, a, restricted, http.MethodPost, "/api/v1/knowledge-bases/:id/knowledge/file") {
t.Fatal("KB-restricted ingest key should pass the gate on a data-plane write")
}
}
func TestGatePlatformOnlyPolicyRejectsTenantKeyBeforeFullAccess(t *testing.T) {
a := NewAPIKeyRouteAuthorizer()
a.Register(http.MethodGet, "/api/v1/system/admin/settings",
APIKeyRoutePolicy{PlatformOnly: true}.
WithCapability(types.APIKeyCapabilitySystemSettingsRead))
tenantFull := &types.TenantAPIKeyScope{FullAccess: true}
platform := &types.TenantAPIKeyScope{
ScopeType: types.APIKeyScopePlatform,
Capabilities: types.StringArray{string(types.APIKeyCapabilitySystemSettingsRead)},
}
if runGate(t, a, tenantFull, http.MethodGet, "/api/v1/system/admin/settings") {
t.Fatal("tenant full-access key must not enter a platform-only route")
}
if !runGate(t, a, platform, http.MethodGet, "/api/v1/system/admin/settings") {
t.Fatal("platform key with the required capability should pass")
}
withoutCapability := NewAPIKeyRouteAuthorizer()
withoutCapability.Register(http.MethodGet, "/api/v1/system/admin/unsafe",
APIKeyRoutePolicy{PlatformOnly: true})
corruptPlatformFull := &types.TenantAPIKeyScope{
ScopeType: types.APIKeyScopePlatform,
FullAccess: true,
}
if runGate(t, withoutCapability, corruptPlatformFull, http.MethodGet, "/api/v1/system/admin/unsafe") {
t.Fatal("platform-only policy without an explicit capability must fail closed")
}
}
// runDenyAPIKey mounts DenyAPIKeyPrincipal ahead of a handler and reports
// whether the request reached the handler.
func runDenyAPIKey(t *testing.T, scope *types.TenantAPIKeyScope) (reached bool, status int) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) {
if scope != nil {
c.Request = c.Request.WithContext(types.WithTenantAPIKeyScope(c.Request.Context(), *scope))
}
c.Next()
})
engine.GET("/api/v1/files/presigned-preview", DenyAPIKeyPrincipal(), func(c *gin.Context) {
reached = true
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/files/presigned-preview", nil))
return reached, w.Code
}
// TestDenyAPIKeyPrincipalBlocksAPIKeys guards the gate-bypass class of bug:
// engine-root routes (outside /api/v1) that rely on RequireRole must not be
// reachable by API-key principals, since RequireRole short-circuits them.
func TestDenyAPIKeyPrincipalBlocksAPIKeys(t *testing.T) {
// Even a full-access API key must be rejected outright.
reached, status := runDenyAPIKey(t, &types.TenantAPIKeyScope{FullAccess: true})
if reached {
t.Fatal("API-key principal must not reach a DenyAPIKeyPrincipal-guarded handler")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403 for API-key principal, got %d", status)
}
}
// TestDenyAPIKeyPrincipalAllowsJWT confirms JWT sessions (no API-key scope)
// pass straight through.
func TestDenyAPIKeyPrincipalAllowsJWT(t *testing.T) {
reached, status := runDenyAPIKey(t, nil)
if !reached || status != http.StatusOK {
t.Fatalf("JWT session should pass DenyAPIKeyPrincipal: reached=%v status=%d", reached, status)
}
}
// runAllowFileServe mounts AllowFileServeAPIKey ahead of a handler and reports
// whether the request reached the handler.
func runAllowFileServe(t *testing.T, scope *types.TenantAPIKeyScope) (reached bool, status int) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) {
if scope != nil {
c.Request = c.Request.WithContext(types.WithTenantAPIKeyScope(c.Request.Context(), *scope))
}
c.Next()
})
engine.GET("/files", AllowFileServeAPIKey(), func(c *gin.Context) {
reached = true
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/files", nil))
return reached, w.Code
}
func TestAllowFileServeAPIKey(t *testing.T) {
cases := []struct {
name string
scope *types.TenantAPIKeyScope
wantReached bool
}{
{name: "jwt passes", scope: nil, wantReached: true},
{name: "full access passes", scope: &types.TenantAPIKeyScope{FullAccess: true}, wantReached: true},
{
name: "tenant-wide retrieve passes",
scope: &types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
},
wantReached: true,
},
{
name: "kb-restricted retrieve denied",
scope: &types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
},
wantReached: false,
},
{
name: "non-retrieve capability denied",
scope: &types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
},
wantReached: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached, status := runAllowFileServe(t, tc.scope)
if reached != tc.wantReached {
t.Fatalf("reached=%v want %v (status=%d)", reached, tc.wantReached, status)
}
if tc.wantReached && status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if !tc.wantReached && status != http.StatusForbidden {
t.Fatalf("expected 403, got %d", status)
}
})
}
}
func TestNormalizeRoutePath(t *testing.T) {
cases := map[string]string{
"/api/v1//models": "/api/v1/models",
"/api/v1/models/": "/api/v1/models",
"/": "/",
"/api/v1/agents": "/api/v1/agents",
}
for in, want := range cases {
if got := normalizeRoutePath(in); got != want {
t.Fatalf("normalizeRoutePath(%q)=%q want %q", in, got, want)
}
}
}
+255
View File
@@ -0,0 +1,255 @@
// Package asynqdl provides an asynq middleware that records every task
// whose retry budget is exhausted into the generic task_dead_letters table.
//
// The middleware is the catch-all observability path for asynq failures:
// without it, archived tasks live only inside Redis with the asynq
// inspector CLI as the sole readable surface. With it, operators can
// SQL-query failures by task type, scope, or tenant.
//
// The package is intentionally separate from /internal/middleware (which
// is gin-only) and from /internal/tracing/langfuse (which has its own
// mux.Use). Putting it in its own package keeps the import graph tidy:
// it depends on types/interfaces and asynq, nothing else inside the
// project.
package asynqdl
import (
"context"
"encoding/json"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/hibiken/asynq"
)
// Middleware returns an asynq.MiddlewareFunc that records dead-lettered
// tasks. It is safe to install before any other asynq middleware: the
// inner handler's return value is forwarded verbatim, so every layer
// (langfuse tracing, business handlers) still sees the original
// error/result.
//
// Behaviour:
//
// - Tasks that succeed (handler returns nil) are passed through with
// zero overhead besides the function call.
//
// - Tasks that fail (handler returns non-nil) trigger an inspection of
// asynq's retry counters. We only record on the FINAL retry —
// i.e. when GetRetryCount(ctx) >= GetMaxRetry(ctx). Earlier failures
// are still reported up to asynq for backoff/retry but do not
// create dead-letter rows; otherwise we'd write one row per
// transient hiccup.
//
// - Insert is best-effort: a DB error is logged and swallowed. The
// middleware never alters the task error returned to the caller.
//
// This middleware should be installed once on the asynq mux, BEFORE
// other middlewares that may transform errors. See router/task.go.
// OnDeadLetter is an optional callback invoked AFTER the dead-letter row has
// been recorded. It exists so callers can flip user-visible state alongside
// the bookkeeping row — e.g. mark a Knowledge as failed when its document
// processing task is permanently archived. Without this hook the only signal
// to the UI is the timestamp on the knowledge row going stale, which is what
// produced "stuck in processing forever" reports.
//
// The callback receives the raw asynq Task and the final error. It runs with
// context.Background() (NOT the task ctx) so a cancelled task ctx during
// shutdown doesn't also drop the state update. Errors are logged and
// swallowed; the callback must NEVER alter the original task error.
type OnDeadLetter func(ctx context.Context, t *asynq.Task, taskErr error)
// MiddlewareWithCallback is the extended form of Middleware. The callback may
// be nil, in which case behaviour matches Middleware exactly.
func MiddlewareWithCallback(repo interfaces.TaskDeadLetterRepository, cb OnDeadLetter) asynq.MiddlewareFunc {
return func(next asynq.Handler) asynq.Handler {
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
err := next.ProcessTask(ctx, t)
if err == nil {
return nil
}
if !isFinalAttempt(ctx) {
return err
}
attempts := 0
if retried, ok := asynq.GetRetryCount(ctx); ok {
attempts = retried + 1
}
if repo != nil {
dl := buildDeadLetter(t, err, attempts)
if dl != nil {
if insertErr := repo.Insert(context.Background(), dl); insertErr != nil {
logger.Warnf(ctx,
"asynq dead-letter: failed to record %s task: %v (original task error: %v)",
t.Type(), insertErr, err,
)
}
}
}
if cb != nil {
// Wrap callback so a panic in user code doesn't escape
// into asynq's worker goroutine.
safeInvokeCallback(ctx, t, err, cb)
}
return err
})
}
}
func safeInvokeCallback(ctx context.Context, t *asynq.Task, taskErr error, cb OnDeadLetter) {
defer func() {
if r := recover(); r != nil {
logger.Warnf(ctx, "asynq dead-letter callback panicked for %s: %v", t.Type(), r)
}
}()
cb(context.Background(), t, taskErr)
}
// Middleware preserves the original signature for callers that don't need
// the dead-letter state-linkback hook. Equivalent to MiddlewareWithCallback
// with a nil callback.
func Middleware(repo interfaces.TaskDeadLetterRepository) asynq.MiddlewareFunc {
return MiddlewareWithCallback(repo, nil)
}
// isFinalAttempt reports whether the just-finished handler invocation
// was the LAST one asynq will run before archiving the task. Asynq
// increments retry_count *after* a successful retry, so on the final
// attempt retry_count == max_retry. Both helpers can fail (returning
// 0, false) when the middleware runs outside an asynq worker context;
// in that case we conservatively report "final" so test setups that
// invoke the middleware directly still exercise the insert path.
func isFinalAttempt(ctx context.Context) bool {
retried, retriedOK := asynq.GetRetryCount(ctx)
maxRetry, maxOK := asynq.GetMaxRetry(ctx)
if !retriedOK || !maxOK {
return true
}
return retried >= maxRetry
}
// buildDeadLetter constructs a TaskDeadLetter from the task and the
// final error. The payload extraction is deliberately schema-agnostic:
// every project payload is a JSON object containing some subset of the
// well-known fields {tenant_id, knowledge_base_id, kb_id, knowledge_id,
// task_id}. We probe for those fields with a tolerant struct so a new
// payload shape doesn't need to know about this middleware.
//
// Returns nil only if the task itself is nil — every other failure mode
// (unparseable payload, unknown task type) still produces a row, with
// scope="unknown" so operators can find it.
func buildDeadLetter(t *asynq.Task, taskErr error, attempts int) *types.TaskDeadLetter {
if t == nil {
return nil
}
probe := payloadProbe{}
_ = json.Unmarshal(t.Payload(), &probe) // best-effort
scope, scopeID := inferScope(probe)
relatedID := probe.KnowledgeID
// asynq stores the payload as raw bytes; preserve it verbatim so a
// future requeue can reuse it directly.
rawPayload := t.Payload()
if len(rawPayload) == 0 {
rawPayload = []byte("{}")
}
return &types.TaskDeadLetter{
TenantID: probe.TenantID,
TaskType: t.Type(),
Scope: scope,
ScopeID: scopeID,
RelatedID: relatedID,
Payload: json.RawMessage(rawPayload),
LastError: truncateError(taskErr.Error(), 8192),
FailCount: attempts,
FailedAt: time.Now(),
}
}
// payloadProbe is a best-effort decoder that extracts the common
// identifier fields from any project payload. We deliberately limit
// the probe to field names that have a consistent meaning across every
// existing payload type:
//
// - tenant_id — uniformly the tenant uint64
// - knowledge_base_id — wiki / chunk / image / post-process / ...
// - kb_id — FAQImportPayload's alias for the same thing
// - knowledge_id — per-document scope
// - source_kb_id — KnowledgeMovePayload (semantic: source KB)
//
// Other plausible-looking keys (`source_id`, `target_id`) are NOT
// probed: different payloads use them with different meanings (KB
// clone vs data-source sync), and a wrong scope inference would route
// dead letters under a misleading bucket. New payloads that need a
// specific scope should reuse one of the canonical keys above.
//
// Missing keys decode to zero values, which we then fall back through
// in inferScope.
type payloadProbe struct {
TenantID uint64 `json:"tenant_id,omitempty"`
KnowledgeBaseID string `json:"knowledge_base_id,omitempty"`
KBID string `json:"kb_id,omitempty"` // FAQImportPayload uses this name
KnowledgeID string `json:"knowledge_id,omitempty"`
SourceKBID string `json:"source_kb_id,omitempty"` // KnowledgeMovePayload
}
// inferScope picks the most-specific scope tuple available in the
// probe. The order reflects the conventional "blast radius" of a
// task: knowledge_base is broader than knowledge, etc. Wiki ingest is
// scope="knowledge_base"; chunk extract is scope="knowledge"; and so on.
//
// Falls back to scope="unknown" with empty scope_id if the payload
// carries no recognizable identifier — better than silently dropping
// the row.
func inferScope(p payloadProbe) (string, string) {
switch {
case p.KnowledgeBaseID != "":
return types.TaskScopeKnowledgeBase, p.KnowledgeBaseID
case p.KBID != "":
return types.TaskScopeKnowledgeBase, p.KBID
case p.SourceKBID != "":
// Knowledge move spans two KBs; the source is the better
// blast-radius indicator (it's where the work was being read
// from when the failure occurred).
return types.TaskScopeKnowledgeBase, p.SourceKBID
case p.KnowledgeID != "":
return types.TaskScopeKnowledge, p.KnowledgeID
case p.TenantID != 0:
return types.TaskScopeTenant, formatUint(p.TenantID)
default:
return types.TaskScopeUnknown, ""
}
}
// truncateError prevents a single runaway stack trace from inflating
// the dead-letter table beyond the disk capacity reasonably allotted
// to it.
func truncateError(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
const suffix = "...(truncated)"
if max <= len(suffix) {
return s[:max]
}
return s[:max-len(suffix)] + suffix
}
// formatUint inlines strconv.FormatUint without importing strconv just
// for one call (keeps the package import set minimal).
func formatUint(v uint64) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
+236
View File
@@ -0,0 +1,236 @@
package asynqdl
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
// fakeRepo records calls to Insert in memory so tests can assert how
// many dead-letter rows would have been written. It implements only
// the subset of TaskDeadLetterRepository the middleware uses; the
// Lister methods are unused in the middleware path.
type fakeRepo struct {
mu sync.Mutex
rows []*types.TaskDeadLetter
fail error
}
func (f *fakeRepo) Insert(ctx context.Context, dl *types.TaskDeadLetter) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
cp := *dl // defensive copy: callers may reuse the struct
f.rows = append(f.rows, &cp)
return nil
}
func (f *fakeRepo) ListByScope(context.Context, string, string, string, int) ([]*types.TaskDeadLetter, string, error) {
return nil, "", nil
}
func (f *fakeRepo) ListByTaskType(context.Context, string, string, int) ([]*types.TaskDeadLetter, string, error) {
return nil, "", nil
}
func (f *fakeRepo) DeleteByID(context.Context, int64) error { return nil }
// captureRow returns the i'th row safely under the lock.
func (f *fakeRepo) captureRow(i int) *types.TaskDeadLetter {
f.mu.Lock()
defer f.mu.Unlock()
if i >= len(f.rows) {
return nil
}
return f.rows[i]
}
func (f *fakeRepo) rowCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.rows)
}
// runMiddleware wires up the middleware around a handler that always
// returns handlerErr. We invoke it directly (no asynq server) so retry
// counters come from the helper-injected ctx values; isFinalAttempt
// treats a missing pair as "final" for testability.
func runMiddleware(repo *fakeRepo, taskType string, payload []byte, handlerErr error) error {
mw := Middleware(repo)
wrapped := mw(asynq.HandlerFunc(func(_ context.Context, _ *asynq.Task) error {
return handlerErr
}))
t := asynq.NewTask(taskType, payload)
return wrapped.ProcessTask(context.Background(), t)
}
func TestMiddleware_NilHandlerErr_NoInsert(t *testing.T) {
repo := &fakeRepo{}
if err := runMiddleware(repo, "any:task", []byte(`{"tenant_id":1}`), nil); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got := repo.rowCount(); got != 0 {
t.Fatalf("expected zero inserts on success, got %d", got)
}
}
func TestMiddleware_FailureWithoutAsynqCtx_RecordsRow(t *testing.T) {
repo := &fakeRepo{}
payload, _ := json.Marshal(map[string]any{
"tenant_id": 42,
"knowledge_base_id": "kb-abc",
"knowledge_id": "k-1",
})
wantErr := errors.New("boom")
gotErr := runMiddleware(repo, "wiki:ingest", payload, wantErr)
if !errors.Is(gotErr, wantErr) {
t.Fatalf("expected the original task error to propagate, got %v", gotErr)
}
if got := repo.rowCount(); got != 1 {
t.Fatalf("expected exactly one insert, got %d", got)
}
row := repo.captureRow(0)
if row.TaskType != "wiki:ingest" {
t.Errorf("task_type: got %q", row.TaskType)
}
if row.TenantID != 42 {
t.Errorf("tenant_id: got %d", row.TenantID)
}
if row.Scope != types.TaskScopeKnowledgeBase || row.ScopeID != "kb-abc" {
t.Errorf("scope tuple: got (%q, %q)", row.Scope, row.ScopeID)
}
if row.RelatedID != "k-1" {
t.Errorf("related_id: got %q", row.RelatedID)
}
if row.LastError != "boom" {
t.Errorf("last_error: got %q", row.LastError)
}
// Outside an asynq worker ctx GetRetryCount returns !ok, so the
// middleware records 0 attempts (clearer than a misleading 1).
if row.FailCount != 0 {
t.Errorf("fail_count: expected 0 outside worker ctx, got %d", row.FailCount)
}
if row.FailedAt.IsZero() {
t.Error("failed_at: expected non-zero timestamp")
}
}
func TestMiddleware_UnknownPayload_StillRecords(t *testing.T) {
repo := &fakeRepo{}
// Payload that doesn't carry any of the well-known identifier keys.
payload := []byte(`{"some_unrelated":"value"}`)
_ = runMiddleware(repo, "future:weird", payload, errors.New("nope"))
if got := repo.rowCount(); got != 1 {
t.Fatalf("expected one insert for unknown payload, got %d", got)
}
row := repo.captureRow(0)
if row.Scope != types.TaskScopeUnknown {
t.Errorf("expected scope=unknown for unparseable payload, got %q", row.Scope)
}
if row.ScopeID != "" {
t.Errorf("expected empty scope_id for unknown scope, got %q", row.ScopeID)
}
if string(row.Payload) != string(payload) {
t.Errorf("expected payload preserved verbatim, got %s", string(row.Payload))
}
}
func TestMiddleware_NilPayload_DefaultsToEmptyObject(t *testing.T) {
repo := &fakeRepo{}
_ = runMiddleware(repo, "any:task", nil, errors.New("err"))
row := repo.captureRow(0)
if row == nil {
t.Fatal("expected a row")
}
if string(row.Payload) != "{}" {
t.Errorf("expected payload normalized to {}, got %s", string(row.Payload))
}
}
func TestMiddleware_LongErrorTruncated(t *testing.T) {
repo := &fakeRepo{}
long := make([]byte, 16384)
for i := range long {
long[i] = 'x'
}
_ = runMiddleware(repo, "any", []byte("{}"), errors.New(string(long)))
row := repo.captureRow(0)
if got := len(row.LastError); got > 8192 {
t.Errorf("expected last_error truncated to <=8192 bytes, got %d", got)
}
const suffix = "...(truncated)"
if got := row.LastError[len(row.LastError)-len(suffix):]; got != suffix {
t.Errorf("expected truncation suffix %q, got %q", suffix, got)
}
}
func TestMiddleware_RepoFailure_DoesNotMaskTaskError(t *testing.T) {
repo := &fakeRepo{fail: errors.New("db down")}
wantErr := errors.New("task boom")
gotErr := runMiddleware(repo, "any", []byte(`{"tenant_id":1}`), wantErr)
if !errors.Is(gotErr, wantErr) {
t.Fatalf("repo failure must not mask task error; got %v", gotErr)
}
}
func TestInferScope_Priority(t *testing.T) {
tests := []struct {
name string
probe payloadProbe
wantScope string
wantID string
}{
{
name: "knowledge_base_id wins over knowledge_id",
probe: payloadProbe{KnowledgeBaseID: "kb1", KnowledgeID: "k1"},
wantScope: types.TaskScopeKnowledgeBase,
wantID: "kb1",
},
{
name: "kb_id alias resolves to knowledge_base scope",
probe: payloadProbe{KBID: "kb-faq"},
wantScope: types.TaskScopeKnowledgeBase,
wantID: "kb-faq",
},
{
name: "source_kb_id from KnowledgeMovePayload",
probe: payloadProbe{SourceKBID: "src-kb"},
wantScope: types.TaskScopeKnowledgeBase,
wantID: "src-kb",
},
{
name: "knowledge_id only",
probe: payloadProbe{KnowledgeID: "k-only"},
wantScope: types.TaskScopeKnowledge,
wantID: "k-only",
},
{
name: "tenant_id fallback",
probe: payloadProbe{TenantID: 7},
wantScope: types.TaskScopeTenant,
wantID: "7",
},
{
name: "no identifiers — unknown scope",
probe: payloadProbe{},
wantScope: types.TaskScopeUnknown,
wantID: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotScope, gotID := inferScope(tc.probe)
if gotScope != tc.wantScope || gotID != tc.wantID {
t.Errorf("inferScope: got (%q, %q), want (%q, %q)",
gotScope, gotID, tc.wantScope, tc.wantID)
}
})
}
}
+41
View File
@@ -0,0 +1,41 @@
package middleware
import (
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// auditServiceContextKey is the gin context key used by
// AuditServiceProvider to stash the running AuditLogService so that
// middleware functions (rbac.go's RequireRole / RequireOwnershipOrRole)
// can pull it out without needing the service threaded into their
// signatures. Same pattern as the langfuse gin middleware.
const auditServiceContextKey = "weknora.audit_service"
// AuditServiceProvider returns a gin middleware that injects the audit
// service into every request's gin.Context. Wiring is centralised in
// router.NewRouter so each request gets the same instance for the
// lifetime of the process; the middleware is a no-op when svc is nil
// (e.g. lite mode where audit isn't configured) so the rbac reject
// path degrades gracefully.
func AuditServiceProvider(svc interfaces.AuditLogService) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil {
c.Set(auditServiceContextKey, svc)
}
c.Next()
}
}
// AuditServiceFromContext fetches the audit service injected by
// AuditServiceProvider, or returns nil if no provider was wired
// upstream. Callers MUST nil-check before invoking — audit failure
// must never break the underlying business operation.
func AuditServiceFromContext(c *gin.Context) interfaces.AuditLogService {
if v, ok := c.Get(auditServiceContextKey); ok {
if svc, ok := v.(interfaces.AuditLogService); ok {
return svc
}
}
return nil
}
+827
View File
@@ -0,0 +1,827 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
const (
defaultExternalUserIDHeader = "X-External-User-ID"
defaultExternalUserTokenHeader = "X-External-User-Token"
maxExternalUserIDLen = 128
maxExternalUserTokenTTL = 24 * time.Hour
)
var (
errMissingDirectHeader = errors.New("missing external user id header")
errInvalidExternalUserID = errors.New("invalid external user id")
errInvalidExternalUserToken = errors.New("invalid external user token")
)
// 无需认证的API列表
var noAuthAPI = map[string][]string{
"/health": {"GET"},
"/api/v1/auth/register": {"POST"},
"/api/v1/auth/login": {"POST"},
"/api/v1/auth/auto-setup": {"POST"},
// Share-link surfaces accept a plaintext invite token from anonymous
// callers (an invitee who hasn't registered yet). They are registered
// as public routes in RegisterAuthRoutes and rate-limited by IP, so the
// global Auth middleware must let them through — otherwise opening a
// share link while logged out 401s and the frontend bounces the user to
// /login instead of the register page (issue #1617).
"/api/v1/auth/invitations/lookup": {"POST"},
"/api/v1/auth/register-by-invite": {"POST"},
"/api/v1/auth/config": {"GET"},
"/api/v1/auth/oidc/config": {"GET"},
"/api/v1/auth/oidc/url": {"GET"},
"/api/v1/auth/oidc/start": {"GET"},
"/api/v1/auth/oidc/callback": {"GET"},
// MCP OAuth provider redirect: the third-party authorization server
// redirects the browser here without a WeKnora bearer token. The request
// is authenticated by the opaque, single-use `state` parameter instead.
"/api/v1/mcp-oauth/callback": {"GET"},
"/api/v1/auth/refresh": {"POST"},
// IM platforms (Feishu, Slack, etc.) commonly issue a HEAD request
// before GET to validate Content-Type / Content-Length when rendering
// image previews — both verbs must be allowed for image links to work.
"/api/v1/files/presigned": {"GET", "HEAD"},
}
// 检查请求是否在无需认证的API列表中
func isNoAuthAPI(path string, method string) bool {
for api, methods := range noAuthAPI {
// 如果以*结尾,按照前缀匹配,否则按照全路径匹配
if strings.HasSuffix(api, "*") {
if strings.HasPrefix(path, strings.TrimSuffix(api, "*")) && slices.Contains(methods, method) {
return true
}
} else if path == api && slices.Contains(methods, method) {
return true
}
}
return false
}
// isTenantOptionalAPI lists authenticated identity-level operations that are
// meaningful before a user belongs to any tenant. Every other authenticated
// route remains tenant-scoped and returns TENANT_REQUIRED when the JWT and
// request headers do not resolve a tenant.
func isTenantOptionalAPI(path, method string) bool {
switch {
case path == "/api/v1/auth/me" && (method == http.MethodGet || method == http.MethodPut):
return true
case path == "/api/v1/auth/me/preferences" && method == http.MethodPut:
return true
case path == "/api/v1/auth/logout" && method == http.MethodPost:
return true
case path == "/api/v1/auth/change-password" && method == http.MethodPost:
return true
case path == "/api/v1/auth/validate" && method == http.MethodGet:
return true
case path == "/api/v1/auth/switch-tenant" && method == http.MethodPost:
return true
case path == "/api/v1/tenants" && method == http.MethodPost:
return true
case strings.HasPrefix(path, "/api/v1/me/invitations"):
return true
default:
return false
}
}
func attachTenantlessUserContext(c *gin.Context, user *types.User) {
applyAuthSession(c, authSession{
User: user,
Principal: types.Principal{Type: types.PrincipalWebUser, ID: user.ID},
SystemAdmin: user.IsSystemAdmin,
})
}
// Auth 认证中间件。按顺序尝试三条通道:
//
// 1. 白名单(isNoAuthAPI)/ OPTIONS 预检 —— 直接放行;
// 2. Bearer JWT —— 成功则走 authenticateJWTUser 完成空间/角色解析;
// 校验失败不立即拒绝,继续尝试 X-API-Key(保持既有兼容行为:
// 携带过期 JWT 但同时带有效 API key 的客户端仍可通过);
// 3. X-API-Key —— authenticateAPIKeyRequest。
//
// 三条通道都未命中时返回 401;若调用方提交过 Bearer token,错误消息
// 明确指出 token 无效而不是笼统的 "missing authentication",方便客户端
// 区分「没登录」和「登录态过期」。
func Auth(
tenantService interfaces.TenantService,
userService interfaces.UserService,
memberService interfaces.TenantMemberService,
apiKeyService interfaces.TenantAPIKeyService,
cfg *config.Config,
) gin.HandlerFunc {
return func(c *gin.Context) {
// ignore OPTIONS request
if c.Request.Method == http.MethodOptions {
c.Next()
return
}
// 检查请求是否在无需认证的API列表中
if isNoAuthAPI(c.Request.URL.Path, c.Request.Method) {
c.Next()
return
}
// 尝试JWT Token认证
bearerPresented := false
if token, ok := bearerToken(c); ok {
bearerPresented = true
user, jwtTenantID, err := userService.ValidateToken(c.Request.Context(), token)
if err == nil && user != nil {
if authenticateJWTUser(c, tenantService, memberService, cfg, user, jwtTenantID) {
c.Next()
}
return
}
logger.Warnf(c.Request.Context(), "[auth] bearer token rejected: %v", err)
}
// 尝试X-API-Key认证(兼容模式)
if apiKey := c.GetHeader("X-API-Key"); apiKey != "" {
if apiKeyService == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: API key service is not configured"})
c.Abort()
return
}
if authenticateAPIKeyRequest(c, tenantService, userService, apiKeyService, apiKey) {
c.Next()
}
return
}
// 没有任何通道认证成功
if bearerPresented {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid or expired token"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: missing authentication"})
}
c.Abort()
}
}
// bearerToken extracts the Bearer token from the Authorization header.
func bearerToken(c *gin.Context) (string, bool) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return "", false
}
return strings.TrimPrefix(authHeader, "Bearer "), true
}
// authenticateJWTUser finishes authentication for a validated JWT user:
// it resolves the target tenant (X-Tenant-ID switch / JWT claim / first
// active membership), resolves the caller's role inside that tenant, and
// attaches the session context. Returns true when the request may proceed;
// on false the response has already been written and the request aborted.
func authenticateJWTUser(
c *gin.Context,
tenantService interfaces.TenantService,
memberService interfaces.TenantMemberService,
cfg *config.Config,
user *types.User,
jwtTenantID uint64,
) bool {
ctx := c.Request.Context()
targetTenantID, tenant, crossTenantSwitch, ok := resolveTargetTenant(c, tenantService, memberService, cfg, user, jwtTenantID)
if !ok {
return false
}
if targetTenantID == 0 {
// 无可用空间:身份级路由(/auth/me 等)放行为 tenantless 会话,
// 其余路由返回 TENANT_REQUIRED 让前端引导用户创建/加入空间。
if isTenantOptionalAPI(c.Request.URL.Path, c.Request.Method) {
attachTenantlessUserContext(c, user)
return true
}
c.JSON(http.StatusConflict, gin.H{
"error": "Workspace required",
"code": "TENANT_REQUIRED",
})
c.Abort()
return false
}
// 获取空间信息(X-Tenant-ID 切换路径已在 resolveTargetTenant 内取到,
// 避免二次查库)。
if tenant == nil {
var err error
tenant, err = tenantService.GetTenantByID(ctx, targetTenantID)
if err != nil || tenant == nil {
logger.Warnf(ctx, "[auth] tenant lookup failed: tenant=%d user=%s err=%v", targetTenantID, user.ID, err)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized: invalid workspace",
})
c.Abort()
return false
}
}
// 解析当前空间内的角色 (issue #1303)
role, ok := resolveTenantRole(ctx, memberService, user, targetTenantID, crossTenantSwitch, cfg)
if !ok {
// 强制 RBAC 时,缺少 active membership 即拒绝;fail-open 路径已在
// resolveTenantRole 内部处理。
logger.Warnf(ctx, "User %s has no active membership in tenant %d", user.ID, targetTenantID)
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: not a member of the target workspace",
})
c.Abort()
return false
}
logger.Infof(ctx,
"[auth] resolved role=%s for user=%s in tenant=%d (jwt_tenant=%d, header=%q, cross_switch=%v)",
role, user.ID, targetTenantID, jwtTenantID, c.GetHeader("X-Tenant-ID"), crossTenantSwitch)
applyAuthSession(c, authSession{
User: user,
Principal: types.Principal{Type: types.PrincipalWebUser, ID: user.ID},
TenantID: targetTenantID,
Tenant: tenant,
Role: role,
SystemAdmin: user.IsSystemAdmin,
})
return true
}
// resolveTargetTenant decides which tenant this request operates in.
//
// Priority:
// 1. X-Tenant-ID header — must parse to a positive integer, the user must
// be allowed to access it (home tenant / cross-tenant superuser / active
// membership, see IsTenantAccessible) and the tenant must exist. The
// fetched tenant is returned so the caller doesn't refetch it.
// 2. JWT tenant claim (falling back to user.TenantID when the claim is 0).
// 3. First active membership — lets a tenantless session become usable as
// soon as an invitation is accepted (see resolveFirstMembershipTarget).
//
// Returns ok=false when the response has already been written (malformed
// header, inaccessible or missing target tenant). targetTenantID == 0 with
// ok=true means "authenticated but no usable workspace" — the caller decides
// between tenantless routes and TENANT_REQUIRED.
func resolveTargetTenant(
c *gin.Context,
tenantService interfaces.TenantService,
memberService interfaces.TenantMemberService,
cfg *config.Config,
user *types.User,
jwtTenantID uint64,
) (targetTenantID uint64, tenant *types.Tenant, crossTenantSwitch bool, ok bool) {
ctx := c.Request.Context()
// 默认 target = JWT 里的 tenant_id(来自登录或 /auth/switch-tenant),
// 兼容 ValidateToken 的 fallback:claim 缺失时 jwtTenantID == user.TenantID。
targetTenantID = jwtTenantID
if targetTenantID == 0 {
targetTenantID = user.TenantID
}
if tenantHeader := c.GetHeader("X-Tenant-ID"); tenantHeader != "" {
// 解析目标空间ID。畸形 / 零值必须显式拒绝:静默忽略会让坏掉的
// 前端/SDK 悄悄写错空间,反而看不到问题。与 RequirePathTenantMatch
// 中对 :id 的校验保持一致(非空、可解析、>0)。
parsedTenantID, err := strconv.ParseUint(tenantHeader, 10, 64)
if err != nil || parsedTenantID == 0 {
logger.Warnf(ctx, "Invalid X-Tenant-ID header from user=%s: %q (err=%v)", user.ID, tenantHeader, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid X-Tenant-ID header"})
c.Abort()
return 0, nil, false, false
}
// 检查用户是否有权限访问目标空间:自家空间、跨空间超管、或
// 有 active membership 行——三选一,由 IsTenantAccessible 统一判定。
if !IsTenantAccessible(ctx, user, parsedTenantID, memberService, cfg) {
logger.Warnf(ctx, "User %s attempted to access tenant %d without permission", user.ID, parsedTenantID)
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: insufficient permissions to access target workspace",
})
c.Abort()
return 0, nil, false, false
}
// 验证目标空间是否存在
targetTenant, err := tenantService.GetTenantByID(ctx, parsedTenantID)
if err != nil || targetTenant == nil {
logger.Warnf(ctx, "Error getting target tenant by ID: %v, tenantID: %d", err, parsedTenantID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target workspace ID"})
c.Abort()
return 0, nil, false, false
}
logger.Infof(ctx, "User %s switching to tenant %d", user.ID, parsedTenantID)
return parsedTenantID, targetTenant, parsedTenantID != user.TenantID, true
}
if targetTenantID == 0 {
targetTenantID = resolveFirstMembershipTarget(ctx, user, memberService, tenantService)
}
return targetTenantID, nil, targetTenantID != user.TenantID, true
}
// resolveFirstMembershipTarget lets a tenantless session immediately become
// usable once an active membership exists (for example after accepting its
// first invitation or being added directly by an administrator). The user
// service persists the same earliest-membership choice on the next token
// issuance; middleware keeps the current JWT usable until then.
func resolveFirstMembershipTarget(
ctx context.Context,
user *types.User,
memberService interfaces.TenantMemberService,
tenantService interfaces.TenantService,
) uint64 {
if user == nil || memberService == nil || tenantService == nil {
return 0
}
members, err := memberService.ListByUser(ctx, user.ID)
if err != nil {
logger.Warnf(ctx, "Failed to list memberships for tenantless user %s: %v", user.ID, err)
return 0
}
for _, member := range members {
if member == nil || member.TenantID == 0 || member.Status != types.TenantMemberStatusActive {
continue
}
tenant, err := tenantService.GetTenantByID(ctx, member.TenantID)
if err == nil && tenant != nil {
return member.TenantID
}
}
return 0
}
func authenticateAPIKeyRequest(
c *gin.Context,
tenantService interfaces.TenantService,
userService interfaces.UserService,
apiKeyService interfaces.TenantAPIKeyService,
apiKey string,
) bool {
ctx := c.Request.Context()
// AuthenticateAPIKey resolves the key by SHA-256 hash (see startup
// BackfillMissingKeyHashes for migration 000065 placeholder rows).
key, err := apiKeyService.AuthenticateAPIKey(ctx, apiKey)
if err != nil || key == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key"})
c.Abort()
return false
}
if key.IsPlatform() {
tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-ID"))
if tenantHeader == "" {
if !isPlatformTenantOptionalAPI(c.Request.URL.Path, c.Request.Method) {
c.JSON(http.StatusConflict, gin.H{
"error": "Workspace required: platform API keys must send X-Tenant-ID",
"code": "TENANT_REQUIRED",
})
c.Abort()
return false
}
attachPlatformAPIKeyAuthContext(c, key)
} else {
targetTenantID, parseErr := strconv.ParseUint(tenantHeader, 10, 64)
if parseErr != nil || targetTenantID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid X-Tenant-ID header"})
c.Abort()
return false
}
attachAPIKeyAuthContext(c, tenantService, userService, targetTenantID, key)
}
} else {
tenantID := key.TenantIDValue()
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key scope"})
c.Abort()
return false
}
if tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-ID")); tenantHeader != "" {
requestedTenantID, parseErr := strconv.ParseUint(tenantHeader, 10, 64)
if parseErr != nil || requestedTenantID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid X-Tenant-ID header"})
c.Abort()
return false
}
if requestedTenantID != tenantID {
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: workspace API key cannot switch workspaces",
})
c.Abort()
return false
}
}
attachAPIKeyAuthContext(c, tenantService, userService, tenantID, key)
}
if c.IsAborted() {
return false
}
// Per-route API-key authorization (full access + capabilities + KB scope)
// is enforced by middleware.APIKeyRouteAuthorizer on the /api/v1 group.
// Key-management and any other undeclared route is denied there.
return true
}
func isPlatformTenantOptionalAPI(path, method string) bool {
path = strings.TrimSuffix(strings.TrimSpace(path), "/")
// 精确匹配 admin 控制面前缀("/api/v1/system/admin" 本身或其子路径)。
// 裸 HasPrefix 会误放行诸如 "/api/v1/system/admin-foo" 的同前缀路径。
if path == "/api/v1/system/admin" || strings.HasPrefix(path, "/api/v1/system/admin/") {
return true
}
if method == http.MethodGet && (path == "/api/v1/tenants/all" || path == "/api/v1/tenants/search") {
return true
}
return method == http.MethodPost && path == "/api/v1/tenants"
}
func attachPlatformAPIKeyAuthContext(c *gin.Context, key *types.TenantAPIKey) {
principal, user := platformAPIKeyIdentity(key)
applyAuthSession(c, authSession{
User: user,
Principal: principal,
// This role context exists only for legacy guard compatibility after
// RequireRole short-circuits API-key principals; the key's real
// authority is its platform capabilities enforced by the APIKeyGate.
Role: types.TenantRoleViewer,
APIKeyScope: &types.TenantAPIKeyScope{
KeyID: key.ID,
ScopeType: types.APIKeyScopePlatform,
FullAccess: false,
Capabilities: key.Capabilities,
},
})
}
func platformAPIKeyIdentity(key *types.TenantAPIKey) (types.Principal, *types.User) {
keyID := uint64(0)
if key != nil {
keyID = key.ID
}
principal := types.Principal{Type: types.PrincipalAPIPlatform, ID: strconv.FormatUint(keyID, 10)}
userID := principal.StorageID()
return principal, &types.User{
ID: userID,
Username: userID,
Email: fmt.Sprintf("platform-api-key-%d@api-key.local", keyID),
IsActive: true,
}
}
func attachAPIKeyAuthContext(
c *gin.Context,
tenantService interfaces.TenantService,
userService interfaces.UserService,
tenantID uint64,
key *types.TenantAPIKey,
) {
t, err := tenantService.GetTenantByID(c.Request.Context(), tenantID)
if err != nil {
logger.Warnf(c.Request.Context(), "[auth] API key tenant lookup failed: tenant=%d err=%v", tenantID, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: invalid API key"})
c.Abort()
return
}
var user *types.User
var principal types.Principal
if key != nil && key.IsPlatform() {
// A platform key keeps one stable machine identity while selecting the
// target workspace through X-Tenant-ID. Tenant API-principal modes and
// tenant-owned synthetic users must not rewrite that identity.
principal, user = platformAPIKeyIdentity(key)
user.TenantID = tenantID
} else {
user, err = userService.GetUserByTenantID(c.Request.Context(), tenantID)
if err != nil || user == nil {
user = &types.User{
ID: fmt.Sprintf("system-%d", tenantID),
Username: fmt.Sprintf("system-%d", tenantID),
Email: fmt.Sprintf("system-%d@api-key.local", tenantID),
TenantID: tenantID,
IsActive: true,
}
logger.Infof(c.Request.Context(),
"No user found for tenant %d via API key, using synthetic system user %s", tenantID, user.ID)
}
var principalErr error
principal, principalErr = resolveAPIPrincipal(c.Request.Context(), t, c.Request.Header)
if principalErr != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": apiPrincipalAuthErrorMessage(principalErr)})
c.Abort()
return
}
}
// This role context exists only for legacy guard compatibility after
// RequireRole short-circuits API-key principals. The API key's real
// authority is FullAccess + Capabilities + KnowledgeBaseIDs.
apiKeyTenantRoleContext := types.TenantRoleViewer
fullAccess := key != nil && key.FullAccess && !key.IsPlatform()
if fullAccess {
apiKeyTenantRoleContext = types.TenantRoleOwner
}
session := authSession{
User: user,
Principal: principal,
TenantID: tenantID,
Tenant: t,
Role: apiKeyTenantRoleContext,
}
if key != nil {
session.APIKeyScope = &types.TenantAPIKeyScope{
KeyID: key.ID,
ScopeType: key.ScopeType,
FullAccess: fullAccess,
KnowledgeBaseIDs: key.KnowledgeBaseIDs,
Capabilities: key.Capabilities,
}
}
applyAuthSession(c, session)
}
func resolveAPIPrincipal(ctx context.Context, tenant *types.Tenant, header http.Header) (types.Principal, error) {
tenantID := uint64(0)
if tenant != nil {
tenantID = tenant.ID
}
fallback := types.Principal{
Type: types.PrincipalAPITenant,
ID: strconv.FormatUint(tenantID, 10),
}
if tenant == nil || tenantID == 0 {
return fallback, nil
}
cfg := tenant.APIPrincipalConfig
if cfg == nil || cfg.Mode == "" || cfg.Mode == types.APIPrincipalModeTenant {
return fallback, nil
}
switch cfg.Mode {
case types.APIPrincipalModeDirect:
externalUserID := strings.TrimSpace(header.Get(defaultExternalUserIDHeader))
if externalUserID == "" {
if cfg.RequireDirectHeader {
return types.Principal{}, errMissingDirectHeader
}
return fallback, nil
}
if err := validateExternalUserID(externalUserID); err != nil {
return types.Principal{}, fmt.Errorf("%w: %v", errInvalidExternalUserID, err)
}
return types.Principal{
Type: types.PrincipalAPIExternalUser,
ID: strconv.FormatUint(tenantID, 10) + ":" + externalUserID,
}, nil
case types.APIPrincipalModeSignedToken:
externalUserID, err := verifyExternalUserJWT(header.Get(defaultExternalUserTokenHeader), tenantID, cfg.HMACSecret)
if err != nil || externalUserID == "" {
logger.Warnf(ctx, "invalid external user token for tenant=%d: %v", tenantID, err)
return types.Principal{}, fmt.Errorf("%w: %w", errInvalidExternalUserToken, err)
}
if err := validateExternalUserID(externalUserID); err != nil {
return types.Principal{}, fmt.Errorf("%w: %v", errInvalidExternalUserID, err)
}
return types.Principal{
Type: types.PrincipalAPIExternalUser,
ID: strconv.FormatUint(tenantID, 10) + ":" + externalUserID,
}, nil
default:
return fallback, nil
}
}
func verifyExternalUserJWT(tokenString string, tenantID uint64, secret string) (string, error) {
tokenString = strings.TrimSpace(tokenString)
secret = strings.TrimSpace(secret)
if tokenString == "" {
return "", errors.New("missing external user token")
}
if secret == "" {
return "", errors.New("external user token secret is not configured")
}
claims := jwt.MapClaims{}
parser := jwt.NewParser(
jwt.WithAudience("weknora"),
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
)
token, err := parser.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return "", err
}
if token == nil || !token.Valid {
return "", errors.New("invalid external user token")
}
exp, err := claims.GetExpirationTime()
if err != nil || exp == nil {
return "", errors.New("missing expiration")
}
if time.Until(exp.Time) > maxExternalUserTokenTTL {
return "", fmt.Errorf("token lifetime exceeds %s", maxExternalUserTokenTTL)
}
if nbf, nbfErr := claims.GetNotBefore(); nbfErr == nil && nbf != nil && time.Now().Before(nbf.Time) {
return "", errors.New("token not yet valid")
}
if got := principalTenantIDFromClaims(claims); got != tenantID {
return "", fmt.Errorf("workspace mismatch: got %d want %d", got, tenantID)
}
sub, _ := claims["sub"].(string)
sub = strings.TrimSpace(sub)
if sub == "" {
return "", errors.New("missing subject")
}
return sub, nil
}
func validateExternalUserID(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return errors.New("empty external user id")
}
if len(id) > maxExternalUserIDLen {
return fmt.Errorf("external user id too long (max %d)", maxExternalUserIDLen)
}
for _, r := range id {
if r < 0x20 || r == 0x7f {
return errors.New("external user id contains invalid characters")
}
}
return nil
}
func apiPrincipalAuthErrorMessage(err error) string {
switch {
case errors.Is(err, errMissingDirectHeader):
return "Unauthorized: missing external user id header"
case errors.Is(err, errInvalidExternalUserID):
return "Unauthorized: invalid external user id"
case errors.Is(err, errInvalidExternalUserToken):
return "Unauthorized: invalid external user token"
default:
return "Unauthorized: invalid external user token"
}
}
func principalTenantIDFromClaims(claims jwt.MapClaims) uint64 {
v, ok := claims["tenant_id"]
if !ok {
return 0
}
switch t := v.(type) {
case float64:
if t <= 0 {
return 0
}
return uint64(t)
case int64:
if t <= 0 {
return 0
}
return uint64(t)
case uint64:
return t
case json.Number:
n, err := strconv.ParseUint(t.String(), 10, 64)
if err != nil {
return 0
}
return n
case string:
n, err := strconv.ParseUint(strings.TrimSpace(t), 10, 64)
if err != nil {
return 0
}
return n
default:
return 0
}
}
// resolveTenantRole determines the caller's TenantRole inside targetTenantID.
//
// Order of resolution:
// 1. Active TenantMember row → return that role.
// 2. Cross-tenant superuser switch (X-Tenant-ID with CanAccessAllTenants=true)
// → grant Admin in the target tenant. Org admins are intentionally not
// promoted to Owner; tenant deletion / API-key rotation should always
// stay with a real Owner inside the target tenant. Cross-tenant access
// is also never allowed to trigger the orphan-tenant auto-promotion
// below — a superuser only visits, never claims ownership.
// 3. No membership but the tenant currently has zero active members AND
// the caller is authenticating into their own home tenant (i.e.
// targetTenantID == user.TenantID and this is not a cross-tenant
// switch). This is the API-key-only orphan-tenant self-heal path:
// the registrant becomes Owner of the tenant their own user record
// points to. Any other path (cross-tenant switch, JWT minted for a
// foreign tenant, etc.) is intentionally excluded to avoid silent
// ownership grabs.
// 4. Otherwise → return ok=false. Caller decides:
// - When EnableRBAC=true (or cfg unavailable): treat as 403.
// - When EnableRBAC=false: fail open with Admin so existing deployments
// don't break in the rollout window where memberships might lag user
// records.
//
// The boolean second return value reports whether enforcement should reject
// the request. It is true whenever a usable role was found OR fail-open
// applies; false only when we want callers to abort with 403.
func resolveTenantRole(
ctx context.Context,
memberService interfaces.TenantMemberService,
user *types.User,
targetTenantID uint64,
crossTenantSwitch bool,
cfg *config.Config,
) (types.TenantRole, bool) {
// 1. 正常成员关系
member, err := memberService.GetMembership(ctx, user.ID, targetTenantID)
if err == nil && member != nil && member.Status == types.TenantMemberStatusActive {
logger.Infof(ctx,
"[auth] resolveTenantRole step1 hit: user=%s tenant=%d row_role=%s row_status=%s",
user.ID, targetTenantID, member.Role, member.Status)
return member.Role, true
}
if err != nil {
logger.Warnf(ctx, "tenant_members lookup failed user=%s tenant=%d: %v",
user.ID, targetTenantID, err)
// Fall through; treat lookup errors the same as "no membership
// found" so a transient DB hiccup doesn't lock everyone out.
} else {
var statusInfo string
if member == nil {
statusInfo = "no_row"
} else {
statusInfo = "row_exists status=" + string(member.Status) + " role=" + string(member.Role)
}
logger.Warnf(ctx,
"[auth] resolveTenantRole step1 miss: user=%s tenant=%d (%s)",
user.ID, targetTenantID, statusInfo)
}
// 2. 跨空间超管直通:CanAccessAllTenants 用户切到别的空间时不强制要求 membership。
// 注意:这里只授予临时 Admin 角色,不写入 tenant_members,避免"看一眼别人空间"
// 意外升级为持久化所有权。
if crossTenantSwitch && user.CanAccessAllTenants {
logger.Infof(ctx,
"[auth] resolveTenantRole step2 (cross-tenant superuser) -> Admin: user=%s tenant=%d",
user.ID, targetTenantID)
return types.TenantRoleAdmin, true
}
// 3. 孤儿空间自愈:仅当用户登录的是自己的 home tenant、且该空间尚无任何活跃成员时
// 允许自动晋升为 Owner。跨空间 switch / JWT 指向他人空间的场景一律不进入此分支,
// 防止越权获得他人空间的 Owner 权限。
isHomeTenant := !crossTenantSwitch && targetTenantID == user.TenantID
if isHomeTenant {
hasAny, anyErr := memberService.HasAnyMembers(ctx, targetTenantID)
if anyErr == nil && !hasAny {
if _, e := memberService.AddMember(
ctx, user.ID, targetTenantID, types.TenantRoleOwner, nil,
); e == nil {
logger.Infof(ctx,
"[audit] Auto-promoted user %s to Owner of orphan tenant %d (home_tenant=true)",
user.ID, targetTenantID,
)
return types.TenantRoleOwner, true
} else {
logger.Warnf(ctx, "Failed to auto-promote user %s in tenant %d: %v",
user.ID, targetTenantID, e)
}
}
}
// 4. 兜底:根据 EnableRBAC 决定 fail-closed 还是 fail-open
if cfg != nil && cfg.Tenant.IsRBACEnforced() {
logger.Warnf(ctx,
"[auth] resolveTenantRole step4 fail-closed (EnableRBAC=true): user=%s tenant=%d",
user.ID, targetTenantID)
return "", false
}
logger.Warnf(ctx,
"[auth] resolveTenantRole step4 fail-open (EnableRBAC=false) -> Admin: user=%s tenant=%d",
user.ID, targetTenantID)
// fail-open 期间保持现有行为(每个登录用户在自己空间里都是"管理员")。
return types.TenantRoleAdmin, true
}
@@ -0,0 +1,175 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/golang-jwt/jwt/v5"
)
func signedExternalUserToken(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return token
}
func TestResolveAPIPrincipalDefaultsToTenant(t *testing.T) {
p, err := resolveAPIPrincipal(context.Background(), &types.Tenant{ID: 7}, http.Header{})
if err != nil {
t.Fatalf("resolveAPIPrincipal error = %v", err)
}
if p.Type != types.PrincipalAPITenant || p.ID != "7" {
t.Fatalf("principal = %#v", p)
}
}
func TestResolveAPIPrincipalDirectHeader(t *testing.T) {
header := http.Header{}
header.Set("X-External-User-ID", "external-u1")
p, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeDirect,
},
}, header)
if err != nil {
t.Fatalf("resolveAPIPrincipal error = %v", err)
}
if p.Type != types.PrincipalAPIExternalUser || p.ID != "7:external-u1" {
t.Fatalf("principal = %#v", p)
}
}
func TestResolveAPIPrincipalSignedToken(t *testing.T) {
secret := "test-secret"
header := http.Header{}
header.Set("X-External-User-Token", signedExternalUserToken(t, secret, jwt.MapClaims{
"sub": "external-u1",
"tenant_id": float64(7),
"aud": "weknora",
"exp": time.Now().Add(time.Minute).Unix(),
}))
p, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeSignedToken,
HMACSecret: secret,
},
}, header)
if err != nil {
t.Fatalf("resolveAPIPrincipal error = %v", err)
}
if p.Type != types.PrincipalAPIExternalUser || p.ID != "7:external-u1" {
t.Fatalf("principal = %#v", p)
}
}
func TestResolveAPIPrincipalSignedTokenRejectsWrongTenant(t *testing.T) {
secret := "test-secret"
header := http.Header{}
header.Set("X-External-User-Token", signedExternalUserToken(t, secret, jwt.MapClaims{
"sub": "external-u1",
"tenant_id": float64(8),
"aud": "weknora",
"exp": time.Now().Add(time.Minute).Unix(),
}))
p, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeSignedToken,
HMACSecret: secret,
},
}, header)
if err == nil {
t.Fatalf("resolveAPIPrincipal error = nil, want error")
}
_ = p
}
func TestResolveAPIPrincipalSignedTokenRejectsExpired(t *testing.T) {
secret := "test-secret"
header := http.Header{}
header.Set("X-External-User-Token", signedExternalUserToken(t, secret, jwt.MapClaims{
"sub": "external-u1",
"tenant_id": float64(7),
"aud": "weknora",
"exp": time.Now().Add(-time.Minute).Unix(),
}))
p, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeSignedToken,
HMACSecret: secret,
},
}, header)
if err == nil {
t.Fatalf("resolveAPIPrincipal error = nil, want error")
}
_ = p
}
func TestResolveAPIPrincipalDirectHeaderRequired(t *testing.T) {
_, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeDirect,
RequireDirectHeader: true,
},
}, http.Header{})
if !errors.Is(err, errMissingDirectHeader) {
t.Fatalf("resolveAPIPrincipal error = %v, want errMissingDirectHeader", err)
}
}
func TestResolveAPIPrincipalDirectHeaderRejectsInvalidUserID(t *testing.T) {
header := http.Header{}
header.Set("X-External-User-ID", strings.Repeat("a", maxExternalUserIDLen+1))
_, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeDirect,
},
}, header)
if !errors.Is(err, errInvalidExternalUserID) {
t.Fatalf("resolveAPIPrincipal error = %v, want errInvalidExternalUserID", err)
}
}
func TestResolveAPIPrincipalSignedTokenRejectsLongLifetime(t *testing.T) {
secret := "test-secret"
header := http.Header{}
header.Set("X-External-User-Token", signedExternalUserToken(t, secret, jwt.MapClaims{
"sub": "external-u1",
"tenant_id": float64(7),
"aud": "weknora",
"exp": time.Now().Add(48 * time.Hour).Unix(),
}))
_, err := resolveAPIPrincipal(context.Background(), &types.Tenant{
ID: 7,
APIPrincipalConfig: &types.APIPrincipalConfig{
Mode: types.APIPrincipalModeSignedToken,
HMACSecret: secret,
},
}, header)
if err == nil {
t.Fatalf("resolveAPIPrincipal error = nil, want error")
}
}
+81
View File
@@ -0,0 +1,81 @@
package middleware
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// authSession is the resolved identity an auth middleware attaches to the
// request. One struct + one apply function replaces the five near-identical
// "c.Set + context.WithValue" blocks that used to live in the JWT branch,
// the tenantless branch, both API-key attach helpers and EmbedAuth — keeping
// the gin-keys surface (c.Keys) and the request-context surface in lockstep
// is exactly the invariant that was easy to break when each caller wrote the
// pairs out by hand.
type authSession struct {
// User is the authenticated (possibly synthetic) user. Required.
User *types.User
Principal types.Principal
// TenantID / Tenant scope the request to a workspace. TenantID == 0 means
// a tenantless session (identity-level routes only) and attaches neither
// key. Tenant may be nil even when TenantID is set (not expected today,
// but the helper stays tolerant).
TenantID uint64
Tenant *types.Tenant
// Role is the caller's resolved role inside TenantID. Empty string means
// "attach no role key" (tenantless sessions); readers then fall back to
// TenantRoleFromContext's fail-closed Viewer default.
Role types.TenantRole
SystemAdmin bool
// APIKeyScope marks machine principals; the APIKeyGate authorizes them
// per-route from this scope.
APIKeyScope *types.TenantAPIKeyScope
// Extra carries surface-specific context values (e.g. the authenticated
// embed channel) that must be visible on both surfaces like the rest.
Extra map[types.ContextKey]any
}
// applyAuthSession writes the session onto BOTH context surfaces of the
// gin request:
//
// - c.Keys (read via c.Get / helpers that take *gin.Context)
// - c.Request.Context() (read via types.*FromContext)
//
// Every auth middleware must go through this helper instead of hand-writing
// the pairs; a key set on only one surface is a latent bug that surfaces as
// "works in handler A, broken in handler B" depending on which read style
// the handler uses.
func applyAuthSession(c *gin.Context, s authSession) {
ctx := c.Request.Context()
set := func(key types.ContextKey, v any) {
c.Set(key.String(), v)
ctx = context.WithValue(ctx, key, v)
}
if s.TenantID != 0 {
set(types.TenantIDContextKey, s.TenantID)
}
if s.Tenant != nil {
set(types.TenantInfoContextKey, s.Tenant)
}
if s.User != nil {
set(types.UserContextKey, s.User)
set(types.UserIDContextKey, s.User.ID)
}
c.Set(types.PrincipalContextKey.String(), s.Principal)
ctx = types.WithPrincipal(ctx, s.Principal)
if s.Role != "" {
set(types.TenantRoleContextKey, s.Role)
}
set(types.SystemAdminContextKey, s.SystemAdmin)
if s.APIKeyScope != nil {
ctx = types.WithTenantAPIKeyScope(ctx, *s.APIKeyScope)
}
for key, v := range s.Extra {
set(key, v)
}
c.Request = c.Request.WithContext(ctx)
}
+124
View File
@@ -0,0 +1,124 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// TestApplyAuthSessionSetsBothSurfaces locks the core invariant of the
// helper: every attached value must be readable from BOTH c.Keys (c.Get)
// and the request context (types.*FromContext). A key present on only one
// surface is the class of bug the helper exists to prevent.
func TestApplyAuthSessionSetsBothSurfaces(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/knowledge-bases", nil)
user := &types.User{ID: "u1", IsSystemAdmin: true}
tenant := &types.Tenant{ID: 7}
principal := types.Principal{Type: types.PrincipalWebUser, ID: "u1"}
scope := &types.TenantAPIKeyScope{KeyID: 3}
applyAuthSession(c, authSession{
User: user,
Principal: principal,
TenantID: 7,
Tenant: tenant,
Role: types.TenantRoleAdmin,
SystemAdmin: true,
APIKeyScope: scope,
Extra: map[types.ContextKey]any{types.EmbedChannelContextKey: &types.EmbedChannel{ID: "ch"}},
})
ctx := c.Request.Context()
if got, ok := types.TenantIDFromContext(ctx); !ok || got != 7 {
t.Fatalf("ctx tenant id = %d, ok=%v", got, ok)
}
if got, ok := c.Get(types.TenantIDContextKey.String()); !ok || got.(uint64) != 7 {
t.Fatalf("keys tenant id = %v, ok=%v", got, ok)
}
if got, ok := types.TenantInfoFromContext(ctx); !ok || got.ID != 7 {
t.Fatalf("ctx tenant info = %#v, ok=%v", got, ok)
}
if got, ok := types.UserIDFromContext(ctx); !ok || got != "u1" {
t.Fatalf("ctx user id = %q, ok=%v", got, ok)
}
if got, ok := c.Get(types.UserContextKey.String()); !ok || got.(*types.User).ID != "u1" {
t.Fatalf("keys user = %#v, ok=%v", got, ok)
}
if got, ok := types.PrincipalFromContext(ctx); !ok || got != principal {
t.Fatalf("ctx principal = %#v, ok=%v", got, ok)
}
if got := types.TenantRoleFromContext(ctx); got != types.TenantRoleAdmin {
t.Fatalf("ctx role = %q", got)
}
if got, ok := c.Get(types.TenantRoleContextKey.String()); !ok || got.(types.TenantRole) != types.TenantRoleAdmin {
t.Fatalf("keys role = %v, ok=%v", got, ok)
}
if !types.IsSystemAdminFromContext(ctx) {
t.Fatal("ctx system admin flag lost")
}
if got, ok := types.TenantAPIKeyScopeFromContext(ctx); !ok || got.KeyID != 3 {
t.Fatalf("ctx api key scope = %#v, ok=%v", got, ok)
}
if ch, ok := EmbedChannelFromContext(ctx); !ok || ch.ID != "ch" {
t.Fatalf("ctx embed channel = %#v, ok=%v", ch, ok)
}
if got, ok := c.Get(types.EmbedChannelContextKey.String()); !ok || got.(*types.EmbedChannel).ID != "ch" {
t.Fatalf("keys embed channel = %v, ok=%v", got, ok)
}
}
// TestApplyAuthSessionTenantless verifies that a tenantless session attaches
// neither tenant keys nor a role key — RequireRole's fail-closed Viewer
// default depends on the role key being absent, and TENANT_REQUIRED
// handling depends on the tenant key being absent.
func TestApplyAuthSessionTenantless(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
attachTenantlessUserContext(c, &types.User{ID: "u2"})
ctx := c.Request.Context()
if _, ok := types.TenantIDFromContext(ctx); ok {
t.Fatal("tenantless session must not carry a tenant id")
}
if _, ok := c.Get(types.TenantRoleContextKey.String()); ok {
t.Fatal("tenantless session must not carry a role key")
}
if got, ok := types.UserIDFromContext(ctx); !ok || got != "u2" {
t.Fatalf("ctx user id = %q, ok=%v", got, ok)
}
if types.IsSystemAdminFromContext(ctx) {
t.Fatal("non-admin user must not be flagged system admin")
}
}
func TestBearerToken(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
header string
want string
ok bool
}{
{"", "", false},
{"Basic abc", "", false},
{"Bearer", "", false},
{"Bearer abc", "abc", true},
}
for _, tc := range cases {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
if tc.header != "" {
c.Request.Header.Set("Authorization", tc.header)
}
got, ok := bearerToken(c)
if got != tc.want || ok != tc.ok {
t.Fatalf("bearerToken(%q) = (%q, %v), want (%q, %v)", tc.header, got, ok, tc.want, tc.ok)
}
}
}
@@ -0,0 +1,95 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func TestPlatformTenantOptionalAPIs(t *testing.T) {
cases := []struct {
method string
path string
want bool
}{
{http.MethodGet, "/api/v1/system/admin/settings", true},
{http.MethodGet, "/api/v1/tenants/all", true},
{http.MethodGet, "/api/v1/tenants/search", true},
{http.MethodPost, "/api/v1/tenants", true},
{http.MethodGet, "/api/v1/knowledge-bases", false},
{http.MethodGet, "/api/v1/tenants", false},
}
for _, tc := range cases {
if got := isPlatformTenantOptionalAPI(tc.path, tc.method); got != tc.want {
t.Fatalf("isPlatformTenantOptionalAPI(%s, %s) = %v, want %v", tc.method, tc.path, got, tc.want)
}
}
}
func TestAttachPlatformAPIKeyAuthContext(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/system/admin/settings", nil)
attachPlatformAPIKeyAuthContext(c, &types.TenantAPIKey{
ID: 9, ScopeType: types.APIKeyScopePlatform,
Capabilities: types.StringArray{string(types.APIKeyCapabilitySystemSettingsRead)},
})
scope, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context())
if !ok || !scope.IsPlatform() || scope.KeyID != 9 {
t.Fatalf("platform scope = %#v, ok=%v", scope, ok)
}
if _, ok := types.TenantIDFromContext(c.Request.Context()); ok {
t.Fatal("tenantless platform control-plane request must not have tenant context")
}
principal, ok := types.PrincipalFromContext(c.Request.Context())
if !ok || principal.Type != types.PrincipalAPIPlatform {
t.Fatalf("principal = %#v, ok=%v", principal, ok)
}
}
func TestPlatformAPIKeyIdentityIsStableAcrossTenants(t *testing.T) {
principal, user := platformAPIKeyIdentity(&types.TenantAPIKey{ID: 27})
if principal.Type != types.PrincipalAPIPlatform || principal.ID != "27" {
t.Fatalf("principal = %#v", principal)
}
if user.ID != "api_platform:27" || user.Username != user.ID {
t.Fatalf("user = %#v", user)
}
}
func TestAttachTargetedPlatformAPIKeyKeepsPlatformPrincipal(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/knowledge-bases", nil)
key := &types.TenantAPIKey{
ID: 27,
ScopeType: types.APIKeyScopePlatform,
FullAccess: true, // Corrupt/legacy data must still be capability-only at runtime.
Capabilities: types.StringArray{
string(types.APIKeyCapabilityRetrieve),
},
}
attachAPIKeyAuthContext(c, &fakeTenantService{tenant: &types.Tenant{ID: 42}}, nil, 42, key)
if c.IsAborted() {
t.Fatal("targeted platform API key context unexpectedly aborted")
}
principal, ok := types.PrincipalFromContext(c.Request.Context())
if !ok || principal.Type != types.PrincipalAPIPlatform || principal.ID != "27" {
t.Fatalf("principal = %#v, ok=%v", principal, ok)
}
tenantID, ok := types.TenantIDFromContext(c.Request.Context())
if !ok || tenantID != 42 {
t.Fatalf("tenant id = %d, ok=%v", tenantID, ok)
}
scope, ok := types.TenantAPIKeyScopeFromContext(c.Request.Context())
if !ok || !scope.IsPlatform() || scope.FullAccess {
t.Fatalf("scope = %#v, ok=%v", scope, ok)
}
user, ok := c.Request.Context().Value(types.UserContextKey).(*types.User)
if !ok || user.ID != "api_platform:27" || user.TenantID != 42 {
t.Fatalf("user = %#v, ok=%v", user, ok)
}
}
@@ -0,0 +1,135 @@
package middleware
import (
"net/http"
"sync"
"time"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/gin-gonic/gin"
)
// auth_public_ratelimit.go — sliding-window IP rate limiter for the
// unauthenticated share-link endpoints (/auth/invitations/lookup and
// /auth/register-by-invite). Both surfaces accept a plaintext token
// from the request and either reveal tenant context (lookup) or
// create an account (register-by-invite); without a limiter an
// attacker can brute-force token guesses and hammer registration.
//
// The token is 256-bit so guessing the space is infeasible regardless,
// but the limiter still narrows the abuse window for partial-token
// leaks (e.g. via referrer / clipboard managers / accidental commits)
// and bounds the noise this endpoint can add to the user-create path.
//
// Local in-memory only — fine for typical deployments since both
// endpoints handle low absolute volumes; if/when WeKnora horizontally
// scales the auth surface, swap to the Redis-backed limiter in
// internal/ratelimit (shared with IM + embed surfaces).
// publicAuthRateLimitWindow is the rolling window length per IP.
const publicAuthRateLimitWindow = 60 * time.Second
// publicAuthRateLimitMax is the request budget per IP per window for
// each endpoint instance. 30/min comfortably covers a real user
// retrying a failed registration while clamping enumeration
// throughput to ~half a request per second.
const publicAuthRateLimitMax = 30
// publicAuthRateLimitCleanupInterval bounds map growth from one-off
// IPs that never come back.
const publicAuthRateLimitCleanupInterval = 2 * time.Minute
type ipBucket struct {
mu sync.Mutex
timestamps []time.Time
}
type ipRateLimiter struct {
window time.Duration
max int
buckets sync.Map // string (IP) -> *ipBucket
}
func newIPRateLimiter(window time.Duration, max int) *ipRateLimiter {
l := &ipRateLimiter{window: window, max: max}
go l.cleanupLoop()
return l
}
// allow returns true if the IP is within budget for the current
// window. Empty IP (proxy stripped X-Forwarded-For) is treated as a
// shared bucket — slightly over-restrictive vs. dropping the limit
// entirely, which would let a misconfigured proxy bypass the gate.
func (l *ipRateLimiter) allow(ip string) bool {
if ip == "" {
ip = "_unknown_"
}
now := time.Now()
cutoff := now.Add(-l.window)
val, _ := l.buckets.LoadOrStore(ip, &ipBucket{})
b := val.(*ipBucket)
b.mu.Lock()
defer b.mu.Unlock()
kept := b.timestamps[:0]
for _, t := range b.timestamps {
if t.After(cutoff) {
kept = append(kept, t)
}
}
b.timestamps = kept
if len(b.timestamps) >= l.max {
return false
}
b.timestamps = append(b.timestamps, now)
return true
}
func (l *ipRateLimiter) cleanupLoop() {
t := time.NewTicker(publicAuthRateLimitCleanupInterval)
defer t.Stop()
for range t.C {
cutoff := time.Now().Add(-l.window)
l.buckets.Range(func(k, v any) bool {
b := v.(*ipBucket)
b.mu.Lock()
drop := len(b.timestamps) == 0 ||
b.timestamps[len(b.timestamps)-1].Before(cutoff)
b.mu.Unlock()
if drop {
l.buckets.Delete(k)
}
return true
})
}
}
// publicAuthLimiter — package-singleton so each route registration
// shares a single bucket map per process. Each route still gets its
// own Gin handler that calls into this same limiter; per-route
// isolation isn't important here (real users don't hit both
// endpoints in the same window) but the shared state makes total
// budget per IP intuitive: 30/min across all share-link surfaces.
var publicAuthLimiter = newIPRateLimiter(
publicAuthRateLimitWindow, publicAuthRateLimitMax)
// PublicAuthRateLimit returns a Gin middleware that rate-limits the
// unauthenticated share-link endpoints by client IP. 429 is mapped
// through the project's AppError type so it flows through the same
// error middleware as the rest of the auth surface.
func PublicAuthRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !publicAuthLimiter.allow(ip) {
c.Error(&apperrors.AppError{
Code: apperrors.ErrTooManyRequests,
Message: "too many requests; please retry shortly",
HTTPCode: http.StatusTooManyRequests,
})
c.Abort()
return
}
c.Next()
}
}
+266
View File
@@ -0,0 +1,266 @@
package middleware
import (
"context"
"errors"
"testing"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// fakeMemberService is a hand-rolled stand-in for
// interfaces.TenantMemberService. It backs Get/HasAnyMembers/AddMember
// with two in-memory maps and lets each test seed exactly the rows it
// cares about. Other interface methods are stubbed because resolveTenantRole
// never touches them.
type fakeMemberService struct {
members map[string]*types.TenantMember // key = userID + "|" + tenantID
// addCalls records every AddMember invocation so tests can assert
// that resolveTenantRole did (or didn't) attempt to auto-promote.
addCalls []struct {
UserID string
TenantID uint64
Role types.TenantRole
}
failGet error
failHasAny error
failAdd error
// 阻止 auto-promote 把 hasAny 翻面:默认 AddMember 成功也会写入 members map。
}
func newFakeMemberService() *fakeMemberService {
return &fakeMemberService{members: map[string]*types.TenantMember{}}
}
func memberKey(u string, t uint64) string {
return u + "|" + uintToStr(t)
}
func uintToStr(t uint64) string {
// 简单数字转字符串,避免引入额外依赖。
if t == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for t > 0 {
i--
buf[i] = byte('0' + t%10)
t /= 10
}
return string(buf[i:])
}
func (f *fakeMemberService) seedActive(userID string, tenantID uint64, role types.TenantRole) {
f.members[memberKey(userID, tenantID)] = &types.TenantMember{
UserID: userID,
TenantID: tenantID,
Role: role,
Status: types.TenantMemberStatusActive,
}
}
func (f *fakeMemberService) AddMember(
ctx context.Context, userID string, tenantID uint64, role types.TenantRole, invitedBy *string,
) (*types.TenantMember, error) {
f.addCalls = append(f.addCalls, struct {
UserID string
TenantID uint64
Role types.TenantRole
}{userID, tenantID, role})
if f.failAdd != nil {
return nil, f.failAdd
}
m := &types.TenantMember{UserID: userID, TenantID: tenantID, Role: role, Status: types.TenantMemberStatusActive}
f.members[memberKey(userID, tenantID)] = m
return m, nil
}
func (f *fakeMemberService) EnsureOwner(
ctx context.Context, userID string, tenantID uint64,
) (*types.TenantMember, error) {
if existing, ok := f.members[memberKey(userID, tenantID)]; ok {
return existing, nil
}
return f.AddMember(ctx, userID, tenantID, types.TenantRoleOwner, nil)
}
func (f *fakeMemberService) GetMembership(
ctx context.Context, userID string, tenantID uint64,
) (*types.TenantMember, error) {
if f.failGet != nil {
return nil, f.failGet
}
m, ok := f.members[memberKey(userID, tenantID)]
if !ok {
return nil, nil
}
cp := *m
return &cp, nil
}
func (f *fakeMemberService) ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error) {
var out []*types.TenantMember
for _, member := range f.members {
if member.UserID == userID && member.Status == types.TenantMemberStatusActive {
copy := *member
out = append(out, &copy)
}
}
return out, nil
}
func (f *fakeMemberService) ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error) {
return nil, nil
}
func (f *fakeMemberService) ListMembersPage(
ctx context.Context, tenantID uint64, query string, page, pageSize int,
) ([]*types.TenantMember, int64, error) {
return nil, 0, nil
}
func (f *fakeMemberService) HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error) {
if f.failHasAny != nil {
return false, f.failHasAny
}
for _, m := range f.members {
if m.TenantID == tenantID && m.Status == types.TenantMemberStatusActive {
return true, nil
}
}
return false, nil
}
func (f *fakeMemberService) UpdateRole(
ctx context.Context, userID string, tenantID uint64, newRole types.TenantRole,
) error {
return nil
}
func (f *fakeMemberService) RemoveMember(ctx context.Context, userID string, tenantID uint64) error {
return nil
}
var _ interfaces.TenantMemberService = (*fakeMemberService)(nil)
func cfgWithRBAC(enabled bool) *config.Config {
return &config.Config{Tenant: &config.TenantConfig{EnableRBAC: &enabled}}
}
func TestResolveTenantRole_ActiveMembershipWins(t *testing.T) {
svc := newFakeMemberService()
svc.seedActive("u1", 10, types.TenantRoleContributor)
got, ok := resolveTenantRole(context.Background(), svc,
&types.User{ID: "u1", TenantID: 10}, 10, false, cfgWithRBAC(true))
if !ok || got != types.TenantRoleContributor {
t.Fatalf("got (%v, %v), want (contributor, true)", got, ok)
}
if len(svc.addCalls) != 0 {
t.Fatalf("must not auto-promote when membership exists, got %d AddMember calls", len(svc.addCalls))
}
}
func TestResolveTenantRole_CrossTenantSuperuserGetsAdmin_NoAutoPromote(t *testing.T) {
// 回归 H1:跨空间超管 switch 到他人空间时,绝对不能写入 tenant_members。
svc := newFakeMemberService()
user := &types.User{ID: "super", TenantID: 1, CanAccessAllTenants: true}
got, ok := resolveTenantRole(context.Background(), svc, user, 99, true, cfgWithRBAC(true))
if !ok || got != types.TenantRoleAdmin {
t.Fatalf("got (%v, %v), want (admin, true)", got, ok)
}
if len(svc.addCalls) != 0 {
t.Fatalf("cross-tenant superuser must not trigger auto-promote, got %+v", svc.addCalls)
}
}
func TestResolveTenantRole_AutoPromoteRequiresHomeTenant(t *testing.T) {
// 回归 H1:即便 target 是孤儿空间,只要不是用户自己的 home tenant,
// 就不能 auto-promote 为 Owner。
svc := newFakeMemberService() // 空 — 任何空间都是孤儿
user := &types.User{ID: "u1", TenantID: 1, CanAccessAllTenants: true}
got, ok := resolveTenantRole(context.Background(), svc, user, 42, true, cfgWithRBAC(true))
if !ok || got != types.TenantRoleAdmin {
t.Fatalf("cross-tenant superuser should still get visitor Admin, got (%v, %v)", got, ok)
}
if len(svc.addCalls) != 0 {
t.Fatalf("auto-promote must skip cross-tenant target, got %+v", svc.addCalls)
}
}
func TestResolveTenantRole_AutoPromoteHomeTenant(t *testing.T) {
// home tenant + 孤儿空间 + 非 switch → 允许 auto-promote 为 Owner。
svc := newFakeMemberService()
user := &types.User{ID: "u1", TenantID: 7}
got, ok := resolveTenantRole(context.Background(), svc, user, 7, false, cfgWithRBAC(true))
if !ok || got != types.TenantRoleOwner {
t.Fatalf("got (%v, %v), want (owner, true)", got, ok)
}
if len(svc.addCalls) != 1 || svc.addCalls[0].Role != types.TenantRoleOwner {
t.Fatalf("expected exactly one Owner AddMember call, got %+v", svc.addCalls)
}
}
func TestResolveTenantRole_AutoPromoteSkippedIfTenantHasMembers(t *testing.T) {
svc := newFakeMemberService()
// 同一 home tenant 已经有其它成员 — 不应自动晋升新登录者。
svc.seedActive("other", 7, types.TenantRoleOwner)
user := &types.User{ID: "u1", TenantID: 7}
got, ok := resolveTenantRole(context.Background(), svc, user, 7, false, cfgWithRBAC(true))
if ok {
t.Fatalf("RBAC enabled + no membership for u1 should be rejected, got role=%v", got)
}
if len(svc.addCalls) != 0 {
t.Fatalf("must not auto-promote into a tenant that already has members, got %+v", svc.addCalls)
}
}
func TestResolveTenantRole_FailOpenAdminWhenRBACDisabled(t *testing.T) {
svc := newFakeMemberService()
user := &types.User{ID: "u1", TenantID: 7}
// targetTenantID != home,所以不进 auto-promote 分支。
got, ok := resolveTenantRole(context.Background(), svc, user, 8, false, cfgWithRBAC(false))
if !ok || got != types.TenantRoleAdmin {
t.Fatalf("EnableRBAC=false should fail open Admin, got (%v, %v)", got, ok)
}
}
func TestResolveTenantRole_FailClosedWhenRBACEnabled(t *testing.T) {
svc := newFakeMemberService()
// 已有其它成员,自动晋升路径关闭;RBAC 启用 → 必须 403。
svc.seedActive("other", 8, types.TenantRoleOwner)
user := &types.User{ID: "u1", TenantID: 7}
if _, ok := resolveTenantRole(context.Background(), svc, user, 8, false, cfgWithRBAC(true)); ok {
t.Fatalf("EnableRBAC=true + no membership should be rejected")
}
}
func TestResolveTenantRole_LookupErrorFailsOpenWhenRBACDisabled(t *testing.T) {
// 短暂 DB 错误时,fail-open 模式不应锁死现有用户。这里 targetTenantID 故意
// 选与 home 不同的值,避免进入 home-tenant auto-promote 分支。
svc := newFakeMemberService()
svc.failGet = errors.New("transient db failure")
// 让 HasAnyMembers 返回 true,关闭孤儿空间自愈路径。
svc.seedActive("placeholder", 8, types.TenantRoleAdmin)
user := &types.User{ID: "u1", TenantID: 7}
got, ok := resolveTenantRole(context.Background(), svc, user, 8, false, cfgWithRBAC(false))
if !ok || got != types.TenantRoleAdmin {
t.Fatalf("transient lookup error under RBAC=false should fail open Admin, got (%v, %v)", got, ok)
}
}
func TestResolveTenantRole_DemotedUserCannotReclaimViaOrphan(t *testing.T) {
// 边界场景:管理员人为软删全部成员后,被踢出的用户不应在登录自己 home tenant 时
// 因 HasAnyMembers=false 而自动重新拿到 Owner。
// 当前实现的策略是 "home tenant + 孤儿 => Owner",这是设计选择;本测试为这条
// 路径加锁,未来如果收紧策略需要同步更新。
svc := newFakeMemberService()
user := &types.User{ID: "demoted", TenantID: 5}
got, ok := resolveTenantRole(context.Background(), svc, user, 5, false, cfgWithRBAC(true))
if !ok || got != types.TenantRoleOwner {
t.Fatalf("current policy allows orphan-tenant self-heal on home tenant, got (%v, %v)", got, ok)
}
}
@@ -0,0 +1,47 @@
package middleware
import (
"context"
"net/http"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestTenantOptionalAPISurface(t *testing.T) {
tests := []struct {
method string
path string
want bool
}{
{http.MethodGet, "/api/v1/auth/me", true},
{http.MethodPut, "/api/v1/auth/me", true},
{http.MethodPut, "/api/v1/auth/me/preferences", true},
{http.MethodPost, "/api/v1/tenants", true},
{http.MethodGet, "/api/v1/me/invitations", true},
{http.MethodPost, "/api/v1/me/invitations/12/accept", true},
{http.MethodGet, "/api/v1/knowledge-bases", false},
{http.MethodGet, "/api/v1/tenants", false},
}
for _, tt := range tests {
if got := isTenantOptionalAPI(tt.path, tt.method); got != tt.want {
t.Errorf("isTenantOptionalAPI(%s %s) = %v, want %v", tt.method, tt.path, got, tt.want)
}
}
}
func TestResolveFirstMembershipTarget(t *testing.T) {
members := newFakeMemberService()
members.seedActive("tenantless-user", 42, types.TenantRoleViewer)
tenants := &fakeTenantService{tenant: &types.Tenant{ID: 42}}
got := resolveFirstMembershipTarget(
context.Background(),
&types.User{ID: "tenantless-user"},
members,
tenants,
)
if got != 42 {
t.Fatalf("resolved tenant = %d, want 42", got)
}
}
+237
View File
@@ -0,0 +1,237 @@
package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/ratelimit"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
embedRateLimitKeyPrefix = "embed:ratelimit:"
embedDailyRateLimitKeyPrefix = "embed:ratelimit:day:"
// embedGlobalMinuteFactor derives a channel-wide per-minute cap from the
// per-IP cap. The publish token is publicly visible, so a single attacker
// can rotate IPs to defeat the per-IP limit; this bounds aggregate burst.
embedGlobalMinuteFactor = 20
// embedGlobalMinuteFloor keeps the global per-minute cap usable even when
// the per-IP cap is tiny.
embedGlobalMinuteFloor = 120
)
var (
embedLimiterOnce sync.Once
embedLimiter *ratelimit.Limiter
embedDailyLimiterOnce sync.Once
embedDailyLimiter *ratelimit.Limiter
)
func embedRateLimiter(redisClient *redis.Client) *ratelimit.Limiter {
embedLimiterOnce.Do(func() {
embedLimiter = ratelimit.New(redisClient, embedRateLimitKeyPrefix, time.Minute, "")
// Local-fallback eviction; Redis keys expire via PEXPIRE in the Lua script.
stopCh := make(chan struct{})
go embedLimiter.StartCleanup(stopCh)
})
return embedLimiter
}
func embedDailyRateLimiter(redisClient *redis.Client) *ratelimit.Limiter {
embedDailyLimiterOnce.Do(func() {
embedDailyLimiter = ratelimit.New(redisClient, embedDailyRateLimitKeyPrefix, 24*time.Hour, "")
stopCh := make(chan struct{})
go embedDailyLimiter.StartCleanup(stopCh)
})
return embedDailyLimiter
}
// embedGlobalPerMinute returns the channel-wide per-minute budget derived from
// the per-IP budget.
func embedGlobalPerMinute(perIP int) int {
budget := perIP * embedGlobalMinuteFactor
if budget < embedGlobalMinuteFloor {
budget = embedGlobalMinuteFloor
}
return budget
}
// EmbedAuth validates publish tokens and injects a scoped tenant context for embed routes.
func EmbedAuth(
svc interfaces.EmbedChannelService,
tenantSvc interfaces.TenantService,
redisClient *redis.Client,
) gin.HandlerFunc {
limiter := embedRateLimiter(redisClient)
dailyLimiter := embedDailyRateLimiter(redisClient)
return func(c *gin.Context) {
channelID := strings.TrimSpace(c.Param("channel_id"))
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
c.Abort()
return
}
token := extractEmbedToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "embed publish token is required"})
c.Abort()
return
}
var ch *types.EmbedChannel
var err error
if service.IsEmbedSessionToken(token) {
resolvedID, resolveErr := svc.ResolveSessionToken(c.Request.Context(), token)
if resolveErr != nil || resolvedID != channelID {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid embed channel or token"})
c.Abort()
return
}
ch, err = svc.LookupEnabledChannel(c.Request.Context(), channelID)
} else {
ch, err = svc.LookupForEmbed(c.Request.Context(), channelID, token)
}
if err != nil {
if errors.Is(err, service.ErrEmbedChannelDisabled) {
c.JSON(http.StatusForbidden, gin.H{"error": "embed channel is disabled"})
c.Abort()
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid embed channel or token"})
c.Abort()
return
}
origin := requestOrigin(c)
if !originAllowed(origin, ch.AllowedOriginsList()) {
logger.Warnf(c.Request.Context(), "[embed_auth] origin %q not allowed for channel %s", origin, channelID)
c.JSON(http.StatusForbidden, gin.H{"error": "origin not allowed"})
c.Abort()
return
}
// Per-IP per-minute cap.
rateKey := fmt.Sprintf("%s:%s", channelID, c.ClientIP())
if !limiter.Allow(c.Request.Context(), rateKey, ch.RateLimitPerMinute) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
c.Abort()
return
}
// Channel-wide per-minute cap (bounds burst across rotating IPs since
// the publish token is publicly visible).
if !limiter.Allow(c.Request.Context(), channelID+":__global", embedGlobalPerMinute(ch.RateLimitPerMinute)) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
c.Abort()
return
}
// Channel-wide daily total cap (bounds sustained abuse).
if !dailyLimiter.Allow(c.Request.Context(), channelID, ch.RateLimitPerDay) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "daily request limit exceeded"})
c.Abort()
return
}
tenant, err := tenantSvc.GetTenantByID(c.Request.Context(), ch.TenantID)
if err != nil || tenant == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace unavailable"})
c.Abort()
return
}
user := &types.User{
ID: fmt.Sprintf("embed-%s", channelID),
Username: fmt.Sprintf("embed-%s", channelID),
Email: fmt.Sprintf("embed-%s@embed.local", channelID),
TenantID: ch.TenantID,
IsActive: true,
}
applyAuthSession(c, authSession{
User: user,
Principal: types.Principal{
Type: types.PrincipalEmbedChannel,
ID: fmt.Sprintf("%d:%s", ch.TenantID, ch.ID),
},
TenantID: ch.TenantID,
Tenant: tenant,
Role: types.TenantRoleViewer,
Extra: map[types.ContextKey]any{types.EmbedChannelContextKey: ch},
})
c.Next()
}
}
func extractEmbedToken(c *gin.Context) string {
// Only accept the token via the Authorization header. A query-string token
// would be captured by proxy/access logs and browser history; the embed
// client always sends "Authorization: Embed <token>".
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Embed ") {
return strings.TrimSpace(strings.TrimPrefix(auth, "Embed "))
}
return ""
}
func requestOrigin(c *gin.Context) string {
if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" {
return o
}
ref := strings.TrimSpace(c.GetHeader("Referer"))
if ref == "" {
return ""
}
u, err := url.Parse(ref)
if err != nil {
return ""
}
if u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
func originAllowed(origin string, allowed []string) bool {
// Empty allowlist rejects all origins. Management create/update requires at
// least one origin; legacy rows with [] must be fixed before going live.
if len(allowed) == 0 {
return false
}
if origin == "" {
return false
}
for _, pattern := range allowed {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if pattern == "*" || strings.EqualFold(pattern, origin) {
return true
}
if strings.HasPrefix(pattern, "*.") {
suffix := strings.TrimPrefix(pattern, "*")
if strings.HasSuffix(origin, suffix) {
return true
}
}
}
return false
}
// EmbedChannelFromContext returns the authenticated embed channel, if any.
func EmbedChannelFromContext(ctx context.Context) (*types.EmbedChannel, bool) {
ch, ok := ctx.Value(types.EmbedChannelContextKey).(*types.EmbedChannel)
return ch, ok && ch != nil
}
+400
View File
@@ -0,0 +1,400 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
type fakeEmbedChannelService struct {
channels map[string]*types.EmbedChannel
sessions map[string]string
}
func (f *fakeEmbedChannelService) Create(
ctx context.Context, tenantID uint64, agentID string, req *types.EmbedChannel,
) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (f *fakeEmbedChannelService) ListByAgent(
ctx context.Context, tenantID uint64, agentID string,
) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (f *fakeEmbedChannelService) ListByTenant(
ctx context.Context, tenantID uint64,
) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (f *fakeEmbedChannelService) Update(
ctx context.Context, tenantID uint64, id string, req *types.EmbedChannel,
enabled *bool, showSuggested *bool, allowWebSearch *bool, allowFileUpload *bool,
defaultLocale *string, webhookURL *string, webhookSecret *string,
) (*types.EmbedChannel, error) {
return nil, nil
}
func (f *fakeEmbedChannelService) GetOwnedChannel(
ctx context.Context, tenantID uint64, id string,
) (*types.EmbedChannel, error) {
ch := f.channels[id]
if ch == nil || ch.TenantID != tenantID {
return nil, service.ErrEmbedChannelNotFound
}
return ch, nil
}
func (f *fakeEmbedChannelService) Delete(ctx context.Context, tenantID uint64, id string) error {
return nil
}
func (f *fakeEmbedChannelService) RotateToken(
ctx context.Context, tenantID uint64, id string,
) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (f *fakeEmbedChannelService) LookupForEmbed(
ctx context.Context, channelID, token string,
) (*types.EmbedChannel, error) {
ch := f.channels[channelID]
if ch == nil || ch.PublishToken != token {
return nil, service.ErrEmbedTokenInvalid
}
if !ch.Enabled {
return nil, service.ErrEmbedChannelDisabled
}
return ch, nil
}
func (f *fakeEmbedChannelService) LookupEnabledChannel(
ctx context.Context, channelID string,
) (*types.EmbedChannel, error) {
ch := f.channels[channelID]
if ch == nil {
return nil, service.ErrEmbedTokenInvalid
}
if !ch.Enabled {
return nil, service.ErrEmbedChannelDisabled
}
return ch, nil
}
func (f *fakeEmbedChannelService) IssueSessionToken(
ctx context.Context, channelID string,
) (string, int, error) {
return "ems_testtoken", 1800, nil
}
func (f *fakeEmbedChannelService) ResolveSessionToken(ctx context.Context, token string) (string, error) {
channelID, ok := f.sessions[token]
if !ok {
return "", service.ErrEmbedTokenInvalid
}
return channelID, nil
}
func (f *fakeEmbedChannelService) PublicConfig(
ctx context.Context, ch *types.EmbedChannel,
) types.EmbedChannelPublicConfig {
return types.EmbedChannelPublicConfig{ChannelID: ch.ID}
}
func (f *fakeEmbedChannelService) SuggestedQuestions(
ctx context.Context, ch *types.EmbedChannel, limit int,
) ([]types.SuggestedQuestion, error) {
return nil, nil
}
func (f *fakeEmbedChannelService) EmbedChunk(
ctx context.Context, ch *types.EmbedChannel, chunkID string,
) (*types.Chunk, error) {
return nil, nil
}
func (f *fakeEmbedChannelService) IssuePreviewSession(
ctx context.Context, tenantID uint64, channelID string,
) (string, int, error) {
return "", 0, nil
}
func (f *fakeEmbedChannelService) EmbedDisplayTitle(ctx context.Context, ch *types.EmbedChannel) string {
return ""
}
type fakeTenantService struct {
tenant *types.Tenant
}
func (f *fakeTenantService) GetTenantByID(ctx context.Context, id uint64) (*types.Tenant, error) {
return f.tenant, nil
}
func (f *fakeTenantService) CreateTenant(ctx context.Context, tenant *types.Tenant) (*types.Tenant, error) {
return nil, nil
}
func (f *fakeTenantService) GetTenantsByIDs(ctx context.Context, ids []uint64) (map[uint64]*types.Tenant, error) {
return nil, nil
}
func (f *fakeTenantService) UpdateTenant(ctx context.Context, tenant *types.Tenant) (*types.Tenant, error) {
return nil, nil
}
func (f *fakeTenantService) DeleteTenant(ctx context.Context, id uint64) error {
return nil
}
func (f *fakeTenantService) ListTenants(ctx context.Context) ([]*types.Tenant, error) {
return nil, nil
}
func (f *fakeTenantService) ListAllTenants(ctx context.Context) ([]*types.Tenant, error) {
return nil, nil
}
func (f *fakeTenantService) BulkSetStorageQuota(ctx context.Context, quotaBytes int64) (int64, error) {
return 0, nil
}
func (f *fakeTenantService) SearchTenants(
ctx context.Context, keyword string, tenantID uint64, page, pageSize int,
) ([]*types.Tenant, int64, error) {
return nil, 0, nil
}
func (f *fakeTenantService) GetTenantByIDForUser(
ctx context.Context, tenantID uint64, userID string,
) (*types.Tenant, error) {
return f.tenant, nil
}
func (f *fakeTenantService) GetWeKnoraCloudCredentials(ctx context.Context) *types.WeKnoraCloudCredentials {
return nil
}
var (
_ interfaces.EmbedChannelService = (*fakeEmbedChannelService)(nil)
_ interfaces.TenantService = (*fakeTenantService)(nil)
)
func TestEmbedGlobalPerMinute(t *testing.T) {
tests := []struct {
perIP int
want int
}{
{perIP: 0, want: 120},
{perIP: 1, want: 120},
{perIP: 6, want: 120},
{perIP: 7, want: 140},
{perIP: 10, want: 200},
}
for _, tt := range tests {
name := fmt.Sprintf("perIP=%d", tt.perIP)
t.Run(name, func(t *testing.T) {
if got := embedGlobalPerMinute(tt.perIP); got != tt.want {
t.Fatalf("embedGlobalPerMinute(%d) = %d, want %d", tt.perIP, got, tt.want)
}
})
}
}
func TestOriginAllowed(t *testing.T) {
tests := []struct {
name string
origin string
allowed []string
want bool
}{
{name: "empty allow list", origin: "https://evil.com", allowed: nil, want: false},
{name: "exact match", origin: "https://app.example.com", allowed: []string{"https://app.example.com"}, want: true},
{name: "wildcard star", origin: "https://any.example.com", allowed: []string{"*"}, want: true},
{name: "subdomain suffix", origin: "https://app.example.com", allowed: []string{"*.example.com"}, want: true},
{name: "missing origin", origin: "", allowed: []string{"https://app.example.com"}, want: false},
{name: "not allowed", origin: "https://evil.com", allowed: []string{"https://app.example.com"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := originAllowed(tt.origin, tt.allowed); got != tt.want {
t.Fatalf("originAllowed(%q, %v) = %v, want %v", tt.origin, tt.allowed, got, tt.want)
}
})
}
}
func TestExtractEmbedToken(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
header string
query string
want string
}{
{name: "authorization header", header: "Embed em_publish", want: "em_publish"},
{name: "query param rejected", query: "ems_session", want: ""},
{name: "header preferred", header: "Embed em_header", query: "em_query", want: "em_header"},
{name: "missing", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodGet, "/?token="+tt.query, nil)
if tt.header != "" {
req.Header.Set("Authorization", tt.header)
}
c.Request = req
if got := extractEmbedToken(c); got != tt.want {
t.Fatalf("extractEmbedToken() = %q, want %q", got, tt.want)
}
})
}
}
func TestEmbedAuthSessionTokenPath(t *testing.T) {
gin.SetMode(gin.TestMode)
const channelID = "ch-1"
svc := &fakeEmbedChannelService{
channels: map[string]*types.EmbedChannel{
channelID: {
ID: channelID,
TenantID: 42,
Enabled: true,
AllowedOrigins: []byte(`["https://app.example.com"]`),
RateLimitPerMinute: 0,
},
},
sessions: map[string]string{
"ems_valid": channelID,
},
}
tenantSvc := &fakeTenantService{tenant: &types.Tenant{ID: 42}}
r := gin.New()
r.GET("/api/v1/embed/:channel_id/config", EmbedAuth(svc, tenantSvc, nil), func(c *gin.Context) {
ch, ok := EmbedChannelFromContext(c.Request.Context())
if !ok || ch.ID != channelID {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing channel"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/embed/"+channelID+"/config", nil)
req.Header.Set("Authorization", "Embed ems_valid")
req.Header.Set("Origin", "https://app.example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if body["success"] != true {
t.Fatalf("expected success response, got %v", body)
}
}
func TestEmbedAuthPublishTokenValid(t *testing.T) {
gin.SetMode(gin.TestMode)
const (
channelID = "ch-pub-1"
publishToken = "em_publish_ok"
)
svc := &fakeEmbedChannelService{
channels: map[string]*types.EmbedChannel{
channelID: {
ID: channelID,
TenantID: 11,
Enabled: true,
PublishToken: publishToken,
AllowedOrigins: []byte(`["https://app.example.com"]`),
RateLimitPerMinute: 0,
},
},
}
tenantSvc := &fakeTenantService{tenant: &types.Tenant{ID: 11}}
r := gin.New()
r.GET("/api/v1/embed/:channel_id/config", EmbedAuth(svc, tenantSvc, nil), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/embed/"+channelID+"/config", nil)
req.Header.Set("Authorization", "Embed "+publishToken)
req.Header.Set("Origin", "https://app.example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func TestEmbedAuthPublishTokenInvalid(t *testing.T) {
gin.SetMode(gin.TestMode)
const channelID = "ch-pub-1"
svc := &fakeEmbedChannelService{
channels: map[string]*types.EmbedChannel{
channelID: {
ID: channelID,
TenantID: 11,
Enabled: true,
PublishToken: "em_real_token",
AllowedOrigins: []byte(`["https://app.example.com"]`),
},
},
}
handler := EmbedAuth(svc, &fakeTenantService{tenant: &types.Tenant{ID: 11}}, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/embed/"+channelID+"/config", nil)
c.Request.Header.Set("Authorization", "Embed em_wrong_token")
c.Request.Header.Set("Origin", "https://app.example.com")
c.Params = gin.Params{{Key: "channel_id", Value: channelID}}
handler(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d, body = %s", w.Code, http.StatusUnauthorized, w.Body.String())
}
}
func TestEmbedAuthSessionTokenMismatch(t *testing.T) {
gin.SetMode(gin.TestMode)
const channelID = "ch-1"
svc := &fakeEmbedChannelService{
sessions: map[string]string{
"ems_other": "other-channel",
},
}
handler := EmbedAuth(svc, &fakeTenantService{tenant: &types.Tenant{ID: 1}}, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/embed/"+channelID+"/config", nil)
c.Request.Header.Set("Authorization", "Embed ems_other")
c.Params = gin.Params{{Key: "channel_id", Value: channelID}}
handler(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d, body = %s", w.Code, http.StatusUnauthorized, w.Body.String())
}
}
+48
View File
@@ -0,0 +1,48 @@
package middleware
import (
stderrors "errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/errors"
)
// ErrorHandler 是一个处理应用错误的中间件
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
// 处理请求
c.Next()
// 检查是否有错误
if len(c.Errors) > 0 && !c.Writer.Written() {
// 获取最后一个错误
err := c.Errors.Last().Err
// 检查是否为应用错误
var appErr *errors.AppError
if stderrors.As(err, &appErr) && appErr.HTTPCode >= 400 && appErr.HTTPCode <= 599 {
// 返回应用错误
c.JSON(appErr.HTTPCode, gin.H{
"success": false,
"error": gin.H{
"code": appErr.Code,
"message": appErr.Message,
"details": appErr.Details,
},
})
return
}
// 处理其他类型的错误
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": gin.H{
"code": errors.ErrInternalServer,
"message": "Internal server error",
},
})
}
}
}
@@ -0,0 +1,70 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestErrorHandler_PreservesStructuredModelUsageDetails(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(ErrorHandler())
router.DELETE("/models/:id", func(c *gin.Context) {
_ = c.Error(apperrors.NewModelInUseError(
"model is used by 2 knowledge base(s); reconfigure or remove those references before deleting",
types.ModelUsageDetails{
KnowledgeBases: []types.ModelUsageResource{
{
ID: "kb-1",
Name: "Product docs",
Bindings: []types.ModelUsageBinding{types.ModelUsageBindingVLMModel},
},
{
ID: "kb-2",
Name: "Engineering",
Bindings: []types.ModelUsageBinding{
types.ModelUsageBindingEmbeddingModel,
types.ModelUsageBindingSummaryModel,
},
},
},
Agents: []types.ModelUsageResource{},
LongTermMemory: types.ModelUsageMemory{Bindings: []types.ModelUsageBinding{}},
},
))
})
request := httptest.NewRequest(http.MethodDelete, "/models/model-1", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusBadRequest, response.Code)
var body struct {
Success bool `json:"success"`
Error struct {
Code apperrors.ErrorCode `json:"code"`
Message string `json:"message"`
Details types.ModelUsageDetails `json:"details"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
assert.False(t, body.Success)
assert.Equal(t, apperrors.ErrModelInUse, body.Error.Code)
assert.NotEmpty(t, body.Error.Message)
require.Len(t, body.Error.Details.KnowledgeBases, 2)
assert.Equal(t, "Product docs", body.Error.Details.KnowledgeBases[0].Name)
assert.Equal(t,
[]types.ModelUsageBinding{types.ModelUsageBindingEmbeddingModel, types.ModelUsageBindingSummaryModel},
body.Error.Details.KnowledgeBases[1].Bindings,
)
assert.Empty(t, body.Error.Details.Agents)
assert.Empty(t, body.Error.Details.LongTermMemory.Bindings)
}
+471
View File
@@ -0,0 +1,471 @@
package middleware
import (
"context"
stderrors "errors"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// kb_access.go centralises the share-fallback that previously lived as
// near-identical 30-line helpers in five handler files (chunk.go,
// faq.go, tag.go, knowledge.go, knowledgebase.go). Each was a copy of
// the same three checks:
//
// 1. KB belongs to caller's tenant -> grant own access
// 2. Org-shared KB -> grant min(share, role) cap
// 3. Shared agent carries the KB -> grant Viewer (read-only)
//
// Putting the resolution in a route-level gin.HandlerFunc makes the
// route declaration the single source of truth for "what permission
// is required" and "where does the kb_id come from". Handlers no
// longer carry an effectiveCtxForKB / validateAndGetKnowledgeBase
// helper — the guard runs first, stashes the resolution under
// KBAccessContextKey and rewrites c.Request to carry the
// effective-tenant-ID context, then handlers just read TenantIDFromContext
// the way they always did.
//
// Plan 3's 3-D cap (tenant Viewer pinned to OrgRoleViewer) is enforced
// inside CheckTenantKBPermission itself, so the guard here just
// propagates the result.
//
// ⚠️ Tenant id has TWO surfaces on a gin.Context:
//
// - c.Request.Context() (read via types.TenantIDFromContext)
// - c.Keys[TenantIDContextKey] (read via c.Get / c.GetUint64)
//
// The guard rewrites ONLY the request context — c.Keys is intentionally
// left at the caller's own tenant so handlers that still run their own
// share resolution (currently knowledge.go / knowledgebase.go, which
// branch on the ?agent_id query) keep working unchanged. New handlers
// MUST read tenant from c.Request.Context() (the "effective" tenant
// for shared KBs); reading from c.Keys after this guard runs gives
// the caller's own tenant, which is almost always the wrong answer
// for KB-scoped routes. The FAQ / Tag / Chunk handlers in this PR
// were converted to the ctx-based read; the migration of knowledge.go
// / knowledgebase.go is a follow-up.
// KBAccess captures the result of a successful KB access resolution.
// Stashed on gin.Context under KBAccessContextKey so handlers that
// need the resolved KB / permission (e.g. to render
// my_permission in the response) can pull it without re-running the
// resolution.
type KBAccess struct {
KnowledgeBase *types.KnowledgeBase
EffectiveTenantID uint64
Permission types.OrgMemberRole
}
// KBAccessContextKey is the gin.Context key under which a successful
// KB access resolution is stored.
const KBAccessContextKey = "rbac.kb_access"
// KBAccessFromContext returns the KBAccess stashed by the guard, if
// any. Handlers that don't care can just rely on the rewritten
// c.Request.Context() for tenant scoping.
func KBAccessFromContext(c *gin.Context) (*KBAccess, bool) {
v, ok := c.Get(KBAccessContextKey)
if !ok {
return nil, false
}
a, ok := v.(*KBAccess)
return a, ok
}
// KBLookup is the minimum surface ResolveKBAccess needs from the
// knowledge-base service: a single method that turns an ID into a
// KnowledgeBase pointer (or repo.ErrKnowledgeBaseNotFound). Defining
// it as a tiny dedicated interface keeps the guard testable without
// forcing test stubs to satisfy the full KnowledgeBaseService surface.
type KBLookup interface {
GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error)
}
// KnowledgeLookup mirrors KBLookup but for resolving a knowledge id
// (document id) back to its parent KB. Used by the chunk routes whose
// URL param is a knowledge_id, not a kb_id.
type KnowledgeLookup interface {
GetKnowledgeByIDOnly(ctx context.Context, id string) (*types.Knowledge, error)
}
// ChunkLookup mirrors KBLookup for resolving a chunk id back to its
// owning knowledge document, which then resolves to the parent KB.
// Used by the /chunks/by-id/:id routes that address chunks directly.
type ChunkLookup interface {
GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error)
}
// KBIDResolver tells the guard how to find the kb_id for a given
// request. Built-in resolvers below cover the param shapes we use:
// :id, :kb_id, :kbId, :knowledge_id (-> parent KB).
//
// On error, resolvers MUST return either a 4xx apperror (bad request /
// not found) or a generic Go error for transient/internal failures;
// the guard maps the latter to 503.
type KBIDResolver func(c *gin.Context) (string, error)
// KBIDFromParam returns a resolver that reads a fixed gin param.
func KBIDFromParam(param string) KBIDResolver {
return func(c *gin.Context) (string, error) {
v := c.Param(param)
if v == "" {
return "", apperrors.NewBadRequestError("missing " + param + " in path")
}
return v, nil
}
}
// KBIDFromKnowledgeIDParam reads `:knowledge_id` from the URL, looks
// up the knowledge document, and returns its KB id. Used by the chunk
// routes that address a chunk via /chunks/:knowledge_id.
//
// A genuine "not found" maps to 404; transient errors (DB hiccup,
// service unavailable) are surfaced as a plain Go error so the guard
// can return 503 instead of pretending the resource doesn't exist
// (a 404 here would also short-circuit any retry / monitoring).
func KBIDFromKnowledgeIDParam(param string, kgService KnowledgeLookup) KBIDResolver {
return func(c *gin.Context) (string, error) {
v := c.Param(param)
if v == "" {
return "", apperrors.NewBadRequestError("missing " + param + " in path")
}
k, err := kgService.GetKnowledgeByIDOnly(c.Request.Context(), v)
if err != nil {
if isResourceNotFound(err) {
return "", apperrors.NewNotFoundError("Knowledge not found")
}
return "", err
}
if k == nil {
return "", apperrors.NewNotFoundError("Knowledge not found")
}
return k.KnowledgeBaseID, nil
}
}
// KBIDFromChunkIDParam walks chunk_id -> knowledge_id -> kb_id.
// Used by /chunks/by-id/:id routes that address a chunk directly. The
// chunk's KnowledgeBaseID is denormalised on the row, so a single
// lookup is enough — no need to chain through GetKnowledgeByIDOnly.
//
// Not-found / transient split mirrors KBIDFromKnowledgeIDParam.
func KBIDFromChunkIDParam(param string, chunkService ChunkLookup) KBIDResolver {
return func(c *gin.Context) (string, error) {
v := c.Param(param)
if v == "" {
return "", apperrors.NewBadRequestError("missing " + param + " in path")
}
ch, err := chunkService.GetChunkByIDOnly(c.Request.Context(), v)
if err != nil {
if isResourceNotFound(err) {
return "", apperrors.NewNotFoundError("Chunk not found")
}
return "", err
}
if ch == nil {
return "", apperrors.NewNotFoundError("Chunk not found")
}
if ch.KnowledgeBaseID == "" {
// Should-never-happen on a fresh schema; on legacy rows the
// chunk effectively isn't resolvable to a KB so the client
// gets the same 404 they'd get for a missing chunk rather
// than a 500 that pollutes alerting.
logger.Warnf(c.Request.Context(),
"[kb_access] chunk %s has empty knowledge_base_id; treating as not-found", v)
return "", apperrors.NewNotFoundError("Chunk not found")
}
return ch.KnowledgeBaseID, nil
}
}
// isResourceNotFound recognises the various "not found" sentinels we
// might see from the underlying services. Keeps the resolvers above
// from forcing every service to standardise on a single error type
// before this refactor is useful.
func isResourceNotFound(err error) bool {
// ErrChunkNotFound is defined in the repository layer and aliased by the
// service; match the canonical repo sentinel so this predicate depends
// only on the repository package (KB / Knowledge / Chunk are all here).
return stderrors.Is(err, apprepo.ErrKnowledgeBaseNotFound) ||
stderrors.Is(err, apprepo.ErrKnowledgeNotFound) ||
stderrors.Is(err, apprepo.ErrChunkNotFound) ||
stderrors.Is(err, ErrResourceNotFound)
}
// RequireKBAccess returns a gin.HandlerFunc that resolves KB access
// (own / org-shared / via shared agent), enforces the minimum required
// org-level permission, and on success stores the result under
// KBAccessContextKey AND rewrites c.Request.Context() to carry the
// effective tenant ID. Handlers downstream just read tenant from
// context as before.
//
// On failure the guard aborts with the appropriate HTTP status (400 /
// 401 / 404 / 403 / 503). Behaviour matches what each handler's
// effectiveCtxForKB helper used to do; the guard is what consolidates
// the repetition so a fix in the resolution order propagates to every
// gated route at once.
//
// Required permission semantics:
// - OrgRoleViewer -> read-only routes (the agent-share fallback path
// activates only at this level)
// - OrgRoleEditor -> mutating routes (org-shared editor or own KB)
// - OrgRoleAdmin -> share-management routes (only the original
// sharer / KB owner / Org admin should pass)
//
// When cfg.Tenant.EnableRBAC is false the guard mirrors the sibling
// role/ownership guards: it logs the would-be rejection and lets the
// request through. The point is to keep the rollout window safe — the
// guard runs full enforcement once the flag flips on, with no code
// changes elsewhere.
func RequireKBAccess(
resolveKBID KBIDResolver,
requiredPermission types.OrgMemberRole,
kbService KBLookup,
kbShareService interfaces.KBShareService,
agentShareService interfaces.AgentShareService,
cfg *config.Config,
) gin.HandlerFunc {
warnOnNilConfig(cfg)
return func(c *gin.Context) {
kbID, err := resolveKBID(c)
if err != nil {
_ = c.Error(err)
c.Abort()
return
}
ctx := c.Request.Context()
if err := types.AuthorizeTenantAPIKeyKnowledgeBases(ctx, kbID); err != nil {
_ = c.Error(err)
c.Abort()
return
}
// Rollout window: enforcement off -> log the would-be check and
// pass through. We still resolve the KB (best-effort) so the
// effective-tenant context rewrite still happens for shared
// KBs; that way embedding queries hit the right tenant
// regardless of whether RBAC enforcement is active.
enforcing := rbacEnforcementEnabled(cfg)
access, err := resolveKBAccessOnce(ctx, c, kbID, requiredPermission, kbService, kbShareService, agentShareService)
switch {
case stderrors.Is(err, errKBAccessUnauthorized):
if !enforcing {
logger.Warnf(ctx, "[rbac] kb-access would 401 (enforcement off): kb=%s", kbID)
c.Next()
return
}
_ = c.Error(apperrors.NewUnauthorizedError("Unauthorized"))
c.Abort()
return
case stderrors.Is(err, errKBAccessNotFound):
// 404 still fires when enforcement is off — a missing KB is
// not an authorisation event, the client genuinely asked
// for nothing.
_ = c.Error(apperrors.NewNotFoundError("knowledge base not found"))
c.Abort()
return
case stderrors.Is(err, errKBAccessForbidden):
if !enforcing {
logger.Warnf(ctx, "[rbac] kb-access would 403 (enforcement off): kb=%s required=%s",
kbID, requiredPermission)
c.Next()
return
}
_ = c.Error(apperrors.NewForbiddenError("Permission denied to access this knowledge base"))
c.Abort()
return
case stderrors.Is(err, errKBAccessBadRequest):
_ = c.Error(apperrors.NewBadRequestError("invalid agent_source_tenant_id"))
c.Abort()
return
case err != nil:
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) && appErr.Code == apperrors.ErrForbidden {
_ = c.Error(err)
c.Abort()
return
}
logger.ErrorWithFields(ctx, err, nil)
// Transient/internal -> 503 so monitoring catches the
// underlying failure rather than a misleading 500.
_ = c.Error(apperrors.NewServiceUnavailableError("cannot verify KB access right now"))
c.Abort()
return
}
// Stash the resolution and rewrite the request to carry the
// effective tenant id. Handlers reading tenant from context now
// see the source-tenant for shared KBs (so retrieval queries
// hit the right embedding store) without having to know.
c.Set(KBAccessContextKey, access)
newCtx := context.WithValue(ctx, types.TenantIDContextKey, access.EffectiveTenantID)
c.Request = c.Request.WithContext(newCtx)
c.Next()
}
}
// resolveKBAccessOnce performs the actual three-step resolution. Kept
// unexported and using package-private sentinel errors so the guard's
// error mapping is the only public surface.
//
// The shared-agent step honours the ?agent_id query parameter when
// present, mirroring the in-handler resolution in
// knowledgebase.go's validateAndGetKnowledgeBase: a request with a
// specific agent_id is validated against THAT agent's KB scope (mode =
// all / selected / none), not against "any shared agent". Falling
// back to "any shared agent" when agent_id is empty preserves the
// "通过智能体可见" KB list entry point.
func resolveKBAccessOnce(
ctx context.Context,
c *gin.Context,
kbID string,
requiredPermission types.OrgMemberRole,
kbService KBLookup,
kbShareService interfaces.KBShareService,
agentShareService interfaces.AgentShareService,
) (*KBAccess, error) {
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok || tenantID == 0 {
return nil, errKBAccessUnauthorized
}
callerTenantRole := types.TenantRoleFromContext(ctx)
kb, err := kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
if stderrors.Is(err, apprepo.ErrKnowledgeBaseNotFound) {
return nil, errKBAccessNotFound
}
return nil, err
}
if kb == nil {
return nil, errKBAccessNotFound
}
// 1. Own KB.
if kb.TenantID == tenantID {
return &KBAccess{
KnowledgeBase: kb,
EffectiveTenantID: tenantID,
Permission: types.OrgRoleAdmin,
}, nil
}
// 2. Org-shared KB. Plan 3's 3-D cap is applied inside
// CheckTenantKBPermission; we just check the result satisfies
// the minimum requirement.
if kbShareService != nil {
permission, isShared, permErr := kbShareService.CheckTenantKBPermission(ctx, kbID, tenantID, callerTenantRole)
if permErr == nil && isShared && permission.HasPermission(requiredPermission) {
source, srcErr := kbShareService.GetKBSourceTenant(ctx, kbID)
if srcErr == nil {
logger.Infof(ctx, "[kb_access] tenant %d -> shared KB %s perm=%s source=%d",
tenantID, kbID, permission, source)
return &KBAccess{
KnowledgeBase: kb,
EffectiveTenantID: source,
Permission: permission,
}, nil
}
}
}
// 3. Shared agent that carries this KB — only ever grants read.
if requiredPermission == types.OrgRoleViewer && agentShareService != nil {
access, agentErr := resolveSharedAgentAccess(ctx, c, tenantID, callerTenantRole, kb, agentShareService)
if agentErr != nil {
return nil, agentErr
}
if access != nil {
return access, nil
}
}
logger.Warnf(ctx, "[kb_access] tenant %d -> KB %s denied (required=%s)", tenantID, kbID, requiredPermission)
return nil, errKBAccessForbidden
}
// resolveSharedAgentAccess implements the agent-share fallback,
// mirroring knowledgebase.go's in-handler logic so the guard and the
// (still-present) handler resolver agree on which requests pass.
//
// - If ?agent_id=X is provided, validate against THAT agent's
// KBSelectionMode (all / selected / none). A mismatch means deny;
// we do NOT fall back to "any shared agent" because the client
// explicitly named one (typically from a @-mention or a deep link
// scoped to that agent).
// - If ?agent_id is empty, allow when any shared agent reachable by
// the caller can access this KB ("通过智能体可见" KB list entry).
func resolveSharedAgentAccess(
ctx context.Context,
c *gin.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
kb *types.KnowledgeBase,
agentShareService interfaces.AgentShareService,
) (*KBAccess, error) {
agentID := c.Query("agent_id")
if agentID != "" {
sourceTenantID, err := types.ParseAgentSourceTenantID(c.Query(types.AgentSourceTenantIDParam))
if err != nil {
return nil, errKBAccessBadRequest
}
agent, err := agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID, sourceTenantID)
if err != nil || agent == nil {
return nil, nil
}
if kb.TenantID != agent.TenantID {
logger.Warnf(ctx, "[kb_access] shared agent workspace mismatch: kb=%s kb.tenant=%d agent.tenant=%d",
kb.ID, kb.TenantID, agent.TenantID)
return nil, nil
}
switch agent.Config.KBSelectionMode {
case "all":
logger.Infof(ctx, "[kb_access] tenant %d -> KB %s via shared agent %s (mode=all)",
tenantID, kb.ID, agentID)
return &KBAccess{
KnowledgeBase: kb,
EffectiveTenantID: kb.TenantID,
Permission: types.OrgRoleViewer,
}, nil
case "selected":
for _, allowedID := range agent.Config.KnowledgeBases {
if allowedID == kb.ID {
logger.Infof(ctx, "[kb_access] tenant %d -> KB %s via shared agent %s (mode=selected)",
tenantID, kb.ID, agentID)
return &KBAccess{
KnowledgeBase: kb,
EffectiveTenantID: kb.TenantID,
Permission: types.OrgRoleViewer,
}, nil
}
}
}
return nil, nil
}
can, err := agentShareService.TenantCanAccessKBViaSomeSharedAgent(ctx, tenantID, callerTenantRole, kb)
if err == nil && can {
logger.Infof(ctx, "[kb_access] tenant %d -> KB %s via some shared agent", tenantID, kb.ID)
return &KBAccess{
KnowledgeBase: kb,
EffectiveTenantID: kb.TenantID,
Permission: types.OrgRoleViewer,
}, nil
}
return nil, nil
}
var (
errKBAccessUnauthorized = stderrors.New("kb_access: unauthorized")
errKBAccessNotFound = stderrors.New("kb_access: not found")
errKBAccessForbidden = stderrors.New("kb_access: forbidden")
errKBAccessBadRequest = stderrors.New("kb_access: bad request")
)
+556
View File
@@ -0,0 +1,556 @@
package middleware
import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// stubKBLookup is a tiny KBLookup stand-in for tests; satisfies the
// KBLookup interface (a single method) without dragging in the full
// KnowledgeBaseService surface.
type stubKBLookup struct {
kbs map[string]*types.KnowledgeBase
getErr error
}
func (s *stubKBLookup) GetKnowledgeBaseByID(_ context.Context, id string) (*types.KnowledgeBase, error) {
if s.getErr != nil {
return nil, s.getErr
}
if kb, ok := s.kbs[id]; ok {
return kb, nil
}
return nil, apprepo.ErrKnowledgeBaseNotFound
}
// stubKBShareForGuard implements just the methods the guard touches —
// CheckTenantKBPermission and GetKBSourceTenant. The other methods on
// the interface panic so any unintended new dependency surfaces
// immediately.
type stubKBShareForGuard struct {
permission map[string]types.OrgMemberRole
shared map[string]bool
source map[string]uint64
}
func (s *stubKBShareForGuard) CheckTenantKBPermission(_ context.Context, kbID string, _ uint64, _ types.TenantRole) (types.OrgMemberRole, bool, error) {
if s.shared[kbID] {
return s.permission[kbID], true, nil
}
return "", false, nil
}
func (s *stubKBShareForGuard) GetKBSourceTenant(_ context.Context, kbID string) (uint64, error) {
if v, ok := s.source[kbID]; ok {
return v, nil
}
return 0, errors.New("not found")
}
func (s *stubKBShareForGuard) ShareKnowledgeBase(context.Context, string, string, string, uint64, types.OrgMemberRole) (*types.KnowledgeBaseShare, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) UpdateSharePermission(context.Context, string, types.OrgMemberRole, string, uint64) error {
panic("not implemented")
}
func (s *stubKBShareForGuard) RemoveShare(context.Context, string, string, uint64) error {
panic("not implemented")
}
func (s *stubKBShareForGuard) ListSharesByKnowledgeBase(context.Context, string, uint64) ([]*types.KnowledgeBaseShare, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) ListSharesByOrganization(context.Context, string) ([]*types.KnowledgeBaseShare, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) ListSharedKnowledgeBases(context.Context, uint64, types.TenantRole) ([]*types.SharedKnowledgeBaseInfo, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) ListSharedKnowledgeBasesInOrganization(context.Context, string, uint64, types.TenantRole) ([]*types.OrganizationSharedKnowledgeBaseItem, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) ListSharedKnowledgeBaseIDsByOrganizations(context.Context, []string, uint64) (map[string][]string, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) GetShare(context.Context, string) (*types.KnowledgeBaseShare, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) GetShareByKBAndOrg(context.Context, string, string) (*types.KnowledgeBaseShare, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) HasTenantKBPermission(context.Context, string, uint64, types.TenantRole, types.OrgMemberRole) (bool, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) CountSharesByKnowledgeBaseIDs(context.Context, []string) (map[string]int64, error) {
panic("not implemented")
}
func (s *stubKBShareForGuard) CountByOrganizations(context.Context, []string) (map[string]int64, error) {
panic("not implemented")
}
// stubAgentShareForGuard implements just the two methods the guard
// touches: GetSharedAgentForTenant (when ?agent_id=X is supplied) and
// TenantCanAccessKBViaSomeSharedAgent (the any-shared-agent fallback).
// Every other method panics so unintended new dependencies surface
// immediately.
type stubAgentShareForGuard struct {
// agents indexed by agent id; nil entry means GetSharedAgentForTenant
// returns nil + nil (i.e. caller has no access to that agent id).
agents map[string]*types.CustomAgent
// kbsViaSomeAgent[kb.ID] -> true means the any-agent fallback grants
// access to that KB.
kbsViaSomeAgent map[string]bool
}
func (s *stubAgentShareForGuard) GetSharedAgentForTenant(_ context.Context, _ uint64, _ types.TenantRole, agentID string, _ ...uint64) (*types.CustomAgent, error) {
return s.agents[agentID], nil
}
func (s *stubAgentShareForGuard) TenantCanAccessKBViaSomeSharedAgent(_ context.Context, _ uint64, _ types.TenantRole, kb *types.KnowledgeBase) (bool, error) {
return s.kbsViaSomeAgent[kb.ID], nil
}
func (s *stubAgentShareForGuard) ShareAgent(context.Context, string, string, string, uint64, types.OrgMemberRole) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) RemoveShare(context.Context, string, string, uint64) error {
panic("not implemented")
}
func (s *stubAgentShareForGuard) ListSharesByAgent(context.Context, string, uint64) ([]*types.AgentShare, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) ListSharesByOrganization(context.Context, string) ([]*types.AgentShare, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) ListSharedAgents(context.Context, uint64, types.TenantRole) ([]*types.SharedAgentInfo, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) ListSharedAgentsInOrganization(context.Context, string, uint64, types.TenantRole) ([]*types.OrganizationSharedAgentItem, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) ListSharedAgentsInOrganizations(context.Context, []string, uint64, types.TenantRole) (map[string][]*types.OrganizationSharedAgentItem, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) SetSharedAgentDisabledByMe(context.Context, uint64, string, uint64, bool) error {
panic("not implemented")
}
func (s *stubAgentShareForGuard) GetShare(context.Context, string) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) GetShareByAgentAndOrg(context.Context, string, string) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) GetShareByAgentIDForTenant(context.Context, uint64, string, uint64) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *stubAgentShareForGuard) CountByOrganizations(context.Context, []string) (map[string]int64, error) {
panic("not implemented")
}
// guardOpts collects optional knobs for runGuard. Keeps the call site
// readable when most tests only care about a couple of dimensions.
type guardOpts struct {
agentID string // ?agent_id query param
agentSourceTenantID string // ?agent_source_tenant_id query param
agentShare *stubAgentShareForGuard // nil means "no agent-share service"
}
// runGuard fires a single request through the guard and returns the
// gin recorder + the kb access (if any) the guard stashed. Defaults
// to EnableRBAC=true; the EnableRBAC=false fail-open path has its own
// dedicated tests further below.
func runGuard(
t *testing.T,
tenantID uint64,
kbID string,
requiredPerm types.OrgMemberRole,
kb *types.KnowledgeBase,
share *stubKBShareForGuard,
opts guardOpts,
) (*httptest.ResponseRecorder, *gin.Context) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: kbID}}
url := "/"
query := make([]string, 0, 2)
if opts.agentID != "" {
query = append(query, "agent_id="+opts.agentID)
}
if opts.agentSourceTenantID != "" {
query = append(query, "agent_source_tenant_id="+opts.agentSourceTenantID)
}
if len(query) > 0 {
url = "/?" + strings.Join(query, "&")
}
req := httptest.NewRequest("GET", url, nil)
ctx := context.WithValue(req.Context(), types.TenantIDContextKey, tenantID)
c.Request = req.WithContext(ctx)
kbsvc := &stubKBLookup{kbs: map[string]*types.KnowledgeBase{}}
if kb != nil {
kbsvc.kbs[kbID] = kb
}
// Convert the package-local concrete stub types to typed interface
// nils when they aren't supplied — otherwise the interface wraps a
// nil pointer and `iface != nil` evaluates true on the guard side
// (classic Go typed-nil trap).
var shareSvc interfaces.KBShareService
if share != nil {
shareSvc = share
}
var agentSvc interfaces.AgentShareService
if opts.agentShare != nil {
agentSvc = opts.agentShare
}
guard := RequireKBAccess(
KBIDFromParam("id"),
requiredPerm,
kbsvc,
shareSvc,
agentSvc,
cfgRBAC(true),
)
guard(c)
return rec, c
}
func TestRequireKBAccess_OwnKB(t *testing.T) {
rec, c := runGuard(t, 100, "kb-1",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-1", TenantID: 100},
nil,
guardOpts{},
)
require.False(t, c.IsAborted(), "should pass through")
require.Equal(t, 200, rec.Code) // gin's default; nothing wrote a status
access, ok := KBAccessFromContext(c)
require.True(t, ok)
require.Equal(t, uint64(100), access.EffectiveTenantID)
require.Equal(t, types.OrgRoleAdmin, access.Permission, "own KB grants admin")
// The request context's tenant should still be the caller's own.
got, ok := types.TenantIDFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, uint64(100), got)
}
// TestIsResourceNotFound_RecognisesKnowledgeSentinel pins that a missing
// *document* (knowledge) is treated as not-found, not a transient error.
// Regression: ErrKnowledgeNotFound was absent from the predicate, so
// GET/DELETE /knowledge/:id and chunk list resolved a missing doc into a
// raw 500 instead of a 404 — which the CLI then surfaced as a retryable
// server.error (exit 7), looping agents on a permanently-absent doc.
func TestIsResourceNotFound_RecognisesKnowledgeSentinel(t *testing.T) {
require.True(t, isResourceNotFound(apprepo.ErrKnowledgeNotFound),
"missing document (ErrKnowledgeNotFound) must classify as not-found")
require.True(t, isResourceNotFound(apprepo.ErrKnowledgeBaseNotFound),
"missing KB must still classify as not-found")
require.True(t, isResourceNotFound(apprepo.ErrChunkNotFound),
"missing chunk (ErrChunkNotFound) must classify as not-found — chunk view/by-id resolved a missing chunk into a raw 500 (exit 7) otherwise")
require.True(t, isResourceNotFound(ErrResourceNotFound),
"generic resource-not-found sentinel must still classify as not-found")
require.False(t, isResourceNotFound(errors.New("connection refused")),
"a genuine transient error must NOT be classified as not-found")
}
func TestRequireKBAccess_NotFound_Aborts(t *testing.T) {
_, c := runGuard(t, 100, "kb-missing", types.OrgRoleViewer, nil, nil, guardOpts{})
require.True(t, c.IsAborted(), "missing KB must abort")
require.NotEmpty(t, c.Errors)
_, ok := KBAccessFromContext(c)
require.False(t, ok, "no access should be stashed on failure")
}
func TestRequireKBAccess_SharedKB_RewritesTenantContext(t *testing.T) {
share := &stubKBShareForGuard{
permission: map[string]types.OrgMemberRole{"kb-shared": types.OrgRoleEditor},
shared: map[string]bool{"kb-shared": true},
source: map[string]uint64{"kb-shared": 200},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleEditor,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
share,
guardOpts{},
)
require.False(t, c.IsAborted())
access, ok := KBAccessFromContext(c)
require.True(t, ok)
require.Equal(t, uint64(200), access.EffectiveTenantID)
got, _ := types.TenantIDFromContext(c.Request.Context())
require.Equal(t, uint64(200), got, "guard must rewrite context to source tenant")
}
func TestRequireKBAccess_SharedKB_PermissionBelowMin_Aborts(t *testing.T) {
share := &stubKBShareForGuard{
permission: map[string]types.OrgMemberRole{"kb-shared": types.OrgRoleViewer},
shared: map[string]bool{"kb-shared": true},
source: map[string]uint64{"kb-shared": 200},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleEditor, // require Editor
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
share,
guardOpts{},
)
require.True(t, c.IsAborted(), "Viewer share must reject when Editor required")
}
func TestRequireKBAccess_NoTenant_Aborts(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "kb-x"}}
c.Request = httptest.NewRequest("GET", "/", nil) // no tenant in context
guard := RequireKBAccess(
KBIDFromParam("id"),
types.OrgRoleViewer,
&stubKBLookup{},
nil,
nil,
cfgRBAC(true),
)
guard(c)
require.True(t, c.IsAborted())
}
// ---------- Agent-share fallback ----------
func TestRequireKBAccess_AgentShare_AnyAgent_ViewerOnly(t *testing.T) {
// No org-share entry; caller has at least one shared agent that can
// access this KB. Required permission is Viewer, so the agent-share
// branch activates and grants read access at the source tenant.
agent := &stubAgentShareForGuard{
kbsViaSomeAgent: map[string]bool{"kb-shared": true},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent},
)
require.False(t, c.IsAborted())
access, ok := KBAccessFromContext(c)
require.True(t, ok)
require.Equal(t, uint64(200), access.EffectiveTenantID)
require.Equal(t, types.OrgRoleViewer, access.Permission, "agent share grants viewer only")
}
func TestRequireKBAccess_AgentShare_EditorRequired_Aborts(t *testing.T) {
// Required permission is Editor → agent-share fallback MUST NOT
// activate. Regression test for the implicit-security-fix in this
// PR: old TagHandler.effectiveCtxForKB granted any agent-share
// access to write routes, which leaked tag CRUD.
agent := &stubAgentShareForGuard{
kbsViaSomeAgent: map[string]bool{"kb-shared": true},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleEditor,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent},
)
require.True(t, c.IsAborted(), "agent share must NOT satisfy Editor requirement")
}
func TestRequireKBAccess_AgentShare_SpecificAgent_ModeAll(t *testing.T) {
// ?agent_id=A and A has KBSelectionMode=all on the source tenant.
// Guard should accept regardless of which KB.
agent := &stubAgentShareForGuard{
agents: map[string]*types.CustomAgent{
"agent-A": {
ID: "agent-A",
TenantID: 200,
Config: types.CustomAgentConfig{
KBSelectionMode: "all",
},
},
},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent, agentID: "agent-A"},
)
require.False(t, c.IsAborted())
access, _ := KBAccessFromContext(c)
require.Equal(t, types.OrgRoleViewer, access.Permission)
}
func TestRequireKBAccess_AgentShare_SpecificAgent_ModeSelected_Match(t *testing.T) {
agent := &stubAgentShareForGuard{
agents: map[string]*types.CustomAgent{
"agent-A": {
ID: "agent-A",
TenantID: 200,
Config: types.CustomAgentConfig{
KBSelectionMode: "selected",
KnowledgeBases: []string{"kb-other", "kb-shared"},
},
},
},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent, agentID: "agent-A"},
)
require.False(t, c.IsAborted())
}
func TestRequireKBAccess_AgentShare_SpecificAgent_ModeSelected_Miss(t *testing.T) {
// ?agent_id=A but A's selected list does NOT include this KB. Even
// though SOME OTHER shared agent (B) would have granted access via
// the any-agent fallback, the explicit agent_id pins the resolution
// to A. This is the divergence the review flagged.
agent := &stubAgentShareForGuard{
agents: map[string]*types.CustomAgent{
"agent-A": {
ID: "agent-A",
TenantID: 200,
Config: types.CustomAgentConfig{
KBSelectionMode: "selected",
KnowledgeBases: []string{"kb-other"},
},
},
},
// any-agent fallback would have said yes — but agent_id=A pins us.
kbsViaSomeAgent: map[string]bool{"kb-shared": true},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent, agentID: "agent-A"},
)
require.True(t, c.IsAborted(), "agent_id=A must NOT fall back to any-agent")
}
func TestRequireKBAccess_AgentShare_SpecificAgent_ModeNone(t *testing.T) {
agent := &stubAgentShareForGuard{
agents: map[string]*types.CustomAgent{
"agent-A": {
ID: "agent-A",
TenantID: 200,
Config: types.CustomAgentConfig{
KBSelectionMode: "none",
},
},
},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent, agentID: "agent-A"},
)
require.True(t, c.IsAborted(), "agent in mode=none must not grant access")
}
func TestRequireKBAccess_AgentShare_SpecificAgent_TenantMismatch(t *testing.T) {
// Agent belongs to tenant 999 but KB belongs to tenant 200 → reject
// (this is the kb.TenantID != agent.TenantID guard in the handler;
// preserves cross-tenant isolation when shares get reshuffled).
agent := &stubAgentShareForGuard{
agents: map[string]*types.CustomAgent{
"agent-A": {
ID: "agent-A",
TenantID: 999,
Config: types.CustomAgentConfig{
KBSelectionMode: "all",
},
},
},
}
_, c := runGuard(t, 100, "kb-shared",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-shared", TenantID: 200},
nil,
guardOpts{agentShare: agent, agentID: "agent-A"},
)
require.True(t, c.IsAborted())
}
// ---------- EnableRBAC=false rollout window ----------
func TestRequireKBAccess_Forbidden_FailOpenWhenRBACDisabled(t *testing.T) {
// Same scenario as PermissionBelowMin (which aborts when enforcing),
// but with EnableRBAC=false the guard logs and passes through.
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "kb-shared"}}
req := httptest.NewRequest("GET", "/", nil)
c.Request = req.WithContext(context.WithValue(req.Context(), types.TenantIDContextKey, uint64(100)))
share := &stubKBShareForGuard{
permission: map[string]types.OrgMemberRole{"kb-shared": types.OrgRoleViewer},
shared: map[string]bool{"kb-shared": true},
source: map[string]uint64{"kb-shared": 200},
}
kbsvc := &stubKBLookup{kbs: map[string]*types.KnowledgeBase{
"kb-shared": {ID: "kb-shared", TenantID: 200},
}}
guard := RequireKBAccess(
KBIDFromParam("id"),
types.OrgRoleEditor, // would-deny
kbsvc, share, nil,
cfgRBAC(false), // enforcement off
)
guard(c)
require.False(t, c.IsAborted(), "guard must pass through when EnableRBAC is off")
_ = rec
}
func TestRequireKBAccess_NotFound_FiresEvenWhenRBACDisabled(t *testing.T) {
// Not-found is not an authorisation event; the client asked for a
// resource that genuinely isn't there. We surface 404 regardless of
// the rollout flag (matches the comment in RequireKBAccess).
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "kb-missing"}}
req := httptest.NewRequest("GET", "/", nil)
c.Request = req.WithContext(context.WithValue(req.Context(), types.TenantIDContextKey, uint64(100)))
guard := RequireKBAccess(
KBIDFromParam("id"),
types.OrgRoleViewer,
&stubKBLookup{kbs: map[string]*types.KnowledgeBase{}},
nil, nil,
cfgRBAC(false),
)
guard(c)
require.True(t, c.IsAborted(), "404 still fires with enforcement off")
_ = rec
}
func TestRequireKBAccess_InvalidAgentSourceTenantID(t *testing.T) {
_, c := runGuard(t, 100, "kb-1",
types.OrgRoleViewer,
&types.KnowledgeBase{ID: "kb-1", TenantID: 200},
nil,
guardOpts{
agentID: "agent-1",
agentSourceTenantID: "not-a-number",
agentShare: &stubAgentShareForGuard{},
},
)
require.True(t, c.IsAborted())
require.NotEmpty(t, c.Errors)
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"github.com/Tencent/WeKnora/internal/accesspolicy"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// RequireKnowledgeExport runs after RequireKBAccess. Published and granted
// resources are read/use only; API keys retain their route and KB scopes.
func RequireKnowledgeExport() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
if _, apiKey := types.TenantAPIKeyScopeFromContext(ctx); apiKey {
c.Next()
return
}
if types.IsSystemAdminFromContext(ctx) || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
c.Next()
return
}
snapshot := accesspolicy.FromContext(ctx)
if snapshot == nil {
c.Next()
return
}
access, ok := KBAccessFromContext(c)
if !ok || access == nil || access.KnowledgeBase == nil {
_ = c.Error(apperrors.NewServiceUnavailableError("cannot verify knowledge export permission"))
c.Abort()
return
}
if snapshot.Publications["knowledge:"+access.KnowledgeBase.ID] || snapshot.NoExport["knowledge:"+access.KnowledgeBase.ID] {
_ = c.Error(apperrors.NewForbiddenError("全局知识库仅支持查看和使用,普通用户不能导出"))
c.Abort()
return
}
userID, _ := types.UserIDFromContext(ctx)
if userID == "" || access.KnowledgeBase.CreatorID == "" || access.KnowledgeBase.CreatorID != userID {
_ = c.Error(apperrors.NewForbiddenError("授权知识库仅支持查看和使用,非所有者不能导出"))
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,64 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestRequireKnowledgeExportFailsClosedWithoutResolvedKnowledgeBase(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
access any
}{
{name: "absent"},
{name: "nil access", access: (*KBAccess)(nil)},
{name: "missing KB", access: &KBAccess{}},
{name: "invalid type", access: "kb-id"},
} {
t.Run(tc.name, func(t *testing.T) {
r := gin.New()
r.Use(ErrorHandler())
r.Use(func(c *gin.Context) {
ctx := accesspolicy.WithSnapshot(c.Request.Context(), &accesspolicy.Snapshot{
Publications: map[string]bool{"knowledge:kb-id": true},
})
c.Request = c.Request.WithContext(ctx)
if tc.access != nil {
c.Set(KBAccessContextKey, tc.access)
}
c.Next()
})
r.GET("/export", RequireKnowledgeExport(), func(c *gin.Context) {
t.Fatal("export handler ran without an authorized KB")
})
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/export", nil))
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
})
}
}
func TestRequireKnowledgeExportPreservesSystemAdministrator(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.SystemAdminContextKey, true)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
ctx = accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:kb-id": true}})
c.Request = c.Request.WithContext(ctx)
c.Set(KBAccessContextKey, &KBAccess{KnowledgeBase: &types.KnowledgeBase{ID: "kb-id"}})
c.Next()
})
r.GET("/export", RequireKnowledgeExport(), func(c *gin.Context) { c.Status(http.StatusNoContent) })
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/export", nil))
require.Equal(t, http.StatusNoContent, rec.Code)
}
@@ -0,0 +1,57 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"github.com/Tencent/WeKnora/internal/accesspolicy"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// RequireKnowledgeVersionTransfer prevents stripping no-export provenance by
// copying or moving protected material into an unrelated, exportable KB.
func RequireKnowledgeVersionTransfer() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
if types.IsSystemAdminFromContext(ctx) || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
c.Next()
return
}
if _, apiKey := types.TenantAPIKeyScopeFromContext(ctx); apiKey {
c.Next()
return
}
snapshot := accesspolicy.FromContext(ctx)
if snapshot == nil {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, (1<<20)+1))
if err != nil || len(body) > 1<<20 {
_ = c.Error(apperrors.NewBadRequestError("复制或移动请求过大"))
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
var request struct {
SourceID string `json:"source_id"`
SourceKBID string `json:"source_kb_id"`
}
if err = json.Unmarshal(body, &request); err != nil {
_ = c.Error(apperrors.NewBadRequestError("无效的复制或移动请求"))
c.Abort()
return
}
for _, id := range []string{request.SourceID, request.SourceKBID} {
if id != "" && (snapshot.NoExport["knowledge:"+id] || snapshot.Publications["knowledge:"+id]) {
_ = c.Error(apperrors.NewForbiddenError("此知识库不可复制或移动到其他知识库;请在我的资源版本中创建独立个人版本"))
c.Abort()
return
}
}
c.Next()
}
}
@@ -0,0 +1,52 @@
package middleware
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func TestKnowledgeVersionTransferPreservesProvenance(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name, body string
admin, blocked bool
}{
{"global copy", `{"source_id":"global"}`, false, true},
{"personal move", `{"source_kb_id":"personal"}`, false, true},
{"ordinary copy", `{"source_id":"owned"}`, false, false},
{"admin copy", `{"source_id":"global"}`, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
router := gin.New()
called := false
router.POST("/transfer", func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantRoleContextKey, types.TenantRoleContributor)
if tc.admin {
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
}
ctx = accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:global": true}, NoExport: map[string]bool{"knowledge:personal": true}})
c.Request = c.Request.WithContext(ctx)
c.Next()
}, RequireKnowledgeVersionTransfer(), func(c *gin.Context) {
var body map[string]string
if err := c.ShouldBindJSON(&body); err != nil {
t.Error("middleware consumed request body", err)
}
called = true
c.Status(204)
})
req := httptest.NewRequest("POST", "/transfer", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(httptest.NewRecorder(), req)
if called == tc.blocked {
t.Fatal("incorrect transfer authorization")
}
})
}
}
+68
View File
@@ -0,0 +1,68 @@
package middleware
import (
"context"
"strings"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// Language extracts the user's language preference and injects it into the request context.
//
// Priority (highest to lowest):
// 1. WEKNORA_LANGUAGE environment variable (deployment-level override for document processing language)
// 2. Accept-Language HTTP header (first tag, e.g. "zh-CN,zh;q=0.9" → "zh-CN")
// 3. "zh-CN" hardcoded fallback
//
// WEKNORA_LANGUAGE takes precedence over Accept-Language because the UI locale (menu language)
// and the document processing language (question/summary generation) are separate concerns.
// A user may prefer English UI while processing Korean documents.
func Language() gin.HandlerFunc {
// Read env var once at startup; empty string means "not configured"
envLang := types.EnvLanguage()
return func(c *gin.Context) {
// 1. WEKNORA_LANGUAGE env takes precedence (deployment-level document language)
if envLang != "" {
c.Set(types.LanguageContextKey.String(), envLang)
ctx := context.WithValue(c.Request.Context(), types.LanguageContextKey, envLang)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}
lang := ""
// 2. Try Accept-Language header
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
lang = parseFirstLanguageTag(acceptLang)
}
// 3. Fallback to hardcoded default
if lang == "" {
lang = "zh-CN"
}
// Inject into context
c.Set(types.LanguageContextKey.String(), lang)
ctx := context.WithValue(c.Request.Context(), types.LanguageContextKey, lang)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// parseFirstLanguageTag extracts the first language tag from an Accept-Language header value.
// e.g. "zh-CN,zh;q=0.9,en;q=0.8" → "zh-CN"
// e.g. "zh-CN" → "zh-CN"
func parseFirstLanguageTag(header string) string {
// Split by comma and take the first entry
parts := strings.SplitN(header, ",", 2)
if len(parts) == 0 {
return ""
}
// Remove quality value if present (e.g. "zh-CN;q=0.9" → "zh-CN")
tag := strings.SplitN(strings.TrimSpace(parts[0]), ";", 2)[0]
return strings.TrimSpace(tag)
}
+259
View File
@@ -0,0 +1,259 @@
package middleware
import (
"bytes"
"context"
"io"
"net/url"
"regexp"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const (
maxBodySize = 1024 * 10 // 最大记录10KB的body内容
)
// loggerResponseBodyWriter 自定义ResponseWriter用于捕获响应内容(用于logger中间件)
type loggerResponseBodyWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
// Write 重写Write方法,同时写入buffer和原始writer
// 限制buffer大小,避免SSE等流式响应导致内存无限增长
func (r loggerResponseBodyWriter) Write(b []byte) (int, error) {
if r.body.Len() < maxBodySize {
remaining := maxBodySize - r.body.Len()
if len(b) <= remaining {
r.body.Write(b)
} else {
r.body.Write(b[:remaining])
}
}
return r.ResponseWriter.Write(b)
}
// sensitiveFieldRegex 匹配 JSON 中的敏感字段(不区分大小写,兼容 snake_case / camelCase / PascalCase)。
// $1 捕获原始字段名(包括两侧引号),保持日志中的字段名不变,仅将值替换为 "***"。
var sensitiveFieldRegex = regexp.MustCompile(
`(?i)("(?:new[_-]?password|old[_-]?password|password|passwd|token|access[_-]?token|` +
`refresh[_-]?token|id[_-]?token|authorization|auth[_-]?token|api[_-]?key|` +
`api[_-]?secret|secret[_-]?key|client[_-]?secret|private[_-]?key|secret|` +
`authorization[_-]?url|authorization[_-]?attempt)")\s*:\s*"[^"]*"`,
)
// sanitizeBody 清理敏感信息
func sanitizeBody(body string) string {
return sensitiveFieldRegex.ReplaceAllString(body, `$1:"***"`)
}
var sensitiveQueryFields = map[string]struct{}{
"access_token": {},
"authorization_attempt": {},
"code": {},
"id_token": {},
"refresh_token": {},
"state": {},
"token": {},
}
// sanitizeQuery prevents OAuth authorization codes and CSRF/attempt state from
// being copied into access logs. Parsing the query also covers repeated and
// percent-encoded parameters without relying on fragile string replacement.
func sanitizeQuery(raw string) string {
values, err := url.ParseQuery(raw)
if err != nil {
return "[invalid query omitted]"
}
for key := range values {
if _, sensitive := sensitiveQueryFields[strings.ToLower(key)]; sensitive {
values[key] = []string{"***"}
}
}
return values.Encode()
}
// readRequestBody 读取请求体(限制大小用于日志,但完整读取用于重置)
func readRequestBody(c *gin.Context) string {
if c.Request.Body == nil {
return ""
}
// 检查Content-Type,只记录JSON类型
contentType := c.GetHeader("Content-Type")
if !strings.Contains(contentType, "application/json") &&
!strings.Contains(contentType, "application/x-www-form-urlencoded") &&
!strings.Contains(contentType, "text/") {
return "[非文本类型,已跳过]"
}
// 完整读取body内容(不限制大小),因为需要完整重置给后续handler使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return "[读取请求体失败]"
}
// 重置request body,使用完整内容,确保后续handler能读取到完整数据
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// 用于日志的body(限制大小)
var logBodyBytes []byte
if len(bodyBytes) > maxBodySize {
logBodyBytes = bodyBytes[:maxBodySize]
} else {
logBodyBytes = bodyBytes
}
bodyStr := string(logBodyBytes)
if len(bodyBytes) > maxBodySize {
bodyStr += "... [内容过长,已截断]"
}
return sanitizeBody(bodyStr)
}
// RequestID middleware adds a unique request ID to the context
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
// Get request ID from header or generate a new one
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}
safeRequestID := secutils.SanitizeForLog(requestID)
// Set request ID in header
c.Header("X-Request-ID", requestID)
// Set request ID in context
c.Set(types.RequestIDContextKey.String(), requestID)
// Set logger in context
requestLogger := logger.GetLogger(c)
requestLogger = requestLogger.WithField("request_id", safeRequestID)
c.Set(types.LoggerContextKey.String(), requestLogger)
// Set request ID in the global context for logging
c.Request = c.Request.WithContext(
context.WithValue(
context.WithValue(c.Request.Context(), types.RequestIDContextKey, requestID),
types.LoggerContextKey, requestLogger,
),
)
c.Next()
}
}
// Logger middleware logs request details with request ID, input and output
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
isWikiStats := strings.HasPrefix(path, "/api/v1/knowledgebase/") && strings.HasSuffix(path, "/wiki/stats")
if strings.HasPrefix(path, "/assets/") || isWikiStats {
c.Next()
return
}
// 读取请求体(在Next之前读取,因为Next会消费body)
var requestBody string
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
requestBody = readRequestBody(c)
}
// 创建响应体捕获器
responseBody := &bytes.Buffer{}
responseWriter := &loggerResponseBodyWriter{
ResponseWriter: c.Writer,
body: responseBody,
}
c.Writer = responseWriter
// Process request
c.Next()
// Get request ID from context
requestID, exists := c.Get(types.RequestIDContextKey.String())
requestIDStr := "unknown"
if exists {
if idStr, ok := requestID.(string); ok && idStr != "" {
requestIDStr = idStr
}
}
safeRequestID := secutils.SanitizeForLog(requestIDStr)
// Calculate latency
latency := time.Since(start)
// Get client IP and status code
clientIP := c.ClientIP()
statusCode := c.Writer.Status()
method := c.Request.Method
if raw != "" {
path = path + "?" + sanitizeQuery(raw)
}
// 读取响应体
responseBodyStr := ""
if responseBody.Len() > 0 {
contentType := c.Writer.Header().Get("Content-Type")
if strings.Contains(contentType, "text/event-stream") {
responseBodyStr = "[SSE流式响应,已跳过]"
} else if strings.Contains(contentType, "application/json") ||
strings.Contains(contentType, "text/") {
bodyBytes := responseBody.Bytes()
if len(bodyBytes) >= maxBodySize {
responseBodyStr = string(bodyBytes[:maxBodySize]) + "... [内容过长,已截断]"
} else {
responseBodyStr = string(bodyBytes)
}
responseBodyStr = sanitizeBody(responseBodyStr)
} else {
responseBodyStr = "[非文本类型,已跳过]"
}
}
// 构建日志消息
logMsg := logger.GetLogger(c)
logMsg = logMsg.WithFields(map[string]interface{}{
"request_id": safeRequestID,
"method": method,
"path": secutils.SanitizeForLog(path),
"status_code": statusCode,
"size": c.Writer.Size(),
"latency": latency.String(),
"client_ip": secutils.SanitizeForLog(clientIP),
})
// 添加请求体(如果有)
if requestBody != "" {
logMsg = logMsg.WithField("request_body", secutils.SanitizeForLog(requestBody))
}
// 添加响应体(如果有)
if responseBodyStr != "" {
logMsg = logMsg.WithField("response_body", secutils.SanitizeForLog(responseBodyStr))
}
if last := c.Errors.Last(); last != nil && last.Err != nil {
logMsg = logMsg.WithField("error", secutils.SanitizeForLog(last.Err.Error()))
}
switch {
case statusCode >= 500:
logMsg.Error()
case statusCode >= 400:
logMsg.Warn()
default:
logMsg.Info()
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package middleware
import "testing"
func TestSanitizeBody(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "camelCase apiKey",
in: `{"modelName":"gpt-5.2","apiKey":"sk-secret-123","provider":"azure_openai"}`,
want: `{"modelName":"gpt-5.2","apiKey":"***","provider":"azure_openai"}`,
},
{
name: "snake_case api_key",
in: `{"api_key":"sk-secret-123"}`,
want: `{"api_key":"***"}`,
},
{
name: "PascalCase APIKey",
in: `{"APIKey":"sk-secret-123"}`,
want: `{"APIKey":"***"}`,
},
{
name: "secretKey camelCase",
in: `{"secretKey":"abc","accessKeyId":"id"}`,
want: `{"secretKey":"***","accessKeyId":"id"}`,
},
{
name: "refreshToken / accessToken camelCase",
in: `{"refreshToken":"rt","accessToken":"at"}`,
want: `{"refreshToken":"***","accessToken":"***"}`,
},
{
name: "password and token preserved as masked",
in: `{"password":"p","token":"t"}`,
want: `{"password":"***","token":"***"}`,
},
{
name: "snake_case new_password and old_password",
in: `{"email":"alice@example.com","new_password":"FreshPass9","old_password":"OldPass9"}`,
want: `{"email":"alice@example.com","new_password":"***","old_password":"***"}`,
},
{
name: "extra whitespace around colon",
in: `{"apiKey" : "leak"}`,
want: `{"apiKey":"***"}`,
},
{
name: "non sensitive fields untouched",
in: `{"baseUrl":"https://example.com","modelName":"gpt"}`,
want: `{"baseUrl":"https://example.com","modelName":"gpt"}`,
},
{
name: "OAuth authorization response fields",
in: `{"authorization_url":"https://idp.example/authorize?state=secret","authorization_attempt":"secret-state"}`,
want: `{"authorization_url":"***","authorization_attempt":"***"}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeBody(tc.in)
if got != tc.want {
t.Errorf("sanitizeBody(%q)\n got: %s\nwant: %s", tc.in, got, tc.want)
}
})
}
}
func TestSanitizeQuery(t *testing.T) {
got := sanitizeQuery("code=secret-code&state=secret-state&next=%2Fsettings&state=second")
want := "code=%2A%2A%2A&next=%2Fsettings&state=%2A%2A%2A"
if got != want {
t.Fatalf("sanitizeQuery() = %q, want %q", got, want)
}
}
+386
View File
@@ -0,0 +1,386 @@
package middleware
import (
"context"
"errors"
"net/http"
"sync"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// ErrResourceNotFound is the sentinel a CreatorLookup returns when the
// :id on the request does not match any row the lookup can see (either
// the row is genuinely missing or its tenant doesn't match). When a
// lookup returns this error, RequireOwnershipOrRole intentionally lets
// the request proceed so the downstream handler can respond with its
// own 404 — middleware-level 403 would hide real "URL is wrong" failures
// behind a permissions error, which breaks client diagnostics and
// operator dashboards.
var ErrResourceNotFound = errors.New("rbac: resource not found")
// CreatorLookup resolves the creator user ID for the resource targeted
// by the current request, based on whatever is on the gin.Context (URL
// params, query, body). Implementations live next to the handlers they
// guard, e.g. handler.kbCreatorLookup(c) reads ":id" and returns
// KnowledgeBase.CreatorID.
//
// Return value contract:
// - (creatorID, nil) where creatorID != "" -> the resource has a
// recorded owner; ownership match grants access.
// - ("", nil) -> "tenant-owned": no
// human creator was recorded (legacy row or built-in resource);
// only callers whose role meets the bar may proceed.
// - ("", ErrResourceNotFound) -> the :id does not
// resolve to any row visible to this caller's tenant. Middleware
// proceeds to the handler so the handler can return 404 instead
// of masking it as 403.
// - ("", other error) -> transient or
// unexpected failure (DB hiccup, etc.). Middleware returns 503
// when enforcement is on so monitoring catches the real fault;
// when enforcement is off, it logs and lets the request through.
type CreatorLookup func(c *gin.Context) (creatorID string, err error)
// RequireRole returns a gin middleware that aborts the request with
// HTTP 403 unless the caller's TenantRole (set by the auth middleware
// in TenantRoleContextKey) is at least min.
//
// Cross-tenant superusers (User.CanAccessAllTenants) automatically
// satisfy any role gate. Otherwise rolling out tenant-RBAC would silently
// break organisation-level operators who own no tenant_members row in
// the tenant they're administering. The escape hatch is bounded by the
// existing canAccessTenant gate in auth.go; this middleware does not
// grant extra reach, only honours what was already approved.
//
// When cfg.Tenant.EnableRBAC is false, the middleware logs the would-be
// rejection but lets the request through — preserving today's behaviour
// during the rollout window. Once operators flip the flag to true,
// the same code paths start rejecting unauthorised callers.
//
// The auth middleware always sets a TenantRole; if for some reason it
// is missing, TenantRoleFromContext defaults to TenantRoleViewer, which
// is the safest fail-closed value: anything that requires more than
// Viewer will reject.
func RequireRole(min types.TenantRole, cfg *config.Config) gin.HandlerFunc {
warnOnNilConfig(cfg)
return func(c *gin.Context) {
ctx := c.Request.Context()
// API-key principals are authorized solely by the APIKeyGate
// (role + KB scope + default-deny). The JWT role ladder does not
// apply to a machine principal, so short-circuit here.
if _, ok := types.TenantAPIKeyScopeFromContext(ctx); ok {
c.Next()
return
}
role := types.TenantRoleFromContext(ctx)
if role.HasPermission(min) {
c.Next()
return
}
if IsCrossTenantSuperuser(ctx, cfg) {
c.Next()
return
}
uid, _ := types.UserIDFromContext(ctx)
if !rbacEnforcementEnabled(cfg) {
logger.Warnf(ctx,
"[rbac] role insufficient (logged but not enforced): user=%s have=%s need=%s path=%s",
uid, role, min, c.Request.URL.Path)
c.Next()
return
}
logger.Warnf(ctx,
"[rbac] role insufficient: user=%s have=%s need=%s path=%s",
uid, role, min, c.Request.URL.Path)
// Durable audit row for the reject. AuditServiceProvider
// injects the service; subject to 1-minute sliding-window
// dedup inside the service so probing clients can't fill the
// table.
if svc := AuditServiceFromContext(c); svc != nil {
tenantID, _ := types.TenantIDFromContext(ctx)
_ = svc.LogDenied(ctx, c, tenantID, uid, string(role), min)
}
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: insufficient workspace role",
})
c.Abort()
}
}
// RequireRoleOrSystemAdmin applies the tenant role floor while also allowing
// platform system administrators. Use it for routes that normally mutate
// tenant infrastructure but have a narrowly-scoped platform-owned resource
// (for example built-in models) that system administrators must be able to
// maintain independently of their role in the active tenant.
//
// API-key behavior remains identical to RequireRole: the APIKeyGate is the
// source of truth for machine principals, so they short-circuit the role
// check here as well.
func RequireRoleOrSystemAdmin(min types.TenantRole, cfg *config.Config) gin.HandlerFunc {
requireRole := RequireRole(min, cfg)
return func(c *gin.Context) {
if types.IsSystemAdminFromContext(c.Request.Context()) {
c.Next()
return
}
requireRole(c)
}
}
// RequireSystemAdmin returns a gin middleware that aborts the request with
// HTTP 403 unless the caller is a system administrator
// (User.IsSystemAdmin = true).
//
// System administrators operate independently of tenant-scoped roles and
// are not bound by the per-tenant RBAC matrix. Use this guard for
// platform-wide administrative endpoints (managing other system admins,
// editing global settings, cross-workspace operations) where the per-tenant
// Owner/Admin/Contributor/Viewer ladder does not apply.
//
// Unlike tenant-role guards, this check is always enforced. The
// tenant RBAC rollout switch only controls per-tenant Owner/Admin/etc.
// checks; it must not turn platform-wide administration endpoints into
// "any authenticated user can call this" endpoints.
func RequireSystemAdmin(cfg *config.Config) gin.HandlerFunc {
warnOnNilConfig(cfg)
return func(c *gin.Context) {
ctx := c.Request.Context()
// The API-key gate runs before this guard and default-denies undeclared
// routes. Only platform keys that passed an explicit platform capability
// policy may reuse the system-admin handlers below.
if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok {
if scope.IsPlatform() {
c.Next()
return
}
logger.Warnf(ctx,
"[rbac] system admin required: API-key principal denied path=%s",
c.Request.URL.Path)
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: API keys cannot access this endpoint",
})
c.Abort()
return
}
if types.IsSystemAdminFromContext(ctx) {
c.Next()
return
}
uid, _ := types.UserIDFromContext(ctx)
logger.Warnf(ctx,
"[rbac] system admin required: user=%s path=%s",
uid, c.Request.URL.Path)
// Durable audit row for the reject — same dedup as RequireRole.
if svc := AuditServiceFromContext(c); svc != nil {
tenantID, _ := types.TenantIDFromContext(ctx)
_ = svc.LogDenied(ctx, c, tenantID, uid, "user", "system_admin")
}
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: system administrator required",
})
c.Abort()
}
}
// RequireOwnershipOrRole guards endpoints whose access is allowed for
// either (a) callers whose role is at least min, or (b) the original
// creator of the resource being touched.
//
// Use it for KB / agent mutations where Contributors should only manage
// their own resources but Admins+ have free reign. The lookup closure
// is responsible for translating the URL into the resource's creator
// user ID (see CreatorLookup for the return-value contract).
//
// Decision order:
// 1. role >= min -> allow without running lookup.
// 2. cross-tenant superuser -> allow without running lookup.
// 3. enforcement off -> log, allow without running lookup. This is the
// critical rollout-safety guarantee: when EnableRBAC is false, the
// lookup is NEVER invoked, so dormant mode incurs zero extra DB
// roundtrips on hot per-resource mutation paths.
// 4. lookup returns ErrResourceNotFound -> pass through; let the
// handler issue the proper 404.
// 5. lookup returns other error -> 503 (transient/unexpected fault).
// Preserves observability instead of disguising server errors as 403.
// 6. lookup returns the caller's user ID -> allow (ownership match).
// 7. lookup returns "" (tenant-owned, no human creator) -> 403 (we
// already know role < min from step 1).
// 8. lookup returns a non-empty creator that is not the caller -> 403.
func RequireOwnershipOrRole(min types.TenantRole, lookup CreatorLookup, cfg *config.Config) gin.HandlerFunc {
warnOnNilConfig(cfg)
return func(c *gin.Context) {
ctx := c.Request.Context()
// API-key principals are authorized solely by the APIKeyGate.
// Ownership ("creator OR Admin+") is a human concept that cannot
// apply to a machine principal (its synthetic system-user never
// matches creator_id), so short-circuit here. KB-scope for API
// keys is still enforced by the KBAccess guards + handler checks.
if _, ok := types.TenantAPIKeyScopeFromContext(ctx); ok {
c.Next()
return
}
role := types.TenantRoleFromContext(ctx)
// 1. Fast path: role meets the bar.
if role.HasPermission(min) {
c.Next()
return
}
// 2. Cross-tenant superuser bypass — same reasoning as RequireRole.
if IsCrossTenantSuperuser(ctx, cfg) {
c.Next()
return
}
uid, _ := types.UserIDFromContext(ctx)
// 3. Fail-open shortcut: when enforcement is off, do NOT run the
// lookup. Running it would add a hidden DB roundtrip on every
// mutating request during the rollout window — see #1318 review.
if !rbacEnforcementEnabled(cfg) {
logger.Warnf(ctx,
"[rbac] ownership/role would be checked (enforcement off, lookup skipped): "+
"user=%s have=%s need=%s path=%s",
uid, role, min, c.Request.URL.Path)
c.Next()
return
}
creator, err := lookup(c)
switch {
case errors.Is(err, ErrResourceNotFound):
// 4. Hand off to the handler so the client sees a real 404
// rather than a fake "no permission" 403.
c.Next()
return
case err != nil:
// 5. Genuine failure — surface it as 5xx so monitoring catches it.
logger.Errorf(ctx,
"[rbac] creator lookup failed: user=%s path=%s err=%v",
uid, c.Request.URL.Path, err)
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Service Unavailable: cannot verify resource ownership",
})
c.Abort()
return
}
// 6. Ownership match wins even when role is below min — that's the
// whole point: Contributors can edit their own resources.
if creator != "" && creator == uid {
c.Next()
return
}
// 7-8. Tenant-owned (creator=="") or non-creator with insufficient role.
logger.Warnf(ctx,
"[rbac] ownership/role insufficient: user=%s have=%s need=%s creator=%q path=%s",
uid, role, min, creator, c.Request.URL.Path)
// Same durable audit hook as RequireRole — subject to dedup.
if svc := AuditServiceFromContext(c); svc != nil {
tenantID, _ := types.TenantIDFromContext(ctx)
_ = svc.LogDenied(ctx, c, tenantID, uid, string(role), min)
}
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: must own the resource or have the required role",
})
c.Abort()
}
}
// rbacEnforcementEnabled reports whether middleware should actually
// reject failed checks. When the flag is off the middleware still runs
// role-only checks (logging, fast paths), but rejection is downgraded
// to a warning and ownership lookups are skipped entirely so the dormant
// rollout window incurs no per-request DB cost.
func rbacEnforcementEnabled(cfg *config.Config) bool {
return cfg != nil && cfg.Tenant.IsRBACEnforced()
}
// ErrOwnershipForbidden is returned by EvaluateOwnershipOrRole when the
// caller is neither the resource creator nor meets the minimum role.
var ErrOwnershipForbidden = errors.New("rbac: ownership or role insufficient")
// EvaluateOwnershipOrRole applies the same decision matrix as
// RequireOwnershipOrRole for handlers that resolve creator_id out-of-band
// (e.g. KB id carried in a JSON body rather than a URL param).
//
// Returns nil when access is allowed. ErrResourceNotFound means the
// handler should issue its own 404. ErrOwnershipForbidden maps to 403.
// Any other error is a transient lookup failure (503).
func EvaluateOwnershipOrRole(
ctx context.Context,
cfg *config.Config,
min types.TenantRole,
creatorID string,
lookupErr error,
) error {
// API-key principals are authorized solely by the APIKeyGate (route
// policy) plus the KB allow-list handlers enforce separately
// (requireTenantAPIKeyKnowledgeBase(s)). Ownership ("creator OR Admin+")
// is a human concept that never applies to a machine principal, so
// short-circuit here — exactly as RequireOwnershipOrRole does for the
// middleware form. Without this, a scoped key (synthesized as Viewer for
// legacy-guard compatibility) would be 403'd by the body-carried-KB
// ownership checks even though the gate + allow-list already admitted it.
if _, ok := types.TenantAPIKeyScopeFromContext(ctx); ok {
return nil
}
role := types.TenantRoleFromContext(ctx)
if role.HasPermission(min) {
return nil
}
if IsCrossTenantSuperuser(ctx, cfg) {
return nil
}
if !rbacEnforcementEnabled(cfg) {
uid, _ := types.UserIDFromContext(ctx)
logger.Warnf(ctx,
"[rbac] ownership/role would be checked (enforcement off, lookup skipped): user=%s have=%s need=%s",
uid, role, min)
return nil
}
if errors.Is(lookupErr, ErrResourceNotFound) {
return ErrResourceNotFound
}
if lookupErr != nil {
return lookupErr
}
uid, _ := types.UserIDFromContext(ctx)
if creatorID != "" && creatorID == uid {
return nil
}
logger.Warnf(ctx,
"[rbac] ownership/role insufficient: user=%s have=%s need=%s creator=%q",
uid, role, min, creatorID)
return ErrOwnershipForbidden
}
// isCrossTenantSuperuser was moved to access.go (renamed to
// IsCrossTenantSuperuser, exported, and made flag-aware) so the same
// helper backs the X-Tenant-ID gate in auth.go and the RequireRole /
// RequireOwnershipOrRole guards above.
// warnOnNilConfig emits a one-shot startup warning when a guard is
// constructed with a nil-or-incomplete config. nil cfg makes
// rbacEnforcementEnabled return false, which means an entire deployment
// silently runs with RBAC disabled — usually because of a configuration
// bug rather than an intentional choice. Operators should see a noisy
// log line at boot pointing at the misconfiguration.
var nilCfgWarnOnce sync.Once
func warnOnNilConfig(cfg *config.Config) {
if cfg != nil && cfg.Tenant != nil {
return
}
nilCfgWarnOnce.Do(func() {
logger.Errorf(context.Background(),
"[rbac] middleware constructed with nil/incomplete config "+
"(cfg=%v); enforcement is permanently disabled. This is "+
"almost certainly a wiring bug.", cfg)
})
}
@@ -0,0 +1,146 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// apiKeyRBACHarness seeds an API-key scope (as attachAPIKeyAuthContext would)
// plus a deliberately-insufficient TenantRole, then runs the guard. It asserts
// the JWT guard short-circuits for API-key principals so that per-route
// API-key authorization is left entirely to the APIKeyGate.
func apiKeyRBACHarness(scope types.TenantAPIKeyScope, role types.TenantRole, mw gin.HandlerFunc) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantRoleContextKey, role)
ctx = types.WithTenantAPIKeyScope(ctx, scope)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
r.GET("/protected", mw, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/protected", nil))
return w
}
func TestRequireRole_ShortCircuitsAPIKey(t *testing.T) {
// Scoped API key against an Admin gate would 403 under the JWT
// ladder; the short-circuit lets it through (the gate handles it).
w := apiKeyRBACHarness(
types.TenantAPIKeyScope{},
types.TenantRoleViewer,
RequireRole(types.TenantRoleAdmin, cfgRBAC(true)),
)
if w.Code != http.StatusOK {
t.Fatalf("API-key principal should short-circuit RequireRole, got %d", w.Code)
}
}
func TestRequireSystemAdmin_RejectsAPIKey(t *testing.T) {
w := apiKeyRBACHarness(
types.TenantAPIKeyScope{FullAccess: true},
types.TenantRoleOwner,
RequireSystemAdmin(cfgRBAC(true)),
)
if w.Code != http.StatusForbidden {
t.Fatalf("API-key principal must be rejected by RequireSystemAdmin, got %d", w.Code)
}
}
func TestRequireSystemAdmin_AllowsPlatformAPIKeyAfterRouteGate(t *testing.T) {
w := apiKeyRBACHarness(
types.TenantAPIKeyScope{ScopeType: types.APIKeyScopePlatform},
types.TenantRoleViewer,
RequireSystemAdmin(cfgRBAC(true)),
)
if w.Code != http.StatusOK {
t.Fatalf("platform API key should pass the system-admin role guard after route authorization, got %d", w.Code)
}
}
// TestRequireOwnershipOrRole_ShortCircuitsAPIKey is the core regression for
// review #1: an API key writing to a KB it does not
// "own" (synthetic system user never matches creator_id) must not be 403'd by
// the OwnedKBOrAdmin guard when EnableRBAC=true. The lookup deliberately
// returns a foreign creator to prove the guard never runs it.
func TestRequireOwnershipOrRole_ShortCircuitsAPIKey(t *testing.T) {
lookupCalled := false
lookup := func(_ *gin.Context) (string, error) {
lookupCalled = true
return "some-other-human-user", nil
}
w := apiKeyRBACHarness(
types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityIngest)},
},
types.TenantRoleContributor,
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)),
)
if w.Code != http.StatusOK {
t.Fatalf("API-key write should short-circuit OwnedKBOrAdmin, got %d", w.Code)
}
if lookupCalled {
t.Fatal("ownership lookup must not run for API-key principals")
}
}
// TestEvaluateOwnershipOrRole_ShortCircuitsAPIKey mirrors
// TestRequireOwnershipOrRole_ShortCircuitsAPIKey for the handler-side twin used
// when the KB id is carried in the request body (MoveKnowledge /
// BatchDeleteKnowledge via requireKBOwnershipOrAdmin). A scoped ingest key that
// the APIKeyGate + KB allow-list already admitted must not be re-rejected here
// just because it is synthesized as a Viewer for legacy-guard compatibility.
func TestEvaluateOwnershipOrRole_ShortCircuitsAPIKey(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityIngest)},
})
// Synthesized Viewer role + a foreign creator would 403 a human caller.
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if err := EvaluateOwnershipOrRole(ctx, cfgRBAC(true),
types.TenantRoleAdmin, "some-other-human-user", nil); err != nil {
t.Fatalf("API-key principal should short-circuit EvaluateOwnershipOrRole, got %v", err)
}
}
// Sanity: a JWT Viewer with a foreign creator is still forbidden — the
// short-circuit must be scoped to API-key principals only.
func TestEvaluateOwnershipOrRole_JWTViewerStillDenied(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleViewer)
err := EvaluateOwnershipOrRole(ctx, cfgRBAC(true),
types.TenantRoleAdmin, "some-other-human-user", nil)
if err == nil {
t.Fatal("JWT Viewer with foreign creator must be denied by EvaluateOwnershipOrRole")
}
}
// Sanity: a real JWT Viewer is still rejected by the Admin gate — the
// short-circuit must be scoped to API-key principals only.
func TestRequireRole_JWTViewerStillDenied(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantRoleContextKey, types.TenantRoleViewer)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
r.GET("/p", RequireRole(types.TenantRoleAdmin, &config.Config{Tenant: &config.TenantConfig{EnableRBAC: boolPtr(true)}}),
func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/p", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("JWT Viewer must still be denied by Admin gate, got %d", w.Code)
}
}
func boolPtr(b bool) *bool { return &b }
+137
View File
@@ -0,0 +1,137 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// PR 6 (#1303): the rbac middleware's two reject paths now call
// AuditServiceFromContext(c).LogDenied so denials are durably
// recorded. These tests pin that wiring — without them, a future
// refactor that drops the AuditServiceProvider middleware (or breaks
// the context-stash key) would silently lose the durable audit
// without any coverage signal.
// stubDenyAudit captures LogDenied calls. Embeds the interface so any
// other method panics on use — keeps tests honest about which API
// they're exercising.
type stubDenyAudit struct {
interfaces.AuditLogService
mu sync.Mutex
calls []denyCall
}
type denyCall struct {
tenantID uint64
actorUserID string
actorRole string
required types.TenantRole
}
func (s *stubDenyAudit) LogDenied(
_ context.Context,
_ *gin.Context,
tenantID uint64,
actorUserID, actorRole string,
requiredRole types.TenantRole,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, denyCall{tenantID, actorUserID, actorRole, requiredRole})
return nil
}
// auditableHarness mirrors rbacTestHarness but additionally wires
// AuditServiceProvider with the supplied stub so the rejection
// paths can exercise the audit hook.
func auditableHarness(
t *testing.T, role types.TenantRole, userID string, tenantID uint64,
audit interfaces.AuditLogService, mw gin.HandlerFunc,
) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuditServiceProvider(audit))
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantRoleContextKey, role)
ctx = context.WithValue(ctx, types.UserIDContextKey, userID)
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
r.GET("/protected", mw, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
r.ServeHTTP(w, req)
return w
}
func TestRequireRole_RejectFiresAuditHook(t *testing.T) {
audit := &stubDenyAudit{}
w := auditableHarness(t, types.TenantRoleContributor, "u1", 7, audit,
RequireRole(types.TenantRoleAdmin, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
if len(audit.calls) != 1 {
t.Fatalf("expected exactly one audit hook call, got %d", len(audit.calls))
}
got := audit.calls[0]
if got.tenantID != 7 || got.actorUserID != "u1" ||
got.actorRole != string(types.TenantRoleContributor) ||
got.required != types.TenantRoleAdmin {
t.Fatalf("audit hook payload mismatch: %+v", got)
}
}
func TestRequireRole_DormantModeDoesNotFireAuditHook(t *testing.T) {
// EnableRBAC=false: middleware logs but does NOT 403, so the
// durable audit must NOT fire either — the dormant rollout window
// would otherwise generate audit noise for non-rejections.
audit := &stubDenyAudit{}
w := auditableHarness(t, types.TenantRoleViewer, "u1", 7, audit,
RequireRole(types.TenantRoleOwner, cfgRBAC(false)))
if w.Code != http.StatusOK {
t.Fatalf("expected 200 in dormant mode, got %d", w.Code)
}
if len(audit.calls) != 0 {
t.Fatalf("dormant mode must not fire audit, got %d calls", len(audit.calls))
}
}
func TestRequireOwnershipOrRole_RejectFiresAuditHook(t *testing.T) {
// Mirror of TestRequireRole_RejectFiresAuditHook but for the
// ownership variant — the audit hook lives at a different reject
// site and must NOT skip the durable write.
audit := &stubDenyAudit{}
lookup := func(_ *gin.Context) (string, error) {
return "someone-else", nil // creator != caller, role too low
}
w := auditableHarness(t, types.TenantRoleContributor, "u1", 7, audit,
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
if len(audit.calls) != 1 {
t.Fatalf("expected exactly one audit hook call, got %d", len(audit.calls))
}
}
func TestRequireRole_NilAuditServiceDoesNotPanic(t *testing.T) {
// AuditServiceProvider(nil) is a deliberate no-op; the rbac path
// must degrade to "log to stderr only" rather than crashing.
w := auditableHarness(t, types.TenantRoleContributor, "u1", 7, nil,
RequireRole(types.TenantRoleAdmin, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 even with nil audit service, got %d", w.Code)
}
}
+342
View File
@@ -0,0 +1,342 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
// rbacTestHarness builds a tiny gin engine with the RBAC middleware in
// front of a no-op handler. It seeds context just like the real auth
// middleware would, so RequireRole / RequireOwnershipOrRole see the
// expected TenantRole and UserID.
//
// Returning the recorder rather than asserting inline keeps each test
// case focused on the (input -> status) pair it cares about.
func rbacTestHarness(role types.TenantRole, userID string, mw gin.HandlerFunc) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
// Mirror what middleware/auth.go's JWT path sets.
ctx := context.WithValue(c.Request.Context(), types.TenantRoleContextKey, role)
ctx = context.WithValue(ctx, types.UserIDContextKey, userID)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
r.GET("/protected", mw, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
r.ServeHTTP(w, req)
return w
}
func cfgRBAC(enabled bool) *config.Config {
return &config.Config{Tenant: &config.TenantConfig{EnableRBAC: &enabled}}
}
// cfgRBACWithCrossTenant returns a config with both per-tenant RBAC
// enforcement AND the cluster-wide cross-tenant access flag enabled.
// IsCrossTenantSuperuser requires BOTH to honour the User attribute,
// so cross-tenant superuser tests need this rather than plain cfgRBAC.
func cfgRBACWithCrossTenant(enabled bool) *config.Config {
return &config.Config{Tenant: &config.TenantConfig{
EnableRBAC: &enabled,
EnableCrossTenantAccess: true,
}}
}
// ---------- RequireRole ----------
func TestRequireRole_AllowsAtMin(t *testing.T) {
w := rbacTestHarness(types.TenantRoleAdmin, "u1",
RequireRole(types.TenantRoleAdmin, cfgRBAC(true)))
if w.Code != http.StatusOK {
t.Fatalf("Admin should clear Admin gate, got %d", w.Code)
}
}
func TestRequireRole_AllowsAboveMin(t *testing.T) {
w := rbacTestHarness(types.TenantRoleOwner, "u1",
RequireRole(types.TenantRoleAdmin, cfgRBAC(true)))
if w.Code != http.StatusOK {
t.Fatalf("Owner should clear Admin gate, got %d", w.Code)
}
}
func TestRequireRole_RejectsBelowMin(t *testing.T) {
w := rbacTestHarness(types.TenantRoleContributor, "u1",
RequireRole(types.TenantRoleAdmin, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("Contributor must NOT clear Admin gate, got %d", w.Code)
}
}
func TestRequireRoleOrSystemAdmin_AllowsSystemAdminBelowTenantRole(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantRoleContextKey, types.TenantRoleViewer)
ctx = context.WithValue(ctx, types.SystemAdminContextKey, true)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
router.GET("/protected",
RequireRoleOrSystemAdmin(types.TenantRoleAdmin, cfgRBAC(true)),
func(c *gin.Context) { c.Status(http.StatusOK) },
)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/protected", nil))
assert.Equal(t, http.StatusOK, w.Code)
}
func TestRequireRoleOrSystemAdmin_RejectsOrdinaryViewer(t *testing.T) {
w := rbacTestHarness(types.TenantRoleViewer, "u1",
RequireRoleOrSystemAdmin(types.TenantRoleAdmin, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("ordinary Viewer must not clear Admin-or-SystemAdmin gate, got %d", w.Code)
}
}
func TestRequireRole_FailOpenWhenRBACDisabled(t *testing.T) {
// EnableRBAC=false: the middleware should log but not block, so the
// downstream handler still runs. This is the rollout-safety guarantee.
w := rbacTestHarness(types.TenantRoleViewer, "u1",
RequireRole(types.TenantRoleOwner, cfgRBAC(false)))
if w.Code != http.StatusOK {
t.Fatalf("EnableRBAC=false must let Viewer through Owner gate, got %d", w.Code)
}
}
func TestRequireRole_NilConfigFailsOpen(t *testing.T) {
// Defensive: nil config must not panic and must fail open (no enforcement
// configured = behave like the legacy path).
w := rbacTestHarness(types.TenantRoleViewer, "u1",
RequireRole(types.TenantRoleAdmin, nil))
if w.Code != http.StatusOK {
t.Fatalf("nil config must fail open, got %d", w.Code)
}
}
func TestRequireRole_CrossTenantSuperuserBypass(t *testing.T) {
// Org-level superusers (User.CanAccessAllTenants) bypass tenant role
// gates — see auth.go's resolveTenantRole, which gives them a
// transient Admin in foreign tenants. RequireRole has to honour the
// same bypass for Owner-only gates, otherwise a superuser would be
// locked out of DELETE /tenants/:id once enforcement turns on.
//
// Pinned as a regression test: if anyone reorders the fast paths so
// the superuser check ends up after the enforcement branch, this
// test fails before the change ships.
router := gin.New()
router.Use(func(c *gin.Context) {
ctx := c.Request.Context()
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
ctx = context.WithValue(ctx, types.UserIDContextKey, "su1")
ctx = context.WithValue(ctx, types.UserContextKey, &types.User{
ID: "su1", CanAccessAllTenants: true,
})
c.Request = c.Request.WithContext(ctx)
c.Next()
})
router.GET("/protected",
RequireRole(types.TenantRoleOwner, cfgRBACWithCrossTenant(true)),
func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) },
)
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("superuser must bypass Owner role gate, got %d", w.Code)
}
}
// ---------- RequireOwnershipOrRole ----------
func TestRequireOwnershipOrRole_AdminBypassesLookup(t *testing.T) {
// Admin / Owner clear the role gate without touching the lookup,
// so an erroring lookup still passes when the caller has the role.
called := false
lookup := func(c *gin.Context) (string, error) {
called = true
return "", errors.New("must not be called")
}
w := rbacTestHarness(types.TenantRoleAdmin, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)))
if w.Code != http.StatusOK {
t.Fatalf("Admin should pass without lookup, got %d", w.Code)
}
if called {
t.Fatalf("lookup must not run when role already meets min")
}
}
func TestRequireOwnershipOrRole_CreatorAllowed(t *testing.T) {
lookup := func(c *gin.Context) (string, error) { return "u1", nil }
w := rbacTestHarness(types.TenantRoleContributor, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)))
if w.Code != http.StatusOK {
t.Fatalf("creator must clear ownership gate, got %d", w.Code)
}
}
func TestRequireOwnershipOrRole_NonCreatorContributorRejected(t *testing.T) {
// Contributor editing someone else's resource is the exact case the
// matrix targets: only the original creator OR Admin+ may proceed.
lookup := func(c *gin.Context) (string, error) { return "someone-else", nil }
w := rbacTestHarness(types.TenantRoleContributor, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("non-creator Contributor must hit 403, got %d", w.Code)
}
}
func TestRequireOwnershipOrRole_LegacyEmptyCreatorTreatedAsTenantOwned(t *testing.T) {
// Pre-migration rows (or rows the backfill couldn't resolve) carry
// creator_id = "". Per the contract those are tenant-owned: only the
// role check decides.
lookup := func(c *gin.Context) (string, error) { return "", nil }
// Contributor on a tenant-owned row -> rejected, only Admin+ can mutate.
w := rbacTestHarness(types.TenantRoleContributor, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)))
if w.Code != http.StatusForbidden {
t.Fatalf("Contributor on legacy tenant-owned row should hit 403, got %d", w.Code)
}
}
func TestRequireOwnershipOrRole_LookupErrorReturns503(t *testing.T) {
// A transient lookup error surfaces as 503 (not 403) so monitoring
// and clients can tell "your permission was denied" from "the server
// briefly couldn't verify ownership". Failing open here would mean
// any DB hiccup on the creator query becomes a free pass.
lookup := func(c *gin.Context) (string, error) { return "", errors.New("boom") }
w := rbacTestHarness(types.TenantRoleContributor, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("lookup error must surface as 503, got %d", w.Code)
}
}
func TestRequireOwnershipOrRole_NotFoundPassesThroughTo404(t *testing.T) {
// When the lookup signals "no such resource visible to this tenant",
// the middleware MUST NOT mask it as 403. The handler downstream
// gets to decide the right status (usually 404), which keeps client
// error handling honest and avoids hiding "wrong URL" behind a
// permissions error.
called := false
lookup := func(c *gin.Context) (string, error) {
return "", ErrResourceNotFound
}
router := gin.New()
router.Use(func(c *gin.Context) {
ctx := c.Request.Context()
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleContributor)
ctx = context.WithValue(ctx, types.UserIDContextKey, "u1")
c.Request = c.Request.WithContext(ctx)
c.Next()
})
router.GET("/protected",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(true)),
func(c *gin.Context) {
called = true
c.JSON(http.StatusNotFound, gin.H{"error": "kb not found"})
},
)
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if !called {
t.Fatalf("handler should have been invoked so it can produce 404")
}
if w.Code != http.StatusNotFound {
t.Fatalf("expected handler 404 to win, got %d", w.Code)
}
}
func TestRequireOwnershipOrRole_SkipsLookupWhenRBACDisabled(t *testing.T) {
// H1 regression: when enforcement is off, the lookup must not run at
// all. Hooking up RBAC pre-rollout used to add a hidden DB roundtrip
// to every mutating request even though the result was thrown away.
calls := 0
lookup := func(c *gin.Context) (string, error) {
calls++
return "someone-else", nil
}
w := rbacTestHarness(types.TenantRoleViewer, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(false)))
if w.Code != http.StatusOK {
t.Fatalf("fail-open should let the request through, got %d", w.Code)
}
if calls != 0 {
t.Fatalf("lookup must not run when EnableRBAC=false (got %d calls)", calls)
}
}
func TestRequireOwnershipOrRole_CrossTenantSuperuserBypass(t *testing.T) {
// Cross-tenant superusers resolve to Admin in foreign tenants (see
// resolveTenantRole). For Owner-only gates we additionally let them
// through to preserve the pre-RBAC ability to administer any tenant.
calls := 0
lookup := func(c *gin.Context) (string, error) {
calls++
return "", nil
}
router := gin.New()
router.Use(func(c *gin.Context) {
ctx := c.Request.Context()
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
ctx = context.WithValue(ctx, types.UserIDContextKey, "su1")
ctx = context.WithValue(ctx, types.UserContextKey, &types.User{
ID: "su1", CanAccessAllTenants: true,
})
c.Request = c.Request.WithContext(ctx)
c.Next()
})
router.GET("/protected",
RequireOwnershipOrRole(types.TenantRoleOwner, lookup, cfgRBACWithCrossTenant(true)),
func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) },
)
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("superuser must bypass Owner gate, got %d", w.Code)
}
if calls != 0 {
t.Fatalf("superuser bypass must skip lookup, got %d", calls)
}
}
func TestRequireOwnershipOrRole_FailOpenWhenRBACDisabled(t *testing.T) {
// Enforcement off: even a failing lookup + non-creator + low role lets
// the request through. This preserves today's "anyone in the tenant
// can edit anything" behaviour while we ship the schema.
lookup := func(c *gin.Context) (string, error) { return "someone-else", nil }
w := rbacTestHarness(types.TenantRoleViewer, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(false)))
if w.Code != http.StatusOK {
t.Fatalf("EnableRBAC=false must let Viewer non-creator through, got %d", w.Code)
}
}
func TestRequireOwnershipOrRole_FailOpenOnLookupErrorWhenRBACDisabled(t *testing.T) {
// Lookup errors in fail-open mode also let the request through —
// otherwise turning RBAC off wouldn't actually unblock anything that
// needs the lookup.
lookup := func(c *gin.Context) (string, error) { return "", errors.New("boom") }
w := rbacTestHarness(types.TenantRoleViewer, "u1",
RequireOwnershipOrRole(types.TenantRoleAdmin, lookup, cfgRBAC(false)))
if w.Code != http.StatusOK {
t.Fatalf("EnableRBAC=false + lookup error must fail open, got %d", w.Code)
}
}
+39
View File
@@ -0,0 +1,39 @@
package middleware
import (
"fmt"
"runtime/debug"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// Recovery is a middleware that recovers from panics
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Get request ID from context
ctx := c.Request.Context()
requestID, _ := c.Get("RequestID")
// Print stacktrace
stacktrace := debug.Stack()
// Log error with structured logger
logger.ErrorWithFields(ctx, fmt.Errorf("panic: %v", err), logrus.Fields{
"request_id": requestID,
"stacktrace": string(stacktrace),
})
// 返回500错误
c.AbortWithStatusJSON(500, gin.H{
"error": "Internal Server Error",
"message": fmt.Sprintf("%v", err),
})
}
}()
c.Next()
}
}