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
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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, ©)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user