构建基于 Echo 的动态策略 IAM 中间件与 Cypress 的自动化安全契约测试


最初的需求很简单:为我们的新服务API加上权限控制。在项目初期,我们用角色(Roles)来划分权限,一个用户是“管理员”,他什么都能做;另一个是“成员”,他只能读写部分资源。这种基于角色的访问控制(RBAC)在只有两三种角色和十几个API端点时,工作得还算不错。问题出在产品迭代上,业务复杂性开始指数级增长。

我们很快遇到了这样的场景:“允许A项目的‘成员’编辑该项目下的文档,但只允许他们查看B项目的文档”,或者“只允许‘财务角色’的用户访问以/billing开头的API,并且请求来源IP必须在公司内网”。这些需求用传统的RBAC硬编码在业务逻辑里,会迅速演变成一场灾难。代码里会充斥着if user.Role == "admin" || (user.Role == "member" && resource.ProjectID == user.ProjectID)这样的补丁,难以维护,更难以审计。

痛点已经明确:我们需要一个与业务逻辑解耦的、策略驱动的、细粒度的访问控制层。这个IAM层需要能够解释“谁(Subject)可以对什么(Resource)做什么操作(Action)”这样的策略。

在技术选型上,我们评估了现成的方案,比如Open Policy Agent (OPA)和Casbin。它们都非常强大,但对于我们当前这个中等规模的项目来说,引入一个独立的策略引擎服务增加了架构的复杂性和运维成本。我们当时的核心诉uerq是快速实现一个够用的、可演进的方案。因此,我们决定自己动手,在Go的Echo框架中构建一个轻量级的策略驱动IAM中间件。这个中间件的核心是解析JWT中的身份信息,并根据预定义的策略集来决定是否放行请求。

这里的关键挑战不仅在于构建这个中间件,更在于如何确保它在不断迭代中始终正确。安全策略的任何一个微小失误都可能导致严重的数据泄露。手动测试覆盖所有权限组合是不现实的。这正是我们将Cypress引入后端测试流程的原因——用它来编写“安全契约测试”,自动化地、端到端地验证我们的IAM策略。

策略定义与JWT载荷设计

一切始于策略的结构化定义。我们将策略定义为一个简单的Go结构体,它清晰地描述了一条权限规则。

// policy.go

package iam

import (
	"regexp"
	"strings"
)

// Effect 定义了策略的效果,是允许还是拒绝。
type Effect string

const (
	Allow Effect = "allow"
	Deny  Effect = "deny"
)

// Policy 表示一条访问控制策略。
// 在真实项目中,这些策略会从数据库、配置文件或策略服务中加载。
type Policy struct {
	// SID (Statement ID) 是策略声明的可选标识符。
	SID    string   `json:"sid"`
	Effect Effect   `json:"effect"`
	// Actions 是此策略允许或拒绝的操作列表,例如 "GET", "POST", "PUT", "DELETE"。
	// 支持通配符 "*"
	Actions []string `json:"actions"`
	// Resources 是此策略应用的资源路径列表。
	// 支持路径参数 ":id" 和通配符 "*"
	// 例如: "/api/v1/projects/:project_id/documents/*"
	Resources []string `json:"resources"`
}

// resourceMatcher 是一个预编译的正则表达式,用于匹配资源路径。
type resourceMatcher struct {
	raw    string
	regex  *regexp.Regexp
}

// compileResourcePattern 将策略资源路径(如 /projects/:id)转换为正则表达式。
func compileResourcePattern(pattern string) (*regexp.Regexp, error) {
	// 将 :param 格式转换为命名的正则表达式捕获组
	pattern = regexp.MustCompile(`:\w+`).ReplaceAllString(pattern, `(?P<$0>[^/]+)`)
	// 将 * 转换为匹配任意字符(非贪婪)
	pattern = strings.ReplaceAll(pattern, "*", ".*")
	return regexp.Compile("^" + pattern + "$")
}

这个设计中,Actions对应HTTP方法,Resources对应API路径。我们支持在路径中使用通配符,这对于匹配一类API(如/projects/123/documents/下的所有子路径)至关重要。

