moon_policy

    A lightweight, explainable authorization policy engine for MoonBit.

    authorization
    rbac
    policy
    security
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    9 days ago
    Downloads
    7

    #MoonPolicy

    MoonPolicy is a lightweight, explainable authorization policy engine for MoonBit. It evaluates role grants and explicit rules with deny-by-default, explicit-deny-wins semantics.

    ///|
    test {
    let policy = @moon_policy.Policy(
    role_permissions={ "editor": ["document:read"] },
    bindings=[RoleBinding(subject="alice", role="editor")],
    )
    let decision = policy.authorize(
    Request(subject="alice", action="document:read", resource="document:42"),
    )
    assert_true(decision.allowed)
    }

    #Native CLI

    moon run --target native cmd/main -- demo moon run --target native cmd/main -- check '{"rules":[]}' moon run --target native cmd/main -- lint '{"rules":[]}' moon run --target native cmd/main -- eval '{"rules":[]}' '{"subject":"alice","action":"read","resource":"doc:1"}' moon run --target native cmd/main -- explain '{"rules":[]}' '{"subject":"alice","action":"read","resource":"doc:1"}' moon run --target native cmd/main -- diff '{"rules":[]}' '{"rules":[]}' '[{"subject":"alice","action":"read","resource":"doc:1"}]' moon run --target native cmd/main -- verify '{"rules":[]}' '[{"name":"deny unknown","request":{"subject":"alice","action":"read","resource":"doc:1"},"expected_allowed":false}]' moon run --target native cmd/main -- coverage '{"rules":[]}' '[{"subject":"alice","action":"read","resource":"doc:1"}]' moon run --target native cmd/main -- batch '{"rules":[]}' '[{"subject":"alice","action":"read","resource":"doc:1"}]'

    The CLI exits with 0 for a valid policy or allowed decision, 1 for a denied decision, and 2 for an invalid command, policy, or request. JSON is passed as command-line text. The examples/ directory also contains policy and request fixtures for integration tests and future file-input support. For diff, exit 0 means no access decisions changed in the supplied request array; exit 1 means at least one request became allowed or denied. The JSON output contains only changed decisions, in input order. For verify, exit 0 means all named cases passed; exit 1 means one or more expectations failed. The JSON output includes every case and aggregate counts.

    For exit-code-sensitive automation, run the built native executable directly. The installed June 2026 moon run wrapper returns 0 even when this CLI exits 1.

    #Development checks

    GitHub Actions checks formatting, generated interfaces, builds, and tests on the Wasm, Wasm GC, JavaScript, and native backends. Run the same checks locally:

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

    #Scope

    The current version provides RBAC with role inheritance, attribute conditions, explicit allow/deny rules, JSON policy/request parsing, and a native CLI.

    Roles can inherit permissions transitively. For example, define "role_parents": { "admin": ["editor"] } alongside roles in JSON, or pass role_parents={ "admin": ["editor"] } to Policy. Every referenced role must be declared in roles; cycles are rejected during validation. Inherited grants appear in the decision trace under the role that owns the permission. Bindings can limit a role grant to a resource: use { "subject": "alice", "role": "editor", "resource": { "glob": "/projects/alpha/*" } }. An omitted resource matches all resources. Resource scope also applies to permissions inherited from parent roles.

    Rules can use Glob("...") where * matches any sequence and ? matches one Unicode character. This is useful for HTTP-like resources and namespaced actions such as project:read:*. In JSON, write { "glob": "project:read:*" } in a rule's action or resource field; plain JSON strings always mean exact matching.

    Rules may also require attributes under subject.<key>, resource.<key>, or context.<key>. Missing attributes never satisfy an Equals or Exists condition, so evaluation remains fail-closed. Negating a missing attribute remains unknown rather than becoming true. An unknown condition never grants access; on a matching deny rule it denies conservatively. Attribute objects may be nested, so paths such as resource.metadata.owner.id and context.risk.score work without flattening the request. JSON attribute nesting is limited to 16 levels.

    The JSON format expresses conditions as exists, equals, same, one_of, contains, compare, all, any, and not objects. A resource-owner rule can use { "same": { "left": "subject.id", "right": "resource.owner_id" } }. Both values must exist and have the same scalar type. Integer thresholds use { "compare": { "path": "context.risk", "op": "lte", "value": 20 } }; op may be lt, lte, gt, or gte. Missing or non-integer attributes are unknown and never satisfy an allow condition, even under not. Attribute values can be strings, string lists, booleans, or integers.

    For group membership, provide a string-list attribute such as "subject_attributes": { "groups": ["users", "reviewers"] } and test it with { "contains": { "path": "subject.groups", "value": "reviewers" } }. Missing or non-list attributes remain unknown under contains and cannot become an allow through not. Use string_matches with an exact string or { "glob": "*@example.com" } to match string attributes. Use contains_any when one required tag is enough, and contains_all when every listed group, approval, or capability is needed. Empty required sets are rejected during policy validation.

    For an allowlist of regions or teams, use { "one_of": { "path": "context.region", "values": ["eu", "apac"] } }. The list must be nonempty and all entries must have the same scalar type. Call policy.validate() for structured configuration diagnostics. JSON loading rejects invalid policy configuration before returning a policy.

    request_from_json decodes an authorization request, and decision.to_json() returns allowed, reason, and the ordered trace of matching grants/rules. policy.explain(request) returns the same decision together with an inspection of every rule's selectors and condition. The CLI explain command emits that report as JSON. Conditions are reported as satisfied, unsatisfied, unknown, or not evaluated; a matching deny with an unknown condition is marked applied. policy.to_json() exports a programmatically assembled policy in the same format accepted by policy_from_json. Validate a policy before persisting it; invalid role references, cycles, and malformed conditions are rejected when loaded again. previous.access_changes(replacement, requests) compares two policies against a request corpus. It reports newly allowed and newly denied requests, while ignoring changes that affect only trace text. requests_from_json accepts a JSON array of requests for this workflow. cases_from_json loads named requests with expected_allowed booleans, and policy.run_cases(cases) checks them all. See examples/cases.json for a starter policy regression suite. policy.coverage(requests) summarizes allow and deny outcomes plus per-rule selector, condition, and applied counts. uncovered_rule_ids() identifies rules that the supplied corpus never selected; it does not prove those rules are unreachable in all possible requests. policy.authorize_batch(requests) evaluates a request array in input order and returns each original request paired with its decision. The batch command emits the same result as a JSON array for gateways and offline audit jobs. policy.lint() returns non-blocking warnings for unrestricted allow rules, wildcard or duplicate role permissions, duplicate parent roles, duplicate rule bodies, and allow conditions that cannot succeed. lint emits the warnings as JSON; a warning does not make a policy invalid or change its decisions.

    #JSON policies

    policy_from_json loads a constrained JSON format. Unknown policy, binding, and rule fields are rejected so a misspelled restriction cannot silently broaden access.

    ///|
    test {
    let policy = policy_from_json(
    (
    #|{
    #| "roles": { "viewer": ["document:read"] },
    #| "bindings": [{ "subject": "bob", "role": "viewer" }]
    #|}
    ),
    )
    let decision = policy.authorize(
    Request(subject="bob", action="document:read", resource="document:1"),
    )
    assert_true(decision.allowed)
    }

    PolicyParseError

    pub(all) suberror PolicyParseError {
    InvalidJson(String)
    MissingField(String)
    ExpectedType(field~ : String, expected~ : String)
    InvalidEffect(String)
    InvalidCondition(String)
    InvalidPolicy(String)
    UnknownField(String)
    } derive(Eq,
    Debug
    )

    Errors raised while decoding a JSON policy document.

    AccessChange

    pub(all) struct AccessChange {
    request : Request
    previous : Decision
    current : Decision
    } derive(Eq,
    Debug
    )

    An authorization result that changed between two policy versions.

    AccessChange::kind

    AccessChange::to_json

    fn AccessChange::to_json(self : AccessChange) -> Json

    Serialize an access regression as a request plus the two decisions.

    AccessChangeKind

    pub(all) enum AccessChangeKind {
    NewlyAllowed
    NewlyDenied
    } derive(Eq,
    Debug
    )

    Whether the replacement policy grants or removes access for this request.

    AttributeValue

    pub(all) enum AttributeValue {
    StringValue(String)
    StringListValue(Array[String])
    ObjectValue(Map[String, AttributeValue])
    BoolValue(Bool)
    IntValue(Int)
    } derive(Eq,
    Debug
    )

    An attribute supplied by the caller for attribute-based authorization.

    AuthorizationResult

    pub(all) struct AuthorizationResult {
    request : Request
    decision : Decision
    } derive(Eq,
    Debug
    )

    One request paired with its authorization decision in a batch result.

    AuthorizationResult::to_json

    Serialize a batch item as its original request and resulting decision.

    Comparison

    pub(all) enum Comparison {
    LessThan
    LessOrEqual
    GreaterThan
    GreaterOrEqual
    } derive(Eq,
    Debug
    )

    An integer threshold comparison for an attribute condition.

    Condition

    pub(all) enum Condition {
    Always
    Exists(String)
    Equals(path~ : String, value~ : AttributeValue)
    Same(left~ : String, right~ : String)
    OneOf(path~ : String, values~ : Array[AttributeValue])
    Contains(path~ : String, value~ : String)
    StringMatches(path~ : String, matcher~ : Matcher)
    ContainsAny(path~ : String, values~ : Array[String])
    ContainsAll(path~ : String, values~ : Array[String])
    Compare(path~ : String, op~ : Comparison, value~ : Int)
    AllOf(Array[Condition])
    AnyOf(Array[Condition])
    Not(Condition)
    } derive(Eq,
    Debug
    )

    A condition attached to an explicit rule.

    ConditionStatus

    pub(all) enum ConditionStatus {
    NotEvaluated
    Satisfied
    Unsatisfied
    Unknown
    } derive(Eq,
    Debug
    )

    Condition status in a rule inspection. A condition is not evaluated when the rule's subject, action, or resource selector does not match.

    Decision

    pub(all) struct Decision {
    allowed : Bool
    reason : DecisionReason
    trace : Array[String]
    } derive(Eq,
    Debug
    )

    The result of evaluating a request. trace lists matching rule or role grants.

    Decision::to_json

    fn Decision::to_json(self : Decision) -> Json

    Serialize a decision as stable, machine-readable JSON.

    DecisionReason

    pub(all) enum DecisionReason {
    Allowed
    ExplicitDeny
    NoMatchingRule
    } derive(Eq,
    Debug
    )

    Why an authorization request was allowed or denied.

    Effect

    pub(all) enum Effect {
    Allow
    Deny
    } derive(Eq,
    Debug
    )

    An authorization decision effect.

    EvaluationReport

    pub(all) struct EvaluationReport {
    decision : Decision
    rules : Array[RuleEvaluation]
    } derive(Eq,
    Debug
    )

    A decision accompanied by an inspection of every explicit rule.

    EvaluationReport::to_json

    fn EvaluationReport::to_json(self : EvaluationReport) -> Json

    Serialize an explanation as a decision plus ordered rule inspections.

    Matcher

    pub(all) enum Matcher {
    Any
    Exact(String)
    Glob(String)
    } derive(Eq,
    Debug
    )

    A matcher for a subject, action, or resource.

    Policy

    pub(all) struct Policy {
    role_permissions : Map[String, Array[String]]
    role_parents : Map[String, Array[String]]
    bindings : Array[RoleBinding]
    rules : Array[Rule]
    } derive(
    Debug
    )

    An immutable policy assembled from role grants, bindings, and explicit rules.

    Policy::Policy

    fn Policy::Policy(role_permissions? : Map[String, Array[String]], role_parents? : Map[String, Array[String]], bindings? : Array[RoleBinding], rules? : Array[Rule]) -> Policy

    Create a policy. The evaluator is deny-by-default and explicit denies win.

    Policy::access_changes

    fn Policy::access_changes(self : Policy, replacement : Policy, requests : Array[Request]) -> Array[AccessChange]

    Evaluate the same request corpus against two policies and return only changes in the access decision. Results retain the corpus order.

    Policy::authorize

    fn Policy::authorize(self : Policy, request : Request) -> Decision

    Evaluate an authorization request.

    Explicit deny rules override every allow rule and role grant. A request with no matching grant is denied.

    Policy::authorize_batch

    fn Policy::authorize_batch(self : Policy, requests : Array[Request]) -> Array[AuthorizationResult]

    Authorize a request collection while retaining its input order. Requests are evaluated independently with the same semantics as authorize.

    Policy::coverage

    fn Policy::coverage(self : Policy, requests : Array[Request]) -> PolicyCoverage

    Measure how a request corpus exercises the policy. Counts are per request: duplicate requests intentionally count more than once. Rule order is kept.

    Policy::explain

    fn Policy::explain(self : Policy, request : Request) -> EvaluationReport

    Explain each rule's contribution while preserving authorize semantics. Explicit deny rules with an unknown condition are marked as applied because MoonPolicy treats them conservatively after their selectors match.

    Policy::lint

    fn Policy::lint(self : Policy) -> Array[PolicyWarning]

    Find valid but suspicious policy constructs. Warnings do not block JSON loading or authorization; validate remains the strict error check.

    Policy::run_cases

    fn Policy::run_cases(self : Policy, cases : Array[PolicyCase]) -> PolicySuiteResult

    Evaluate named examples against a policy. Every case is evaluated even after a failure, so a suite reports all regressions in one run.

    Policy::to_json

    fn Policy::to_json(self : Policy) -> Json

    Serialize a policy to the documented JSON representation. A valid policy can be loaded again with policy_from_json without changing its behavior.

    Policy::validate

    fn Policy::validate(self : Policy) -> Array[PolicyIssue]

    Check a policy for ambiguous or broken configuration before evaluating it. The returned issues are ordered by role, binding, and then rule order.

    PolicyCase

    pub(all) struct PolicyCase {
    name : String
    request : Request
    expected_allowed : Bool
    } derive(Eq,
    Debug
    )

    A named authorization example with an expected allow or deny result.

    PolicyCase::PolicyCase

    fn PolicyCase::PolicyCase(name~ : String, request~ : Request, expected_allowed~ : Bool) -> PolicyCase

    PolicyCaseResult

    pub(all) struct PolicyCaseResult {
    name : String
    request : Request
    expected_allowed : Bool
    decision : Decision
    passed : Bool
    } derive(Eq,
    Debug
    )

    The actual decision and pass/fail status for a policy case.

    PolicyCoverage

    pub(all) struct PolicyCoverage {
    total_requests : Int
    allowed : Int
    explicit_denies : Int
    default_denies : Int
    rules : Array[RuleCoverage]
    } derive(Eq,
    Debug
    )

    Aggregate authorization outcomes and per-rule exercise counts.

    PolicyCoverage::to_json

    fn PolicyCoverage::to_json(self : PolicyCoverage) -> Json

    Serialize a policy coverage report as machine-readable JSON.

    PolicyCoverage::uncovered_rule_ids

    fn PolicyCoverage::uncovered_rule_ids(self : PolicyCoverage) -> Array[String]

    Return rule ids that no request matched at the selector level. This is a coverage gap in the supplied corpus, not proof that a rule is unreachable.

    PolicyIssue

    pub(all) struct PolicyIssue {
    code : String
    location : String
    message : String
    } derive(Eq,
    Debug
    )

    A static problem with a policy before it is used to authorize requests.

    PolicySuiteResult

    pub(all) struct PolicySuiteResult {
    results : Array[PolicyCaseResult]
    passed : Int
    failed : Int
    } derive(Eq,
    Debug
    )

    Ordered case results and aggregate pass/fail counts.

    PolicySuiteResult::to_json

    Serialize a suite result for CLI and CI consumers.

    PolicyWarning

    pub(all) struct PolicyWarning {
    code : String
    location : String
    message : String
    } derive(Eq,
    Debug
    )

    A non-blocking warning about a policy that is valid but may be too broad, redundant, or ineffective.

    PolicyWarning::to_json

    fn PolicyWarning::to_json(self : PolicyWarning) -> Json

    Serialize a warning for editor integrations and the command-line linter.

    Request

    pub(all) struct Request {
    subject : String
    action : String
    resource : String
    subject_attributes : Map[String, AttributeValue]
    resource_attributes : Map[String, AttributeValue]
    context : Map[String, AttributeValue]
    } derive(Eq,
    Debug
    )

    A request passed to the policy evaluator.

    Request::Request

    fn Request::Request(subject~ : String, action~ : String, resource~ : String, subject_attributes? : Map[String, AttributeValue], resource_attributes? : Map[String, AttributeValue], context? : Map[String, AttributeValue]) -> Request

    Create an authorization request.

    Request::to_json

    fn Request::to_json(self : Request) -> Json

    Serialize a request into the same format accepted by request_from_json.

    RoleBinding

    pub(all) struct RoleBinding {
    subject : String
    role : String
    resource : Matcher
    } derive(Eq,
    Debug
    )

    Attach a subject to a named role.

    RoleBinding::RoleBinding

    fn RoleBinding::RoleBinding(subject~ : String, role~ : String, resource? : Matcher) -> RoleBinding

    Create a role binding.

    Rule

    pub(all) struct Rule {
    id : String
    effect : Effect
    subject : Matcher
    action : Matcher
    resource : Matcher
    condition : Condition
    } derive(
    Debug
    )

    A single explicit policy rule.

    Rule::Rule

    fn Rule::Rule(id~ : String, effect~ : Effect, subject? : Matcher, action? : Matcher, resource? : Matcher, condition? : Condition) -> Rule

    Create an explicit rule. Omitted matchers match every value.

    RuleCoverage

    pub(all) struct RuleCoverage {
    id : String
    selector_matches : Int
    condition_satisfied : Int
    condition_unsatisfied : Int
    condition_unknown : Int
    applied : Int
    } derive(Eq,
    Debug
    )

    How a request corpus exercised one explicit rule.

    RuleEvaluation

    pub(all) struct RuleEvaluation {
    id : String
    effect : Effect
    subject_matches : Bool
    action_matches : Bool
    resource_matches : Bool
    condition_status : ConditionStatus
    applied : Bool
    } derive(Eq,
    Debug
    )

    The selector and condition results for one rule during authorization.

    authorization_results_to_json

    fn authorization_results_to_json(results : Array[AuthorizationResult]) -> Json

    Serialize a batch result array for transport or audit storage.

    cases_from_json

    fn cases_from_json(source : String) -> Array[PolicyCase] raise PolicyParseError

    Decode a JSON array of named authorization examples. Each member requires name, request, and expected_allowed fields; duplicate names fail.

    glob_matches

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

    Match a value against a glob pattern. * matches zero or more characters and ? matches exactly one Unicode scalar value. Runtime is bounded by O(pattern length * value length) with O(value length) working memory.

    policy_from_json

    fn policy_from_json(source : String) -> Policy raise PolicyParseError

    Decode the documented JSON representation of a policy.

    roles, role_parents, bindings, and rules are optional. Unknown fields are rejected to prevent misspelled restrictions from widening access.

    request_from_json

    fn request_from_json(source : String) -> Request raise PolicyParseError

    Decode a JSON authorization request. The required keys are subject, action, and resource. The optional subject_attributes, resource_attributes, and context maps contain scalar values.

    requests_from_json

    fn requests_from_json(source : String) -> Array[Request] raise PolicyParseError

    Decode a JSON array of requests for batch checks or policy regression tests.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io