用规则 DAG 重构即时访问授权:HubSpot JITA 架构的工程价值

2026-08-03 31 预计阅读时间: 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.

预计阅读时间:10 分钟

即时访问授权(Just-In-Time Access,JITA)解决的是一个敏感问题:用户只在需要时获得权限,并在条件不再满足后失去权限。随着审批条件、资源类型和治理要求增加,把所有判断堆进一段条件代码,很快会形成难以解释、难以测试、也难以审计的授权逻辑。

HubSpot 对 JITA 授权系统的重构给出了一条清晰路径:把访问决策拆成独立规则,再用有向无环图(DAG)表达规则之间的依赖。同时,系统为决策增加结构化元数据、规则级可观测性和治理工作流。变化的重点不只是整理代码,而是让授权决策成为可以解释、测量和管理的工程对象。

从条件分支转向规则图

传统实现通常从一个入口函数开始,依次检查用户、资源、审批状态和风险信号:

if 用户有效 and 完成审批 and 资源允许JITA:
    if 低风险 or 存在额外批准:
        授权

这种写法在条件较少时足够直接,但随着业务发展会出现几个问题:

  • 同一个条件被复制到多个授权流程中,修正规则时容易遗漏。
  • 条件之间存在隐含顺序,开发者必须阅读完整函数才能发现依赖。
  • 日志通常只记录最终的允许或拒绝,无法回答具体是哪条规则导致拒绝。
  • 修改一项策略往往需要重新发布整个授权服务,治理边界不清晰。

规则引擎把每个判断封装为具有稳定输入、输出和身份标识的节点。例如,user_active 判断用户状态,approval_valid 验证审批,risk_acceptable 处理风险信号,grant_access 汇总上游结果。

DAG 则显式表示执行依赖:只有依赖节点完成后,当前规则才会执行。由于图中不存在环,系统可以进行拓扑排序,也能在启动或配置发布阶段拒绝循环依赖。对于彼此独立的规则,还可以并行求值以降低整体延迟。

决策结果不能只有 true 和 false

授权系统的调用方确实需要一个布尔结果,但运维、审计和治理流程需要更多上下文。一个更有用的规则结果可以包含:

{
  "rule_id": "approval_valid",
  "outcome": "deny",
  "reason_code": "APPROVAL_EXPIRED",
  "duration_ms": 3.7,
  "policy_version": "2025-01",
  "evidence": {
    "approval_id": "apr-2048"
  }
}

这类结构化元数据带来三项直接收益。

其一,系统能生成稳定的机器可读原因码。调用方不需要解析日志文本,就可以区分“审批过期”和“用户被停用”。

其二,可观测性可以下沉到规则级别。团队能够按 rule_id 统计延迟、错误率和拒绝率,而不是只监控整个授权接口。如果外部审批服务变慢,相关规则的耗时会直接暴露出来。

其三,审计记录可以关联策略版本、请求身份和证据来源,帮助团队回答“当时依据哪一版策略作出了什么决定”。不过,证据字段必须控制敏感信息,尤其不应把令牌、完整个人资料或机密资源内容写入日志。

一个可运行的最小规则 DAG

下面是一个可以直接运行的 Python 示例,用来演示独立规则、依赖检查和结构化决策轨迹。它是根据摘要所述架构编写的简化实践示例,并非 HubSpot 内部实现。

将代码保存为 jita_rules.py,然后执行 python jita_rules.py

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


@dataclass(frozen=True)
class RuleResult:
    rule_id: str
    allowed: bool
    reason_code: str
    duration_ms: float


@dataclass(frozen=True)
class Rule:
    rule_id: str
    dependencies: tuple[str, ...]
    evaluate: Callable[[dict[str, Any]], tuple[bool, str]]


def active_user(ctx: dict[str, Any]) -> tuple[bool, str]:
    allowed = ctx.get("user_status") == "active"
    return allowed, "USER_ACTIVE" if allowed else "USER_INACTIVE"


def valid_approval(ctx: dict[str, Any]) -> tuple[bool, str]:
    allowed = bool(ctx.get("approval_valid"))
    return allowed, "APPROVAL_VALID" if allowed else "APPROVAL_MISSING_OR_EXPIRED"