接下来是身份凭证。我们使用JWT,并在其claims中携带用户的核心身份信息和权限边界。一个典型的JWT载荷如下:

{
  "user_id": "usr_abc123",
  "tenant_id": "ten_xyz789",
  "policies": [
    {
      "sid": "AllowDocumentReadInProjectA",
      "effect": "allow",
      "actions": ["GET"],
      "resources": ["/api/v1/projects/proj_A/documents/*"]
    },
    {
      "sid": "AllowDocumentWriteInProjectA",
      "effect": "allow",
      "actions": ["POST", "PUT", "DELETE"],
      "resources": ["/api/v1/projects/proj_A/documents/*"]
    },
    {
      "sid": "DenySettingsAccessGlobally",
      "effect": "deny",
      "actions": ["*"],
      "resources": ["/api/v1/settings/*"]
    }
  ],
  "exp": 1672531199,
  "iat": 1672527600,
  "iss": "my-auth-service"
}

将策略直接嵌入JWT有其利弊。优点是无状态,IAM中间件无需查询数据库就能获得决策所需的所有信息,性能极高。缺点是JWT的大小会随着策略数量增加而变大,并且一旦签发,策略无法实时撤销。在我们的场景中,用户的权限变更不频繁,且我们可以通过缩短JWT的有效期(例如15分钟)来缓解撤销问题,因此这个方案是可接受的。对于需要实时权限变更的系统,更好的做法是在JWT中只存放用户ID和角色ID,由中间件再去查询一个带缓存的策略服务。

Echo IAM中间件的实现

这是整个系统的核心。我们创建了一个Echo中间件,它负责在业务处理器执行前完成所有的认证和授权检查。

// middleware.go

package iam

import (
	"context"
	"fmt"
	"net/http"
	"strings"

	"github.com/golang-jwt/jwt/v4"
	"github.comcom/labstack/echo/v4"
	"github.comcom/labstack/gommon/log"
)

// UserClaims 定义了JWT中我们关心的自定义载荷。
type UserClaims struct {
	UserID   string   `json:"user_id"`
	TenantID string   `json:"tenant_id"`
	Policies []Policy `json:"policies"`
	jwt.RegisteredClaims
}

// contextKey 是一个私有类型,用于避免context key的冲突。
type contextKey string

const userContextKey = contextKey("user")

// MiddlewareConfig 定义了中间件的配置。
type MiddlewareConfig struct {
	JWTSecret string
}

// NewIAMMiddleware 创建一个新的IAM中间件实例。
func NewIAMMiddleware(config MiddlewareConfig) echo.MiddlewareFunc {
	// 预编译所有策略的资源路径为正则表达式,以提高性能
	precompilePolicies := func(policies []Policy) ([]resourceMatcher, error) {
		matchers := make([]resourceMatcher, len(policies))
		for i, p := range policies {
			for _, r := range p.Resources {
				regex, err := compileResourcePattern(r)
				if err != nil {
					return nil, fmt.Errorf("failed to compile resource '%s' for policy '%s': %w", r, p.SID, err)
				}
				matchers[i] = resourceMatcher{raw: r, regex: regex}
			}
		}
		return matchers, nil
	}


	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			authHeader := c.Request().Header.Get("Authorization")
			if authHeader == "" {
				log.Warn("Authorization header missing")
				return c.JSON(http.StatusUnauthorized, map[string]string{"error": "missing authorization header"})
			}

			tokenString := strings.TrimPrefix(authHeader, "Bearer ")
			if tokenString == authHeader { // No "Bearer " prefix
				log.Warn("Bearer token missing")
				return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token format"})
			}

			token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, 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(config.JWTSecret), nil
			})

			if err != nil {
				log.Errorf("JWT parsing failed: %v", err)
				return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"})
			}

			if claims, ok := token.Claims.(*UserClaims); ok && token.Valid {
				// 将用户信息存入context,供下游业务逻辑使用
				ctx := context.WithValue(c.Request().Context(), userContextKey, claims)
				c.SetRequest(c.Request().WithContext(ctx))

				// --- 授权逻辑核心 ---
				isAllowed := checkAuthorization(c.Request().Method, c.Path(), claims.Policies)
				if !isAllowed {
					log.Warnf("Access denied for user '%s' to '%s %s'", claims.UserID, c.Request().Method, c.Path())
					return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
				}

				return next(c)
			}
			
			return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token claims"})
		}
	}
}

