moon-policy-diff

    Bounded access-policy change audit with explainable counterexamples

    authorization
    policy
    rbac
    abac
    change-audit
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    12 hours ago
    Downloads
    2

    Dependencies

    #MoonPolicyDiff

    MoonPolicyDiff 是 MoonBit 实现的离线权限策略变更审计工具。输入新旧策略和一组明确列出的请求,程序分别求值,找出新增授权、撤销授权、命中的规则,并用退出码供 CI 拦截。适用于多租户 SaaS、数据访问和 AI 工具权限的发布前检查。

    #快速运行

    安装 MoonBit 工具链后,在仓库根目录执行:

    moon run cmd/main -- audit examples/saas/before.policy examples/saas/after.policy examples/saas/universe.txt moon run cmd/main -- audit examples/ai-tools/before.policy examples/ai-tools/after.policy examples/ai-tools/universe.txt moon run cmd/main -- audit examples/finance/before.policy examples/finance/after.policy examples/finance/universe.txt json moon run cmd/main -- audit examples/finance/before.policy examples/finance/after.policy examples/finance/universe.txt attribution moon run cmd/main -- verify examples/finance/before.policy examples/finance/universe.txt examples/finance/expected-before.txt moon run cmd/main -- impact examples/saas/after.policy examples/saas/universe.txt moon run cmd/main -- audit examples/saas/before.policy examples/saas/after.policy examples/saas/universe.txt groups moon run cmd/main -- audit examples/saas/before.policy examples/saas/after.policy examples/saas/universe.txt witnesses moon run cmd/main -- scope examples/saas/universe.txt read,export moon run cmd/main -- audit examples/finance/before.policy examples/finance/after.policy examples/finance/universe.txt structural

    两个示例都会报告新增授权,因此严格门禁退出码为 1。退出码 0 表示通过,2 表示输入无效或结论不确定。

    #输入格式

    策略文件每行一个指令:

    policy NAME inherit CHILD_ROLE PARENT_ROLE rule ID permit|deny ROLES ACTIONS RESOURCE_KINDS RESOURCE_IDS any|same|other CONDITIONS

    列表使用逗号分隔,单个 - 表示不限。条件格式为 principal:KEY:eq:VALUE、resource:KEY:neq:VALUE 或 request:KEY:exists:-。同一条规则的所有条件必须满足;拒绝规则优先于允许规则,未命中时默认拒绝。

    请求样本文件:

    principal ID TENANT ROLE1,ROLE2 KEY=VALUE,... resource ID KIND TENANT KEY=VALUE,... request PRINCIPAL_ID ACTION RESOURCE_ID KEY=VALUE,... matrix ACTION1,ACTION2

    属性为空时使用 -。matrix 将主体、动作和资源做笛卡尔积,最多生成 100000 条显式请求;超过上限会报错,不输出部分结果。示例见 examples/saas、examples/ai-tools 和 examples/finance。

    #能力与边界

    • 角色继承、通配符匹配、资源类型、租户关系和主体/资源/请求属性条件。
    • 对显式请求集做确定性新旧对比,保留每个变更的主体、动作、资源和决定性规则。
    • 严格门禁默认拒绝任何新增授权、撤权或跨租户新增授权。
    • JSON 报告包含请求、决策、规则命中轨迹和门禁发现。
    • MoonBit 库 API 可按动作分别设置新增授权和撤权预算;未配置的动作默认预算为零。
    • 两因素反事实归因可区分规则修订和角色继承修订对样本请求的影响;结论仅针对这两种变更维度。
    • verify 读取 expect PRINCIPAL ACTION RESOURCE allow|deny 断言,检查单版策略是否满足既定权限基线。
    • impact 逐条移除规则重算样本,给出每条规则被删除后新增或失去访问的数量及见证请求。
    • groups 按主体租户、资源租户和动作汇总样本变更,便于查看跨租户授权集中在哪些场景。
    • witnesses 按变更类型、动作、资源类型、跨租户关系和决定性规则合并同类请求,保留每组一个可复现见证;完整结果仍可用 text 或 json 查看。
    • scope 对给定动作检查“主体 × 动作 × 资源”三元组覆盖情况,明确列出缺失示例;属性取值组合仍需单独设计样本。
    • structural 列出规则和角色继承边的增删改,与基于请求的行为对比互补。
    • 分析只覆盖输入的请求集合;未采样请求不构成安全保证。
    • 当前不提供认证、令牌签发、在线授权服务或其他策略语言的兼容解释器。

    #验证

    moon check --target js moon test --target js moon build --target js moon info moon fmt

    GitHub Actions 在 push 和 PR 上运行检查、测试、构建和格式检查。

    #原创性与许可

    项目为原创 MoonBit 实现,借鉴通用 RBAC/ABAC 概念,不移植现有引擎代码。与相关公开项目的边界、来源见 RELATED_WORK.md。源码采用 Apache-2.0;依赖、更新和验证记录见 THIRD_PARTY.md、CHANGELOG.md 和 TEST_RECORD.md。

    AccessPolicy

    pub(all) struct AccessPolicy {
    name : String
    role_inheritance : Array[RoleInheritance]
    rules : Array[AccessRule]
    } derive(Eq,
    Debug
    )

    AccessRequest

    pub(all) struct AccessRequest {
    principal_id : String
    action : String
    resource_id : String
    attributes : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    AccessResource

    pub(all) struct AccessResource {
    id : String
    kind : String
    tenant : String
    attributes : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    AccessRule

    pub(all) struct AccessRule {
    id : String
    effect : RuleEffect
    roles : Array[String]
    actions : Array[String]
    resource_kinds : Array[String]
    resource_ids : Array[String]
    tenant_relation : TenantRelation
    conditions : Array[AttributeCondition]
    } derive(Eq,
    Debug
    )

    Empty selector arrays mean "any". An explicit * has the same meaning and is accepted by the text parser for readable policy files.

    ActionBudget

    pub(all) struct ActionBudget {
    action : String
    max_new_grants : Int
    max_revocations : Int
    } derive(
    Debug
    )

    AttributeCondition

    pub(all) struct AttributeCondition {
    source : AttributeSource
    key : String
    operator : AttributeOperator
    value : String
    } derive(Eq,
    Debug
    )

    AttributeOperator

    pub(all) enum AttributeOperator {
    Equals
    NotEquals
    Exists
    Missing
    } derive(Eq,
    Debug
    )

    AttributeSource

    pub(all) enum AttributeSource {
    PrincipalAttribute
    ResourceAttribute
    RequestAttribute
    } derive(Eq,
    Debug
    )

    AttributedChange

    pub(all) struct AttributedChange {
    request : AccessRequest
    kind : ChangeKind
    cause : ChangeCause
    changed_rule_ids : Array[String]
    role_edges_changed : Bool
    } derive(
    Debug
    )

    AuditReport

    pub(all) struct AuditReport {
    before_name : String
    after_name : String
    requests_considered : Int
    changes : Array[DecisionChange]
    diagnostics : Array[Diagnostic]
    complete : Bool
    } derive(Eq,
    Debug
    )

    ChangeCause

    pub(all) enum ChangeCause {
    RuleRevision
    RoleInheritanceRevision
    EitherRevision
    CombinedRevision
    Unattributed
    } derive(Eq,
    Debug
    )

    ChangeGroup

    pub(all) struct ChangeGroup {
    principal_tenant : String
    resource_tenant : String
    action : String
    new_grants : Int
    revocations : Int
    unchanged : Int
    inconclusive : Int
    } derive(
    Debug
    )

    ChangeKind

    pub(all) enum ChangeKind {
    NewGrant
    RevokedGrant
    UnchangedAllow
    UnchangedDeny
    Inconclusive
    } derive(Eq,
    Debug
    )

    Decision

    pub(all) struct Decision {
    kind : DecisionKind
    decisive_rules : Array[String]
    traces : Array[RuleTrace]
    explanation : String
    } derive(Eq,
    Debug
    )

    DecisionChange

    pub(all) struct DecisionChange {
    request : AccessRequest
    before : Decision
    after : Decision
    kind : ChangeKind
    } derive(Eq,
    Debug
    )

    DecisionExpectation

    pub(all) struct DecisionExpectation {
    principal_id : String
    action : String
    resource_id : String
    expected : DecisionKind
    } derive(
    Debug
    )

    DecisionKind

    pub(all) enum DecisionKind {
    Allowed
    Denied
    Unknown
    } derive(Eq,
    Debug
    )

    Diagnostic

    pub(all) struct Diagnostic {
    code : String
    severity : DiagnosticSeverity
    message : String
    } derive(Eq,
    Debug
    )

    DiagnosticSeverity

    pub(all) enum DiagnosticSeverity {
    Info
    Warning
    Error
    } derive(Eq,
    Debug
    )

    ExpectationResult

    pub(all) struct ExpectationResult {
    expectation : DecisionExpectation
    actual : DecisionKind
    matched_requests : Int
    passed : Bool
    } derive(
    Debug
    )

    GateConfig

    pub(all) struct GateConfig {
    max_new_grants : Int
    max_revocations : Int
    forbid_cross_tenant_grants : Bool
    permitted_principals : Array[String]
    permitted_actions : Array[String]
    } derive(
    Debug
    )

    GateFinding

    pub(all) struct GateFinding {
    code : String
    request : AccessRequest
    message : String
    } derive(
    Debug
    )

    GateResult

    pub(all) struct GateResult {
    verdict : GateVerdict
    findings : Array[GateFinding]
    } derive(
    Debug
    )

    GateVerdict

    pub(all) enum GateVerdict {
    Pass
    Fail
    Indeterminate
    } derive(Eq,
    Debug
    )

    MatrixExpansion

    pub(all) struct MatrixExpansion {
    universe : RequestUniverse
    generated_requests : Int
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    ParsedExpectations

    pub(all) struct ParsedExpectations {
    expectations : Array[DecisionExpectation]
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    ParsedPolicy

    pub(all) struct ParsedPolicy {
    policy : AccessPolicy
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    ParsedUniverse

    pub(all) struct ParsedUniverse {
    universe : RequestUniverse
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    PolicyCoverage

    pub(all) struct PolicyCoverage {
    policy_name : String
    evaluated_requests : Int
    allowed_requests : Int
    denied_requests : Int
    rule_coverage : Array[RuleCoverage]
    actions_seen : Array[String]
    principal_tenants_seen : Array[String]
    resource_tenants_seen : Array[String]
    } derive(
    Debug
    )

    Principal

    pub(all) struct Principal {
    id : String
    tenant : String
    roles : Array[String]
    attributes : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    RequestUniverse

    pub(all) struct RequestUniverse {
    principals : Array[Principal]
    resources : Array[AccessResource]
    requests : Array[AccessRequest]
    } derive(Eq,
    Debug
    )

    The universe is explicit so a finite analysis never silently claims coverage of identities or resources that were not provided.

    RoleInheritance

    pub(all) struct RoleInheritance {
    child : String
    parent : String
    } derive(Eq,
    Debug
    )

    A role name may inherit another role. Inheritance is directed: a holder of child receives all permissions granted to parent.

    RuleCoverage

    pub(all) struct RuleCoverage {
    rule_id : String
    matched_requests : Int
    decisive_requests : Int
    } derive(
    Debug
    )

    RuleDelta

    pub(all) struct RuleDelta {
    rule_id : String
    kind : RuleDeltaKind
    } derive(
    Debug
    )

    RuleDeltaKind

    pub(all) enum RuleDeltaKind {
    AddedRule
    RemovedRule
    ModifiedRule
    UnchangedRule
    } derive(Eq,
    Debug
    )

    RuleEffect

    pub(all) enum RuleEffect {
    Permit
    Deny
    } derive(Eq,
    Debug
    )

    The two possible effects of a policy rule. A deny rule overrides permits.

    RuleImpact

    pub(all) struct RuleImpact {
    rule_id : String
    grants_when_removed : Int
    revocations_when_removed : Int
    example_grant : AccessRequest?
    example_revocation : AccessRequest?
    } derive(
    Debug
    )

    RuleImpactReport

    pub(all) struct RuleImpactReport {
    policy_name : String
    impacts : Array[RuleImpact]
    diagnostics : Array[Diagnostic]
    complete : Bool
    } derive(
    Debug
    )

    RuleTrace

    pub(all) struct RuleTrace {
    rule_id : String
    effect : RuleEffect
    matched : Bool
    reason : String
    } derive(Eq,
    Debug
    )

    ScopeCoverage

    pub(all) struct ScopeCoverage {
    possible_requests : Int
    represented_requests : Int
    missing_requests : Int
    missing_examples : Array[AccessRequest]
    complete : Bool
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    StructuralDiff

    pub(all) struct StructuralDiff {
    rules : Array[RuleDelta]
    added_role_edges : Array[RoleInheritance]
    removed_role_edges : Array[RoleInheritance]
    } derive(
    Debug
    )

    TenantRelation

    pub(all) enum TenantRelation {
    AnyTenant
    SameTenant
    OtherTenant
    } derive(Eq,
    Debug
    )

    VerificationReport

    pub(all) struct VerificationReport {
    policy_name : String
    passed : Int
    failed : Int
    inconclusive : Int
    results : Array[ExpectationResult]
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    WitnessGroup

    pub(all) struct WitnessGroup {
    kind : ChangeKind
    action : String
    resource_kind : String
    cross_tenant : Bool
    before_rules : Array[String]
    after_rules : Array[String]
    occurrences : Int
    example : AccessRequest
    } derive(
    Debug
    )

    WitnessSelection

    pub(all) struct WitnessSelection {
    groups : Array[WitnessGroup]
    total_changed_requests : Int
    omitted_groups : Int
    complete : Bool
    } derive(
    Debug
    )

    analyze_scope

    fn analyze_scope(universe : RequestUniverse, actions : Array[String], max_combinations : Int) -> ScopeCoverage

    Measures coverage of principal × action × resource triples. Attribute valuations are intentionally excluded; a represented triple may still omit other important attribute combinations.

    attribute_changes

    fn attribute_changes(before : AccessPolicy, after : AccessPolicy, universe : RequestUniverse) -> Array[AttributedChange]

    A two-factor counterfactual: swap only the rules, then only the role graph. The result explains the observed decision for the supplied request and these two policy dimensions; it is not a general causal proof.

    cause_name

    fn cause_name(cause : ChangeCause) -> String

    change_name

    fn change_name(kind : ChangeKind) -> String

    check_action_budgets

    fn check_action_budgets(report : AuditReport, budgets : Array[ActionBudget]) -> GateResult

    Independent per-action budgets. Unlisted actions have a zero budget. This gate complements the tenant-aware strict gate.

    check_gate

    fn check_gate(report : AuditReport, universe : RequestUniverse, config : GateConfig) -> GateResult

    Exceptions require both the principal and action to be explicitly listed. They affect only the general grant count, never cross-tenant violations.

    classify_change

    fn classify_change(before : Decision, after : Decision) -> ChangeKind

    compare_policies

    fn compare_policies(before : AccessPolicy, after : AccessPolicy, universe : RequestUniverse) -> AuditReport

    Every claim is scoped to the supplied request universe. Invalid input returns an incomplete report and never silently asserts equivalence.

    condition_matches

    fn condition_matches(condition : AttributeCondition, principal : Principal, resource : AccessResource, request : AccessRequest) -> Bool

    count_changes

    fn count_changes(report : AuditReport, kind : ChangeKind) -> Int

    count_lint

    fn count_lint(issues : Array[Diagnostic], severity : DiagnosticSeverity) -> Int

    cross_tenant_change_count

    fn cross_tenant_change_count(groups : Array[ChangeGroup]) -> Int

    default_deny

    fn default_deny() -> Decision

    effective_roles

    fn effective_roles(policy : AccessPolicy, principal : Principal) -> Array[String]

    Compute roles reachable from the principal's direct grants.

    empty_policy

    fn empty_policy(name : String) -> AccessPolicy

    empty_universe

    fn empty_universe() -> RequestUniverse

    evaluate_request

    fn evaluate_request(policy : AccessPolicy, universe : RequestUniverse, request : AccessRequest) -> Decision

    A deny rule takes precedence over permits. Without a matching rule, evaluation uses default deny.

    evaluate_validated

    fn evaluate_validated(policy : AccessPolicy, universe : RequestUniverse, request : AccessRequest) -> Decision

    expand_request_matrix

    fn expand_request_matrix(universe : RequestUniverse, actions : Array[String], max_requests : Int) -> MatrixExpansion

    Adds all principal × action × resource requests. The caller must supply a bound; overflow fails without returning a partial expanded universe.

    find_witnesses

    fn find_witnesses(report : AuditReport, kind : ChangeKind) -> Array[DecisionChange]

    glob_matches

    fn glob_matches(pattern : String, value : String) -> Bool

    Small glob matcher: '*' accepts any sequence and '?' one character. Iterative backtracking avoids a regular-expression dependency.

    group_changes

    fn group_changes(report : AuditReport, universe : RequestUniverse) -> Array[ChangeGroup]

    Groups decisions by subject tenant, resource tenant, and action. This is an aggregate over the explicit sample, not a population estimate.

    has_errors

    fn has_errors(issues : Array[Diagnostic]) -> Bool

    inert_rules

    fn inert_rules(report : RuleImpactReport) -> Array[String]

    is_valid_identifier

    fn is_valid_identifier(value : String) -> Bool

    lint_policy

    fn lint_policy(policy : AccessPolicy, universe : RequestUniverse) -> Array[Diagnostic]

    Static and sample-scoped lint. Lint warnings do not change evaluation.

    lookup_attribute

    fn lookup_attribute(attrs : Array[(String, String)], key : String) -> String?

    matching_rule_ids

    fn matching_rule_ids(policy : AccessPolicy, principal : Principal, resource : AccessResource, request : AccessRequest) -> Array[String]

    measure_policy_coverage

    fn measure_policy_coverage(policy : AccessPolicy, universe : RequestUniverse) -> PolicyCoverage

    Counts are observational: a rule with zero matches in this universe may still match a request outside the supplied finite sample.

    measure_rule_impact

    fn measure_rule_impact(policy : AccessPolicy, universe : RequestUniverse) -> RuleImpactReport

    Measures the effect of deleting each rule, one at a time, over the explicit universe. Interacting rule deletions are outside this analysis.

    parse_expectations

    fn parse_expectations(source : String) -> ParsedExpectations

    Each line: expect PRINCIPAL ACTION RESOURCE allow|deny.

    parse_policy

    fn parse_policy(source : String) -> ParsedPolicy

    Policy DSL: policy NAME; inherit CHILD PARENT; rule ID EFFECT ROLES ACTIONS KINDS IDS TENANT CONDITIONS. Lists are comma separated; '-' means unrestricted or empty.

    parse_universe

    fn parse_universe(source : String) -> ParsedUniverse

    Universe DSL: principal ID TENANT ROLES ATTRS resource ID KIND TENANT ATTRS request PRINCIPAL ACTION RESOURCE ATTRS matrix ACTION1,ACTION2

    render_attribution

    fn render_attribution(changes : Array[AttributedChange]) -> String

    render_coverage

    fn render_coverage(coverage : PolicyCoverage) -> String

    render_groups

    fn render_groups(groups : Array[ChangeGroup]) -> String

    render_lint

    fn render_lint(issues : Array[Diagnostic]) -> String

    render_report

    fn render_report(report : AuditReport, gate : GateResult) -> String

    render_report_json

    fn render_report_json(report : AuditReport, gate : GateResult) -> String

    render_rule_impact

    fn render_rule_impact(report : RuleImpactReport) -> String

    render_scope

    fn render_scope(scope : ScopeCoverage) -> String

    render_structural_diff

    fn render_structural_diff(diff : StructuralDiff) -> String

    render_verification

    fn render_verification(report : VerificationReport) -> String

    render_witnesses

    fn render_witnesses(selection : WitnessSelection) -> String

    rule_match_trace

    fn rule_match_trace(rule : AccessRule, roles : Array[String], principal : Principal, resource : AccessResource, request : AccessRequest) -> RuleTrace

    select_witnesses

    fn select_witnesses(report : AuditReport, universe : RequestUniverse, max_groups : Int) -> WitnessSelection

    Picks one representative request per decision signature. The first request in input order is retained. This is a compact review view, while the full audit report remains the source of all individual changes.

    selector_matches

    fn selector_matches(selectors : Array[String], value : String) -> Bool

    shadowed_rules

    fn shadowed_rules(coverage : PolicyCoverage) -> Array[String]

    strict_gate

    fn strict_gate() -> GateConfig

    structural_diff

    fn structural_diff(before : AccessPolicy, after : AccessPolicy) -> StructuralDiff

    Lists syntactic policy changes by stable rule ID and directed role edge. It complements, but cannot replace, behavioral comparison on requests.

    tenant_pairs

    fn tenant_pairs(universe : RequestUniverse) -> Array[(String, String)]

    Returns a summary of how many distinct subject/target tenant pairs occur in the explicit universe. This helps reviewers judge sample breadth.

    unknown_decision

    fn unknown_decision(message : String) -> Decision

    unmatched_rules

    fn unmatched_rules(coverage : PolicyCoverage) -> Array[String]

    validate_policy

    fn validate_policy(policy : AccessPolicy) -> Array[Diagnostic]

    validate_universe

    fn validate_universe(universe : RequestUniverse) -> Array[Diagnostic]

    verdict_name

    fn verdict_name(verdict : GateVerdict) -> String

    verify_expectations

    fn verify_expectations(policy : AccessPolicy, universe : RequestUniverse, expectations : Array[DecisionExpectation]) -> VerificationReport

    A request match must be unique. Two sampled requests with different attributes but the same three identifiers are ambiguous for this format.