HubSpot 用规则引擎重构 JITA 授权:把条件判断变成可观测的决策图

2026-08-03 39 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

Just-In-Time Access(JITA)要解决的是一个看似简单、实际很难维护的问题:用户在特定时间、特定理由和特定审批条件下,能否临时获得某项权限。随着例外、审批链和安全策略不断增加,集中式条件判断很容易演变成难以解释的授权代码。

HubSpot 的重构思路是把访问请求交给一组相互独立的规则评估,并将规则组织成有向无环图(DAG)。与此同时,系统为决策增加结构化元数据、规则级可观测性和治理工作流。变化的重点不只是“换一种写法”,而是让授权结果能够被解释、监控和审查。

为什么复杂条件会拖垮授权系统

传统 JITA 实现常把所有条件堆进一个函数:检查工单、确认审批人、判断资源级别、验证有效期,再处理若干特殊例外。这样的代码短期直接,长期却有三个明显问题。

  • 决策过程不透明:系统只返回允许或拒绝,调用方很难知道具体是哪条规则产生了结果。
  • 规则相互缠绕:修改有效期策略时,可能意外影响审批或资源范围判断。
  • 治理缺少抓手:安全团队难以回答某条规则由谁维护、何时修改,以及它拒绝了多少请求。

规则引擎架构把这些条件拆成独立节点。一个节点只负责一个判断,节点之间通过依赖关系组成 DAG。例如,“允许生产环境临时访问”可以依赖“工单有效”“经理已批准”和“申请时长未超限”三个节点。

DAG 还提供了明确的执行边界:依赖节点可以复用,无关分支可以独立演进,并且每个节点都能产生自己的状态、理由和耗时数据。

决策结果不应只有一个布尔值

授权系统即使最终只需要返回 allowdeny,内部结果也应该更丰富。HubSpot 的设计加入了结构化决策元数据,这类数据通常可以承载:

  • 命中的规则及其版本;
  • 每条规则的通过、拒绝或错误状态;
  • 可供审计的原因代码;
  • 评估时间和关联请求标识;
  • 用于解释决策的非敏感证据。

结构化结果能同时服务三个场景:API 向申请者解释拒绝原因,监控系统统计规则失败率,审计流程还原某次授权的完整决策链。

这里要严格控制元数据边界。访问令牌、敏感工单内容和个人隐私字段不应直接进入日志;解释能力不能以泄露安全上下文为代价。

可以这样实践:实现一个最小 DAG 规则评估器

下面是一个可直接运行的 Python 示例。它不是 HubSpot 内部实现,而是根据摘要中的架构思路构造的最小模型:三个基础规则组成依赖图,根规则汇总结果,并为每个节点记录原因、耗时和依赖关系。

将代码保存为 jita_rules.py,使用 Python 3.10 或更高版本运行。

from __future__ import annotations

import json
import time
from dataclasses import asdict, dataclass
from typing import Any, Callable


@dataclass
class RuleResult:
    rule: str
    allowed: bool
    reason: str
    duration_ms: float
    dependencies: list[str]


@dataclass
class Rule:
    name: str
    check: Callable[[dict[str, Any]], tuple[bool, str]]
    dependencies: tuple[str, ...] = ()