// checkAuthorization 是策略评估的核心引擎。
func checkAuthorization(requestMethod, requestPath string, policies []Policy) bool {
	// 默认拒绝,除非有明确的允许策略匹配
	finalDecision := false

	// 1. 先检查所有Deny策略
	for _, p := range policies {
		if p.Effect == Deny {
			if matchPolicy(requestMethod, requestPath, p) {
				// 只要有一个Deny策略匹配,立即拒绝
				return false
			}
		}
	}

	// 2. 再检查Allow策略
	for _, p := range policies {
		if p.Effect == Allow {
			if matchPolicy(requestMethod, requestPath, p) {
				// 找到一个匹配的Allow策略,暂时允许
				finalDecision = true
				break // 找到一个即可
			}
		}
	}

	return finalDecision
}

// matchPolicy 检查单个策略是否与当前请求匹配。
func matchPolicy(method, path string, policy Policy) bool {
	actionMatch := false
	for _, action := range policy.Actions {
		if action == "*" || strings.EqualFold(action, method) {
			actionMatch = true
			break
		}
	}
	if !actionMatch {
		return false
	}

	resourceMatch := false
	for _, resourcePattern := range policy.Resources {
        // 在真实项目中,这里应该使用预编译的正则表达式
		regex, err := compileResourcePattern(resourcePattern)
		if err != nil {
			log.Errorf("Invalid resource pattern in policy SID '%s': %v", policy.SID, err)
			continue // 跳过无效的策略
		}
		if regex.MatchString(path) {
			resourceMatch = true
			break
		}
	}

	return resourceMatch
}

// GetUserClaims 从 Echo Context 中提取用户信息。
func GetUserClaims(c echo.Context) (*UserClaims, bool) {
	user, ok := c.Request().Context().Value(userContextKey).(*UserClaims)
	return user, ok
}

这个中间件的逻辑很明确:

  1. 解析并验证JWT。
  2. UserClaims存入请求上下文,方便后续业务逻辑获取用户信息。
  3. 执行checkAuthorization。这个函数是决策核心,它遵循“默认拒绝”和“Deny优先”的原则。一个请求只有在没有匹配任何Deny策略,并且至少匹配一个Allow策略的情况下才会被放行。

Cypress的安全契约测试

后端中间件已经就绪,但我们如何相信它能正确处理所有边缘情况?这就是Cypress的用武之地。我们不把它用于测试UI,而是直接用它的cy.request()功能来作为API客户端,验证API的安全层。

为了让测试更高效,我们创建了一个仅在测试环境中暴露的特殊API端点:/test/auth/token。这个端点可以根据我们传入的参数,动态生成一个包含特定策略的JWT。这使我们能够模拟任何类型的用户。

// main_test.go (或者一个单独的测试服务器启动文件)
// 这个函数只在测试构建时被包含

func setupTestRouter() *echo.Echo {
	e := echo.New()
	
	// ... 注册你的真实API路由
	api := e.Group("/api/v1")
	iamMiddleware := iam.NewIAMMiddleware(iam.MiddlewareConfig{JWTSecret: "test_secret"})
	api.Use(iamMiddleware)

	api.GET("/projects/:project_id/documents/:doc_id", func(c echo.Context) error {
		return c.String(http.StatusOK, "document content")
	})
	api.PUT("/projects/:project_id/documents/:doc_id", func(c echo.Context) error {
		return c.String(http.StatusOK, "document updated")
	})
	api.GET("/settings/billing", func(c echo.Context) error {
		return c.String(http.StatusOK, "billing info")
	})


	// --- 测试专用端点 ---
	testGroup := e.Group("/test")
	testGroup.POST("/auth/token", generateTestToken)

	return e
}

