最初的需求很简单:为我们的新服务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
}
这个中间件的逻辑很明确:
- 解析并验证JWT。
- 将
UserClaims
存入请求上下文,方便后续业务逻辑获取用户信息。 - 执行
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);
});
});
});
});
这套测试的价值在于:
- 文档化: 测试用例本身就是一份活的、可执行的权限策略文档。
- 回归防护: 每当我们修改IAM中间件的核心逻辑,或者调整策略结构时,只需运行一遍Cypress测试,就能立刻知道是否破坏了现有的安全规则。
- 安全左移: 我们将安全验证的环节从部署后的人工渗透测试,提前到了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测试套件的价值会更加凸显,因为它能确保我们在进行如此重大的架构重构时,对外的安全契约始终保持一致和正确。