def acceptable_risk(ctx: dict[str, Any]) -> tuple[bool, str]:
    allowed = ctx.get("risk_score", 100) < 50
    return allowed, "RISK_ACCEPTABLE" if allowed else "RISK_TOO_HIGH"


def grant(_: dict[str, Any]) -> tuple[bool, str]:
    return True, "ACCESS_GRANTED"


RULES = {
    rule.rule_id: rule
    for rule in (
        Rule("user_active", (), active_user),
        Rule("approval_valid", (), valid_approval),
        Rule("risk_acceptable", ("user_active",), acceptable_risk),
        Rule(
            "grant_access",
            ("approval_valid", "risk_acceptable"),
            grant,
        ),
    )
}


def evaluate(target: str, ctx: dict[str, Any]) -> tuple[bool, list[RuleResult]]:
    results: dict[str, RuleResult] = {}
    visiting: set[str] = set()

    def run(rule_id: str) -> RuleResult:
        if rule_id in results:
            return results[rule_id]
        if rule_id in visiting:
            raise ValueError(f"Cycle detected at rule: {rule_id}")
        if rule_id not in RULES:
            raise KeyError(f"Unknown rule: {rule_id}")

        visiting.add(rule_id)
        rule = RULES[rule_id]
        dependency_results = [run(dep) for dep in rule.dependencies]
        visiting.remove(rule_id)

        denied = next((item for item in dependency_results if not item.allowed), None)
        if denied:
            result = RuleResult(
                rule_id,
                False,
                f"DEPENDENCY_DENIED:{denied.rule_id}",
                0.0,
            )
        else:
            started = perf_counter()
            allowed, reason = rule.evaluate(ctx)
            duration_ms = (perf_counter() - started) * 1000
            result = RuleResult(rule_id, allowed, reason, duration_ms)

        results[rule_id] = result
        return result

    decision = run(target)
    return decision.allowed, list(results.values())


if __name__ == "__main__":
    request_context = {
        "user_status": "active",
        "approval_valid": True,
        "risk_score": 18,
    }
    allowed, trace = evaluate("grant_access", request_context)
    print({"allowed": allowed, "trace": [asdict(item) for item in trace]})

生产实现还需要补上超时、异常隔离、策略版本、并发控制和持久化审计。对外部依赖规则尤其要明确失败策略:审批服务超时时,是拒绝访问、使用短期缓存,还是进入人工复核。对高权限资源,一般应采用默认拒绝,而不是在依赖故障时放行。

让规则变更进入治理流程

规则引擎减少了条件代码的耦合,却也可能把复杂度转移到配置和流程中。如果任何人都能即时修改生产规则,系统只会从“代码难治理”变成“配置难治理”。

可以这样实践一套最小治理链路:

  1. 每条规则都有所有者、稳定 ID、说明和版本号。
  2. 规则变更通过代码审查或策略审查,不允许直接覆盖生产配置。
  3. 发布前验证 DAG 不含环、不引用未知节点,并覆盖关键允许与拒绝路径。
  4. 新版本先运行影子评估,只记录新旧结果差异,不影响实际授权。
  5. 按规则监控拒绝率、错误率、P95 延迟和新旧策略分歧率。
  6. 审计日志记录请求 ID、规则结果和策略版本,同时对敏感证据做脱敏和保留期限控制。

影子评估尤其适合授权系统。团队可以在真实流量上比较旧逻辑与新规则图,定位意外扩大权限或过度拒绝的问题,再决定是否切换执行版本。

采用时要守住的边界

规则 DAG 适合依赖关系明确、决策需要解释、策略持续演进的授权场景。它并不会自动解决所有权限问题:规则数量失控会形成庞大的图;跨规则共享可变状态会破坏独立性;过度记录证据又会产生新的隐私风险。

落地时应优先挑选一条条件复杂但边界清楚的 JITA 流程,先拆出稳定规则和原因码,再接入规则级指标与审计。等团队能够可靠地测试、发布和回滚规则后,再扩大覆盖范围。衡量重构是否成功,也不应只看代码行数,而要看一次拒绝能否被快速解释、一次策略变更能否被安全验证,以及一次异常能否精确定位到具体规则。


相关推荐