func generateTestToken(c echo.Context) error {
	var requestBody struct {
		UserID   string        `json:"user_id"`
		TenantID string        `json:"tenant_id"`
		Policies []iam.Policy `json:"policies"`
	}

	if err := c.Bind(&requestBody); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
	}

	claims := &iam.UserClaims{
		UserID:   requestBody.UserID,
		TenantID: requestBody.TenantID,
		Policies: requestBody.Policies,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 1)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "test-issuer",
		},
	}
	
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte("test_secret"))
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to sign token"})
	}

	return c.JSON(http.StatusOK, map[string]string{"token": tokenString})
}

有了这个测试端点,我们就可以在Cypress中编写非常清晰的测试用例了。首先,我们创建一个自定义命令cy.loginAs来封装获取token的过程。

// cypress/support/commands.js

Cypress.Commands.add('loginAs', (userProfile) => {
  cy.request({
    method: 'POST',
    url: 'http://localhost:1323/test/auth/token', // Go API的地址
    body: {
      user_id: userProfile.userId || 'test-user',
      tenant_id: userProfile.tenantId || 'test-tenant',
      policies: userProfile.policies || [],
    },
  }).then((response) => {
    // 将token存储在Cypress的别名中,以便后续请求使用
    cy.wrap(response.body.token).as('jwt');
  });
});

现在,我们可以编写具体的安全测试了。每个测试文件都专注于一个特定的资源或一组相关的策略。

// cypress/e2e/document_access.cy.js

describe('Document API Access Control', () => {
  const projectA_Editor = {
    policies: [
      {
        sid: 'AllowAllOnProjectA',
        effect: 'allow',
        actions: ['*'],
        resources: ['/api/v1/projects/proj-A/documents/*'],
      },
    ],
  };

  const projectB_Viewer = {
    policies: [
      {
        sid: 'AllowReadOnProjectB',
        effect: 'allow',
        actions: ['GET'],
        resources: ['/api/v1/projects/proj-B/documents/*'],
      },
    ],
  };

  const global_Deny = {
    policies: [
        // 这是一个高权限用户,但是被一条Deny策略限制了
      {
        sid: 'AllowAll',
        effect: 'allow',
        actions: ['*'],
        resources: ['*'],
      },
      {
        sid: 'ExplicitlyDenyDoc123',
        effect: 'deny',
        actions: ['*'],
        resources: ['/api/v1/projects/proj-A/documents/doc-123'],
      },
    ],
  };

  context('As a Project-A Editor', () => {
    beforeEach(() => {
      cy.loginAs(projectA_Editor);
    });

    it('should be able to GET documents in Project A', () => {
      cy.get('@jwt').then((token) => {
        cy.request({
          method: 'GET',
          url: 'http://localhost:1323/api/v1/projects/proj-A/documents/doc-123',
          headers: {
            Authorization: `Bearer ${token}`,
          },
        }).its('status').should('eq', 200);
      });
    });

    it('should be able to PUT documents in Project A', () => {
      cy.get('@jwt').then((token) => {
        cy.request({
          method: 'PUT',
          url: 'http://localhost:1323/api/v1/projects/proj-A/documents/doc-456',
          headers: {
            Authorization: `Bearer ${token}`,
          },
        }).its('status').should('eq', 200);
      });
    });

    it('should NOT be able to access documents in Project B (Forbidden)', () => {
      cy.get('@jwt').then((token) => {
        cy.request({
          method: 'GET',
          url: 'http://localhost:1323/api/v1/projects/proj-B/documents/doc-789',
          headers: {
            Authorization: `Bearer ${token}`,
          },
          failOnStatusCode: false, // 防止Cypress在4xx/5xx时失败
        }).its('status').should('eq', 403);
      });
    });
  });

  context('As a Project-B Viewer', () => {
    beforeEach(() => {
      cy.loginAs(projectB_Viewer);
    });

    it('should be able to GET documents in Project B', () => {
       cy.get('@jwt').then((token) => {
        cy.request({
          method: 'GET',
          url: 'http://localhost:1323/api/v1/projects/proj-B/documents/doc-789',
          headers: {
            Authorization: `Bearer ${token}`,
          },
        }).its('status').should('eq', 200);
      });
    });

    it('should NOT be able to PUT documents in Project B (Forbidden)', () => {
      cy.get('@jwt').then((token) => {
        cy.request({
          method: 'PUT',
          url: 'http://localhost:1323/api/v1/projects/proj-B/documents/doc-789',
          headers: {
            Authorization: `Bearer ${token}`,
          },
          failOnStatusCode: false,
        }).its('status').should('eq', 403);
      });
    });
  });

  context('With explicit Deny policies', () => {
    beforeEach(() => {
      cy.loginAs(global_Deny);
    });

    it('should be denied access to a specific document even with broad allow permissions', () => {
      cy.get('@jwt').then((token) => {
        cy.request({
          method: 'GET',
          url: 'http://localhost:1323/api/v1/projects/proj-A/documents/doc-123',
          headers: {
            Authorization: `Bearer ${token}`,
          },
          failOnStatusCode: false,
        }).its('status').should('eq', 403);
      });
    });

    it('should still be able to access other documents', () => {
      cy.get('@jwt').then((token) => {
        cy.request({
          method: 'GET',
          url: 'http://localhost:1323/api/v1/projects/proj-A/documents/doc-456',
          headers: {
            Authorization: `Bearer ${token}`,
          },
        }).its('status').should('eq', 200);
      });
    });
  });
});

