微服务架构下,硬编码在各个服务内部的访问控制逻辑是一场维护噩梦。每次权限模型的微调都可能触发多个服务的重新部署。将授权逻辑上移至API网关是标准解法,但Tyk自带的JWT声明校验、Scope控制等机制,在面对复杂多变的业务规则时,往往显得力不从心。例如,“只允许‘经理’角色的用户在工作日的9点到18点之间,访问特定客户名下的‘财务’资源”,这种策略用简单的Scope组合几乎无法表达。
最初的构想是在Tyk网关层面开发一个自定义的Go中间件插件,通过RPC调用一个独立的授权中心服务。这个方案解耦了逻辑,但引入了新的网络延迟和单点故障风险。每一次API请求都需要额外进行一次网络调用来完成授权,这对性能敏感的端点是不可接受的。我们需要一个既能集中管理、又能动态更新,同时还具备高性能本地执行能力的方案。
最终的决策是:设计一种领域特定语言(DSL)来描述访问策略,并用原生Go语言为Tyk编写一个插件来解析和执行这些策略。策略本身作为文本存储在Redis中,可以被动态更新。同时,我们构建一个基于Svelte和Pinia的轻量级管理前端,供运维或安全团队实时编辑、测试和部署这些策略,无需触碰任何网关或后端代码。
技术选型决策
- API网关 - Tyk: 选择Tyk主要看中其开源、高性能以及对Go语言插件的良好支持。用Go编写原生插件意味着没有跨语言调用的性能损耗,可以直接在Tyk的进程空间内执行逻辑,这对延迟至关重要。
- 插件编程语言 - Go: 这是Tyk官方推荐且性能最好的选择。静态类型和强大的标准库也让开发复杂的解析和执行逻辑更为可靠。
- 策略管理前端 - Svelte & Pinia: 我们需要一个快速响应的管理界面。Svelte的编译时优化特性使其运行时非常轻量,几乎没有框架开销,非常适合构建这种工具型应用。Pinia作为状态管理库,其API设计简洁直观,对于管理策略文本、加载状态、错误信息等全局状态非常方便。
- 策略存储 - Redis: 策略的更新频率不高,但读取频率极高(每次API请求都要读)。Redis基于内存的特性提供了极低的读取延迟,其Pub/Sub机制也为未来实现策略的实时推送更新留下了扩展空间。
架构概览
整个系统的协同工作流程可以通过下面的图表来描述:
sequenceDiagram participant Admin as 管理员 participant SvelteUI as Svelte管理前端 participant MgmtAPI as 管理API (Go) participant Redis participant Client as 客户端 participant TykGateway as Tyk网关 (含Go插件) participant Upstream as 上游服务 Admin->>SvelteUI: 编写/更新访问策略DSL SvelteUI->>MgmtAPI: 发送保存请求 (携带策略文本) MgmtAPI->>Redis: SET policy:[api_id] "[DSL_text]" Redis-->>MgmtAPI: OK MgmtAPI-->>SvelteUI: 保存成功 Note right of Client: 稍后, 客户端发起业务请求 Client->>TykGateway: GET /users/123/profile TykGateway->>TykGateway: 执行自定义Go插件 Note over TykGateway: 插件从请求上下文中获取API ID TykGateway->>Redis: GET policy:api-users Redis-->>TykGateway: 返回DSL策略文本 TykGateway->>TykGateway: 解析并执行DSL, 对比请求上下文 alt 策略评估通过 TykGateway->>Upstream: 代理请求 Upstream-->>TykGateway: 返回响应 TykGateway-->>Client: 返回响应 else 策略评估失败 TykGateway-->>Client: 返回 403 Forbidden end
第一步:设计与实现策略DSL
我们的DSL需要足够简单,让非开发人员也能理解,同时也要具备足够的表达能力。经过几次迭代,我们确定了以下格式,由ALLOW
或DENY
关键字、一个IF
条件以及多个用AND
或OR
连接的规则组成。
一个策略示例如下:
ALLOW IF token.claims.role IN ["admin"] OR (request.method == "GET" AND request.path MATCHES "^/public/.*")
这条策略的含义是:如果JWT令牌中的role
声明是admin
,则允许访问;或者,如果请求方法是GET
且路径匹配/public/
开头的模式,也允许访问。
第二步:核心实现 - Tyk的Go授权插件
这是整个系统的核心。我们需要编写一个Go模块,它将被编译为.so
文件并由Tyk加载。
1. 项目结构与依赖
首先,初始化Go模块并获取Tyk的插件开发SDK。
mkdir tyk-dsl-auth && cd tyk-dsl-auth
go mod init github.com/your-org/tyk-dsl-auth
go get github.com/TykTechnologies/tyk-plugin-go-demo/plugin
go get github.com/go-redis/redis/v8
2. 策略解析与评估器 policy_evaluator.go
这是最复杂的部分。为了避免引入庞大的解析器生成器库(如ANTLR),在项目初期,我们采用了一个基于正则表达式和字符串分割的简易解析器。在真实项目中,当DSL变得更复杂时,迁移到真正的词法/语法分析器是必要的步骤。
// file: policy_evaluator.go
package main
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/TykTechnologies/tyk/log"
"github.com/go-redis/redis/v8"
)
var (
redisClient *redis.Client
ctx = context.Background()
// 正则表达式用于解析简单的规则, 例如: token.claims.role IN ["admin", "viewer"]
ruleRegex = regexp.MustCompile(`([\w\.]+) (==|!=|IN|NOT IN|MATCHES) (\[.*?\]|".*?"|\d+)`)
)
// PolicyEvaluator 负责解析和评估DSL策略
type PolicyEvaluator struct {
policy string
}
func NewPolicyEvaluator(policy string) *PolicyEvaluator {
return &PolicyEvaluator{policy: policy}
}
// Evaluate 根据请求上下文评估策略
// requestContext 包含了请求方法、路径、头部、JWT声明等信息
func (p *PolicyEvaluator) Evaluate(requestContext map[string]interface{}) (bool, error) {
// 简化处理:目前只支持 ALLOW IF ...
if !strings.HasPrefix(p.policy, "ALLOW IF ") {
return false, fmt.Errorf("invalid policy format: must start with 'ALLOW IF '")
}
conditionStr := strings.TrimPrefix(p.policy, "ALLOW IF ")
// 在真实项目中,这里需要一个完整的逻辑表达式求值器。
// 为简化演示,我们仅处理 AND 和 OR,并且不支持括号嵌套的优先级。
// 实际生产中,这里的坑在于逻辑运算的优先级和短路求值。
if strings.Contains(conditionStr, " OR ") {
parts := strings.Split(conditionStr, " OR ")
for _, part := range parts {
// 实现 OR 的短路逻辑:任何一个子条件为真,则整体为真
result, err := evaluateAndConditions(part, requestContext)
if err != nil {
log.Errorf("Error evaluating OR part: %v", err)
continue // 如果一个子条件出错,跳过它,继续检查下一个
}
if result {
return true, nil
}
}
return false, nil // 所有 OR 条件都为假
}
return evaluateAndConditions(conditionStr, requestContext)
}
// evaluateAndConditions 负责处理由 AND 连接的多个规则
func evaluateAndConditions(conditionStr string, requestContext map[string]interface{}) (bool, error) {
parts := strings.Split(conditionStr, " AND ")
for _, part := range parts {
// 实现 AND 的短路逻辑:任何一个子条件为假,则整体为假
result, err := evaluateSingleRule(strings.TrimSpace(part), requestContext)
if err != nil {
return false, err // 如果单个规则评估出错,立即返回错误
}
if !result {
return false, nil
}
}
return true, nil // 所有 AND 条件都为真
}
// evaluateSingleRule 评估单个规则,如 `request.path MATCHES "^/api/v1/.*"`
func evaluateSingleRule(ruleStr string, requestContext map[string]interface{}) (bool, error) {
matches := ruleRegex.FindStringSubmatch(ruleStr)
if len(matches) != 4 {
return false, fmt.Errorf("malformed rule: %s", ruleStr)
}
field, operator, valueStr := matches[1], matches[2], matches[3]
// 从请求上下文中获取实际值
actualValue, found := getValueFromContext(field, requestContext)
if !found {
// 如果策略要求的字段在请求中不存在,默认评估为false
log.Warnf("Field '%s' not found in request context", field)
return false, nil
}
actualValueStr := fmt.Sprintf("%v", actualValue)
switch operator {
case "==":
return actualValueStr == strings.Trim(valueStr, `"`), nil
case "!=":
return actualValueStr != strings.Trim(valueStr, `"`), nil
case "IN":
listStr := strings.Trim(valueStr, "[]")
items := strings.Split(listStr, ",")
for _, item := range items {
if actualValueStr == strings.Trim(strings.TrimSpace(item), `"`) {
return true, nil
}
}
return false, nil
case "MATCHES":
regexStr := strings.Trim(valueStr, `"`)
matched, err := regexp.MatchString(regexStr, actualValueStr)
if err != nil {
return false, fmt.Errorf("invalid regex in rule '%s': %w", ruleStr, err)
}
return matched, nil
default:
return false, fmt.Errorf("unsupported operator: %s", operator)
}
}
// getValueFromContext 是一个辅助函数,用于从嵌套的map中安全地获取值
// 例如 field = "token.claims.role"
func getValueFromContext(field string, context map[string]interface{}) (interface{}, bool) {
parts := strings.Split(field, ".")
var current interface{} = context
for _, part := range parts {
currentMap, ok := current.(map[string]interface{})
if !ok {
return nil, false
}
current, ok = currentMap[part]
if !ok {
return nil, false
}
}
return current, true
}
设计考量: 这个简易解析器是项目的技术债起点。它无法处理带括号的复杂逻辑
(A AND B) OR C
。生产级实现应该使用go/parser
或第三方库来构建一个真正的抽象语法树(AST),然后在AST上进行求值。但对于启动项目和验证想法,这足够了。
3. Tyk插件入口与中间件逻辑 main.go
这是插件的主文件,定义了Tyk加载时会调用的钩子函数。
// file: main.go
package main
import (
"fmt"
"net/http"
"os"
goplugin "github.com/TykTechnologies/tyk-plugin-go-demo/plugin"
"github.com/TykTechnologies/tyk/log"
"github.com/TykTechnologies/tyk/user"
"github.com/go-redis/redis/v8"
)
func init() {
// Tyk加载插件时会调用init函数,这是建立Redis连接的最佳时机
redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379" // 提供一个默认值
}
redisClient = redis.NewClient(&redis.Options{
Addr: redisAddr,
})
// 测试连接
if _, err := redisClient.Ping(ctx).Result(); err != nil {
log.Fatalf("Failed to connect to Redis: %v", err)
}
log.Info("Successfully connected to Redis for DSL Auth Plugin")
}
// AuthCheck 是我们实现的核心中间件函数
func AuthCheck(rw http.ResponseWriter, r *http.Request) {
// 从Tyk注入的请求上下文中获取API定义信息
apiDef := goplugin.GetAPI(r)
if apiDef == nil {
log.Error("Could not get API Definition from context")
rw.WriteHeader(http.StatusInternalServerError)
return
}
// 构造用于存储策略的Redis键
policyKey := fmt.Sprintf("policy:%s", apiDef.APIID)
// 从Redis获取策略
policy, err := redisClient.Get(ctx, policyKey).Result()
if err == redis.Nil {
// 一个常见的错误是未配置策略时直接拒绝请求。
// 更稳健的做法是默认放行,并记录警告,除非有明确的安全要求。
log.Warnf("No policy found for key '%s', allowing request by default.", policyKey)
return // 放行
} else if err != nil {
log.Errorf("Error fetching policy from Redis: %v", err)
rw.WriteHeader(http.StatusServiceUnavailable) // Redis故障,返回503
return
}
// 构建请求上下文,供策略评估器使用
requestContext := buildRequestContext(r, goplugin.GetSession(r))
evaluator := NewPolicyEvaluator(policy)
allowed, err := evaluator.Evaluate(requestContext)
if err != nil {
log.Errorf("Policy evaluation error for API %s: %v", apiDef.APIID, err)
rw.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(rw, "Policy evaluation error")
return
}
if !allowed {
log.Infof("Request denied by DSL policy for API %s. Path: %s", apiDef.APIID, r.URL.Path)
rw.WriteHeader(http.StatusForbidden)
fmt.Fprintf(rw, "Access denied by policy.")
// 阻止Tyk继续处理请求
goplugin.CloseRequest(r)
return
}
// 授权通过,请求会继续流转到上游服务
log.Debugf("Request allowed by DSL policy for API %s", apiDef.APIID)
}
// buildRequestContext 将请求信息和JWT会话信息组装成一个map
func buildRequestContext(r *http.Request, session *user.SessionState) map[string]interface{} {
context := make(map[string]interface{})
// 请求相关信息
requestData := map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"host": r.Host,
}
// 在真实项目中,这里还应该包括查询参数、头部信息等
context["request"] = requestData
// JWT令牌相关信息
// Tyk会自动解析JWT并将其内容放入SessionState
if session != nil && session.MetaData["jwt_claims"] != nil {
// 注意类型断言的安全性
if claims, ok := session.MetaData["jwt_claims"].(map[string]interface{}); ok {
tokenData := map[string]interface{}{
"claims": claims,
}
context["token"] = tokenData
}
}
return context
}
func main() {}
4. 编译与部署
编译Go插件为共享库,并更新Tyk的API定义来加载它。
# 编译插件
go build -buildmode=plugin -o tyk-dsl-auth.so .
# 移动到Tyk的中间件目录
# sudo mv tyk-dsl-auth.so /opt/tyk-gateway/middleware/
在Tyk的API定义JSON文件中,添加custom_middleware
部分:
{
"name": "My API with DSL Auth",
"api_id": "api-users",
"use_keyless": false,
"auth": {
"auth_header_name": "Authorization"
},
"custom_middleware": {
"pre": [
{
"name": "AuthCheck",
"path": "middleware/tyk-dsl-auth.so",
"require_session": true
}
],
"driver": "goplugin"
},
// ... 其他配置
}
"require_session": true
告诉Tyk在执行我们的插件之前,必须先完成JWT的校验和解析,这样我们才能在插件中拿到SessionState
。
第三步:Svelte和Pinia驱动的管理前端
管理前端的核心是一个策略编辑器。用户选择一个API,查看或编辑其当前的DSL策略,然后保存。
1. Pinia状态管理 (stores/policyStore.js
)
我们用Pinia来管理当前编辑的API ID、策略内容、加载状态和错误信息。
// file: stores/policyStore.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
// 这是一个模拟的API客户端
const apiClient = {
async getPolicy(apiId) {
// 生产环境中,这里应是真实的fetch调用
const response = await fetch(`/api/policies/${apiId}`);
if (!response.ok) {
if(response.status === 404) return { policy: '' }; // 未找到策略,返回空字符串
throw new Error(`Failed to fetch policy: ${response.statusText}`);
}
return response.json();
},
async savePolicy(apiId, policy) {
const response = await fetch(`/api/policies/${apiId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ policy }),
});
if (!response.ok) {
throw new Error(`Failed to save policy: ${response.statusText}`);
}
}
};
export const usePolicyStore = defineStore('policy', () => {
const currentApiId = ref(null);
const policyContent = ref('');
const isLoading = ref(false);
const error = ref(null);
const successMessage = ref('');
async function fetchPolicy(apiId) {
if (!apiId) return;
currentApiId.value = apiId;
isLoading.value = true;
error.value = null;
successMessage.value = '';
try {
const data = await apiClient.getPolicy(apiId);
policyContent.value = data.policy;
} catch (e) {
error.value = e.message;
policyContent.value = '// Failed to load policy.';
} finally {
isLoading.value = false;
}
}
async function saveCurrentPolicy() {
if (!currentApiId.value) return;
isLoading.value = true;
error.value = null;
successMessage.value = '';
try {
await apiClient.savePolicy(currentApiId.value, policyContent.value);
successMessage.value = `Policy for ${currentApiId.value} saved successfully!`;
} catch (e) {
error.value = e.message;
} finally {
isLoading.value = false;
}
}
// 用于在组件中更新策略内容
function updatePolicyContent(content) {
policyContent.value = content;
}
return {
currentApiId,
policyContent,
isLoading,
error,
successMessage,
fetchPolicy,
saveCurrentPolicy,
updatePolicyContent
};
});
2. Svelte编辑器组件 (components/PolicyEditor.svelte
)
这个组件负责UI展示,包括一个文本域用于编辑策略,以及加载、保存按钮。它通过usePolicyStore
与状态层交互。
<!-- file: components/PolicyEditor.svelte -->
<script>
import { usePolicyStore } from '../stores/policyStore.js';
const policyStore = usePolicyStore();
// 模拟API列表
const apis = [
{ id: 'api-users', name: 'User Service API' },
{ id: 'api-billing', name: 'Billing Service API' },
{ id: 'api-reports', name: 'Reporting Service API' },
];
let selectedApi = apis[0].id;
// 当选择的API变化时,触发数据获取
$: policyStore.fetchPolicy(selectedApi);
</script>
<div class="editor-container">
<h2>Dynamic Policy Editor</h2>
<div class="api-selector">
<label for="api-select">Select API:</label>
<select id="api-select" bind:value={selectedApi}>
{#each apis as api}
<option value={api.id}>{api.name} ({api.id})</option>
{/each}
</select>
</div>
{#if policyStore.isLoading}
<p>Loading policy...</p>
{:else if policyStore.error}
<div class="message error">Error: {policyStore.error}</div>
{/if}
<textarea
class="policy-textarea"
placeholder="Enter DSL policy here. e.g., ALLOW IF request.method == 'GET'"
value={policyStore.policyContent}
on:input={(e) => policyStore.updatePolicyContent(e.target.value)}
disabled={policyStore.isLoading}
></textarea>
<button on:click={policyStore.saveCurrentPolicy} disabled={policyStore.isLoading}>
{policyStore.isLoading ? 'Saving...' : 'Save and Deploy Policy'}
</button>
{#if policyStore.successMessage}
<div class="message success">{policyStore.successMessage}</div>
{/if}
</div>
<style>
.editor-container { max-width: 800px; margin: 2rem auto; font-family: sans-serif; }
.api-selector { margin-bottom: 1rem; }
.policy-textarea { width: 100%; height: 200px; font-family: monospace; font-size: 1rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 0.75rem 1.5rem; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-top: 1rem; }
button:disabled { background-color: #ccc; }
.message { padding: 1rem; margin-top: 1rem; border-radius: 4px; }
.error { border: 1px solid #d9534f; background-color: #f2dede; color: #a94442; }
.success { border: 1px solid #5cb85c; background-color: #dff0d8; color: #3c763d; }
</style>
这个Svelte组件完全由Pinia store驱动。UI上的任何交互(选择API、编辑文本、点击保存)都只是调用store中的action。Store负责处理所有异步逻辑和状态变更,Svelte则 благодаря своей реактивности автоматически обновляет DOM。这种分离使得组件本身非常简洁,逻辑清晰。
局限性与未来迭代路径
这个方案虽然解决了最初的问题,但在生产环境中仍有几个需要完善的点:
- DSL解析器的鲁棒性: 正如前面提到的,基于正则的解析器非常脆弱。下一步是引入一个成熟的Go解析器库(如
goyacc
或participle
)来构建一个能够处理复杂逻辑、括号优先级和提供更友好错误提示的解析器。 - 策略测试与模拟: 目前,保存策略即意味着部署。一个关键的缺失功能是在管理前端提供一个“测试”环境,允许管理员输入模拟的请求上下文(如JWT payload、请求路径),并立即看到策略的评估结果,以防止错误的策略影响线上流量。
- 性能与缓存: 当前每次请求都需要访问Redis。虽然Redis很快,但在极高的QPS下,这仍然是开销。可以在Go插件内部实现一个内存缓存(如使用
ristretto
),缓存策略的文本甚至解析后的AST,并利用Redis的Pub/Sub来接收策略变更通知,从而实现缓存的精准失效。 - 可观测性: Go插件需要输出更详细的Metrics,例如策略评估的平均耗时、被拒绝的请求数量、因特定规则被拒绝的计数等。这些指标对于监控策略引擎的健康状况和性能至关重要。