mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user