这套测试的价值在于:

  1. 文档化: 测试用例本身就是一份活的、可执行的权限策略文档。
  2. 回归防护: 每当我们修改IAM中间件的核心逻辑,或者调整策略结构时,只需运行一遍Cypress测试,就能立刻知道是否破坏了现有的安全规则。
  3. 安全左移: 我们将安全验证的环节从部署后的人工渗透测试,提前到了CI/CD流水线中。每次代码提交都能触发这套检查,极大地降低了权限漏洞流入生产环境的风险。

下面是整个流程的示意图:

sequenceDiagram
    participant Cypress as Cypress Test Runner
    participant Go_API as Go Echo API (Test Env)
    participant IAM_Middleware as IAM Middleware

    Cypress->>Go_API: POST /test/auth/token (with policy definitions)
    Go_API->>Go_API: Generates and signs JWT
    Go_API-->>Cypress: Returns JWT
    
    Cypress->>Go_API: GET /api/v1/projects/proj-A/documents/doc-123 (with JWT)
    Go_API->>IAM_Middleware: Request passes to middleware
    IAM_Middleware->>IAM_Middleware: 1. Parse & Validate JWT
    IAM_Middleware->>IAM_Middleware: 2. Extract Policies from Claims
    IAM_Middleware->>IAM_Middleware: 3. Evaluate Request (Method, Path) against Policies
    alt Access Allowed
        IAM_Middleware->>Go_API: next()
        Go_API->>Go_API: Executes Business Logic
        Go_API-->>Cypress: 200 OK
    else Access Denied
        IAM_Middleware-->>Cypress: 403 Forbidden
    end
    
    Cypress->>Cypress: Assert(response.status == 200 or 403)

这套“后端中间件 + Cypress端到端测试”的组合,为我们提供了一个健壮且可维护的IAM解决方案。它在性能、复杂性和可测试性之间取得了很好的平衡。

当前这套方案并非终点。它的局限性在于将策略硬编码在JWT中,对于需要频繁、实时变更权限的复杂系统,这种模式会遇到瓶颈。下一步的演进方向可能是将策略存储在专门的服务(如PostgreSQL, Redis, 或者直接使用OPA)中,IAM中间件通过缓存高效地拉取和评估策略。届时,Cypress测试套件的价值会更加凸显,因为它能确保我们在进行如此重大的架构重构时,对外的安全契约始终保持一致和正确。


  目录