test(api-keys): add unit tests for tenant API key validation

Introduce unit tests for the tenant API key validation logic, ensuring that scoped keys require capabilities, while full-access keys are validated without them. The tests cover various scenarios, reinforcing the expected behavior of the API key validation function.
This commit is contained in:
wizardchen
2026-07-07 11:40:47 +08:00
committed by lyingbug
parent 2c6f082062
commit d60614475e
12 changed files with 149 additions and 14 deletions
+17 -2
View File
@@ -11,9 +11,24 @@ func RoleFromContext(ctx context.Context) types.TenantRole {
return types.TenantRoleFromContext(ctx)
}
// CanViewIntegrationSecrets is true for Admin+ (includes Owner).
// CanViewIntegrationSecrets is true for Admin+ tenant members and for API keys
// with full tenant access or the manage_tenant_settings capability.
func CanViewIntegrationSecrets(ctx context.Context) bool {
return RoleFromContext(ctx).HasPermission(types.TenantRoleAdmin)
if RoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
return true
}
return apiKeyCanManageIntegrationSecrets(ctx)
}
func apiKeyCanManageIntegrationSecrets(ctx context.Context) bool {
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
if !ok {
return false
}
if scope.FullAccess {
return true
}
return scope.HasCapability(types.APIKeyCapabilityManageTenantSettings)
}
// RoleCanViewTenantAPIKey is true for Owner+ only.
+42
View File
@@ -0,0 +1,42 @@
package dto
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestCanViewIntegrationSecretsAdminRole(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleAdmin)
if !CanViewIntegrationSecrets(ctx) {
t.Fatal("admin should view integration secrets")
}
}
func TestCanViewIntegrationSecretsViewerDenied(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleViewer)
if CanViewIntegrationSecrets(ctx) {
t.Fatal("viewer should not view integration secrets")
}
}
func TestCanViewIntegrationSecretsScopedAPIKeyWithManageTenantSettings(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageTenantSettings)},
})
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if !CanViewIntegrationSecrets(ctx) {
t.Fatal("manage_tenant_settings API key should view integration secrets")
}
}
func TestCanViewIntegrationSecretsScopedAPIKeyWithoutCapabilityDenied(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
})
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if CanViewIntegrationSecrets(ctx) {
t.Fatal("chat-only API key should not view integration secrets")
}
}
+12
View File
@@ -656,6 +656,10 @@ func validateTenantAPIKeyRequest(
if req.FullAccess {
return nil
}
caps := types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities))
if len(caps) == 0 {
return errors.NewValidationError("capabilities are required for scoped API keys")
}
for _, cap := range req.Capabilities {
if strings.TrimSpace(cap) == "" {
continue
@@ -1170,6 +1174,14 @@ func (h *TenantHandler) UpdateTenantKV(c *gin.Context) {
ctx := c.Request.Context()
key := secutils.SanitizeForLog(c.Param("key"))
switch key {
case "web-search-config", "parser-engine-config", "storage-engine-config":
if !dto.CanViewIntegrationSecrets(ctx) {
c.Error(errors.NewForbiddenError("integration configuration requires admin access"))
return
}
}
switch key {
case "web-search-config":
h.updateTenantWebSearchConfigInternal(c)
@@ -0,0 +1,35 @@
package handler
import (
"context"
"testing"
)
func TestValidateTenantAPIKeyRequestRequiresCapabilitiesForScopedKey(t *testing.T) {
err := validateTenantAPIKeyRequest(context.Background(), nil, 1, tenantAPIKeyCreateRequest{
Name: "integration",
FullAccess: false,
})
if err == nil {
t.Fatal("expected validation error for scoped key without capabilities")
}
}
func TestValidateTenantAPIKeyRequestAllowsFullAccessWithoutCapabilities(t *testing.T) {
if err := validateTenantAPIKeyRequest(context.Background(), nil, 1, tenantAPIKeyCreateRequest{
Name: "owner",
FullAccess: true,
}); err != nil {
t.Fatalf("full-access key validation error = %v", err)
}
}
func TestValidateTenantAPIKeyRequestAcceptsScopedKeyWithCapability(t *testing.T) {
if err := validateTenantAPIKeyRequest(context.Background(), nil, 1, tenantAPIKeyCreateRequest{
Name: "chat",
FullAccess: false,
Capabilities: []string{"chat"},
}); err != nil {
t.Fatalf("scoped key validation error = %v", err)
}
}
+1 -1
View File
@@ -258,7 +258,7 @@ func NewRouter(params RouterParams) *gin.Engine {
// has not yet expired are kept out by the role check, matching the
// rest of the RBAC matrix in this file.
func RegisterChunkerDebugRoutes(r *gin.RouterGroup, g *rbacGuards) {
g.apiKeyRoute(r, http.MethodPost, "/chunker/preview", apiKeyAny(), g.Viewer(), handler.PreviewChunking)
g.apiKeyRoute(r, http.MethodPost, "/chunker/preview", apiKeyRetrieve(apiKeyIngest(apiKeyFullAccess())), g.Viewer(), handler.PreviewChunking)
}
// RegisterChunkRoutes 注册分块相关的路由
@@ -293,6 +293,25 @@ func TestTenantInfrastructureRoutesDeclareSpecificCapabilities(t *testing.T) {
}
}
func TestChunkerPreviewRouteRequiresRetrieveOrIngestCapability(t *testing.T) {
gin.SetMode(gin.TestMode)
g := &rbacGuards{}
v1 := gin.New().Group("/api/v1")
RegisterChunkerDebugRoutes(v1, g)
policy := mustLookupAPIKeyPolicy(t, g, http.MethodPost, "/api/v1/chunker/preview")
if !policy.RequireFullAccess {
t.Fatal("policy should require full access without a matching capability")
}
if !policyHasCapability(policy, types.APIKeyCapabilityRetrieve) {
t.Fatalf("policy capabilities = %#v, want retrieve", policy.Capabilities)
}
if !policyHasCapability(policy, types.APIKeyCapabilityIngest) {
t.Fatalf("policy capabilities = %#v, want ingest", policy.Capabilities)
}
}
func mustLookupAPIKeyPolicy(
t *testing.T,
g *rbacGuards,
+8 -1
View File
@@ -154,12 +154,19 @@ func MCPOAuthPrincipalFromContext(ctx context.Context) Principal {
// SessionOwnerIDFromContext returns the sessions.user_id scope for the current
// caller. API external users and embed chat sessions use principal-derived IDs;
// MCP OAuth token storage uses MCPOAuthPrincipalFromContext (visitor-level for embed).
// tenant API keys are isolated per key id; MCP OAuth token storage uses
// MCPOAuthPrincipalFromContext (visitor-level for embed).
func SessionOwnerIDFromContext(ctx context.Context) string {
if p, ok := PrincipalFromContext(ctx); ok {
switch p.Type {
case PrincipalAPIExternalUser, PrincipalEmbedSession:
return p.StorageID()
case PrincipalAPITenant:
if scope, ok := TenantAPIKeyScopeFromContext(ctx); ok && scope.KeyID > 0 {
if tenantID, ok := TenantIDFromContext(ctx); ok && tenantID > 0 {
return fmt.Sprintf("api_tenant_key:%d:%d", tenantID, scope.KeyID)
}
}
}
}
userID, _ := UserIDFromContext(ctx)
+11
View File
@@ -53,6 +53,17 @@ func TestSessionOwnerIDFromContextFallsBackToUserID(t *testing.T) {
}
}
func TestSessionOwnerIDFromContextIsolatesTenantAPIKeys(t *testing.T) {
ctx := WithPrincipal(context.Background(), Principal{Type: PrincipalAPITenant, ID: "7"})
ctx = context.WithValue(ctx, TenantIDContextKey, uint64(7))
ctx = context.WithValue(ctx, UserIDContextKey, "system-7")
ctx = WithTenantAPIKeyScope(ctx, TenantAPIKeyScope{KeyID: 99})
if got := SessionOwnerIDFromContext(ctx); got != "api_tenant_key:7:99" {
t.Fatalf("SessionOwnerIDFromContext() = %q, want per-key isolation", got)
}
}
func TestSessionOwnerIDFromContextUsesEmbedSessionPrincipal(t *testing.T) {
ctx := WithPrincipal(context.Background(), EmbedSessionPrincipal(10000, "ch1", "sess1"))
ctx = context.WithValue(ctx, UserIDContextKey, "embed-ch1")
+1
View File
@@ -758,6 +758,7 @@ CREATE TABLE IF NOT EXISTS tenant_api_keys (
api_key TEXT NOT NULL DEFAULT '',
full_access BOOLEAN NOT NULL DEFAULT 0,
knowledge_base_ids TEXT NOT NULL DEFAULT '[]',
capabilities TEXT NOT NULL DEFAULT '[]',
last_used_at DATETIME,
expires_at DATETIME,
revoked_at DATETIME,
@@ -8,6 +8,9 @@ CREATE TABLE IF NOT EXISTS tenant_api_keys (
api_key TEXT NOT NULL DEFAULT '',
full_access BOOLEAN NOT NULL DEFAULT FALSE,
knowledge_base_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Bounded per-key grants for non-full-access keys. KB allow-list still
-- constrains which knowledge bases a scoped key may touch.
capabilities JSONB NOT NULL DEFAULT '[]'::jsonb,
last_used_at TIMESTAMP,
expires_at TIMESTAMP,
revoked_at TIMESTAMP,
@@ -1 +0,0 @@
ALTER TABLE tenant_api_keys DROP COLUMN IF EXISTS capabilities;
@@ -1,9 +0,0 @@
DO $$ BEGIN RAISE NOTICE '[Migration 000066] Adding tenant_api_keys.capabilities...'; END $$;
-- Additive per-key grants for non-full-access keys. Capabilities let a scoped
-- key call a bounded route family, while knowledge_base_ids still constrains
-- the KBs the key can touch where a route targets knowledge-base data.
ALTER TABLE tenant_api_keys
ADD COLUMN IF NOT EXISTS capabilities JSONB NOT NULL DEFAULT '[]'::jsonb;
DO $$ BEGIN RAISE NOTICE '[Migration 000066] tenant_api_keys.capabilities ready'; END $$;