class RuleEngine:
    def __init__(self, rules: list[Rule]) -> None:
        self.rules = {rule.name: rule for rule in rules}

    def evaluate(self, root: str, request: dict[str, Any]) -> dict[str, Any]:
        results: dict[str, RuleResult] = {}
        visiting: set[str] = set()

        def run(name: str) -> bool:
            if name in results:
                return results[name].allowed
            if name in visiting:
                raise ValueError(f'cycle detected at rule: {name}')
            if name not in self.rules:
                raise KeyError(f'unknown rule: {name}')

            visiting.add(name)
            rule = self.rules[name]
            dependency_states = [run(dep) for dep in rule.dependencies]
            started = time.perf_counter()

            if not all(dependency_states):
                allowed, reason = False, 'DEPENDENCY_DENIED'
            else:
                allowed, reason = rule.check(request)

            duration_ms = (time.perf_counter() - started) * 1000
            results[name] = RuleResult(
                rule=name,
                allowed=allowed,
                reason=reason,
                duration_ms=round(duration_ms, 3),
                dependencies=list(rule.dependencies),
            )
            visiting.remove(name)
            return allowed

        allowed = run(root)
        return {
            'request_id': request['request_id'],
            'decision': 'ALLOW' if allowed else 'DENY',
            'root_rule': root,
            'rule_results': [asdict(result) for result in results.values()],
        }


def pass_rule(_: dict[str, Any]) -> tuple[bool, str]:
    return True, 'ALL_DEPENDENCIES_PASSED'


rules = [
    Rule(
        name='ticket_is_open',
        check=lambda req: (
            req.get('ticket_status') == 'OPEN',
            'TICKET_OPEN' if req.get('ticket_status') == 'OPEN' else 'TICKET_NOT_OPEN',
        ),
    ),
    Rule(
        name='manager_approved',
        check=lambda req: (
            bool(req.get('manager_approved')),
            'APPROVED' if req.get('manager_approved') else 'APPROVAL_MISSING',
        ),
    ),
    Rule(
        name='duration_within_limit',
        check=lambda req: (
            0 < req.get('duration_minutes', 0) <= 60,
            'DURATION_VALID' if 0 < req.get('duration_minutes', 0) <= 60 else 'DURATION_EXCEEDED',
        ),
    ),
    Rule(
        name='production_jita_access',
        check=pass_rule,
        dependencies=('ticket_is_open', 'manager_approved', 'duration_within_limit'),
    ),
]

request = {
    'request_id': 'req-2025-001',
    'ticket_status': 'OPEN',
    'manager_approved': True,
    'duration_minutes': 45,
}

print(json.dumps(RuleEngine(rules).evaluate('production_jita_access', request), indent=2))

运行命令:

python jita_rules.py

duration_minutes 改成 90 后,根规则会因为依赖节点拒绝而返回 DENY,同时保留 DURATION_EXCEEDED 这一机器可读原因。生产实现还需要定义错误、超时和数据缺失时的策略;对高权限资源,通常应采用失败即拒绝的方式。

可观测性要落到规则层级

只有请求总量和整体拒绝率还不够。规则引擎更有价值的监控维度包括:

  • rule_evaluations_total{rule, outcome}:每条规则的通过、拒绝和错误次数;
  • rule_duration_seconds{rule}:规则评估延迟分布;
  • decision_total{resource, outcome}:不同资源类型的最终决策;
  • policy_version:产生决策的策略版本。

规则名称和原因代码应保持低基数。不要把用户 ID、工单号或请求 ID 放入指标标签,这会显著增加时序数据库成本;这些字段更适合进入经过脱敏的结构化日志或审计记录。

上线时把规则当作受治理的生产资产

架构拆分只是起点。规则变得容易修改后,错误策略也可能更快进入生产,因此治理流程必须同步建立。

可以采用以下上线检查表:

  1. 为规则指定负责人、版本和变更说明。
  2. 对 DAG 做静态校验,拒绝循环依赖和不存在的节点。
  3. 用历史请求进行回放,对比新旧决策差异。
  4. 先以影子模式运行,只记录结果,不影响真实授权。
  5. 为拒绝率、错误率和延迟设置规则级告警。
  6. 保留紧急回滚能力,并记录每次策略发布。

规则引擎并不会自动消除授权复杂度,它只是把复杂度从嵌套条件转化为可命名、可组合、可观测的决策单元。对于 JITA 这类高风险系统,真正的收益来自三者同时成立:规则边界清晰、决策证据完整、策略变更受到治理。


相关推荐