Skip to main content

narrow-argument

Summary: Function argument can be narrowed

Category: Custom

Avoid

package policy

valid_user(user) if endswith(user.email, "acmecorp.com")
valid_user(user) if endswith(user.email, "acmecorp.org")

Prefer

package policy

valid_email(email) if endswith(email, "acmecorp.com")
valid_email(email) if endswith(email, "acmecorp.org")

Rationale

Note! This is a highly opinionated rule with some caveats that you should be aware of before you use it.

Accepting the most minimal types/values as functions arguments avoids unnecessary "dependencies", makes them more likely to be reusable, and tends to make code more readable as it's often easier to predict from a call site what a specifically named function does compared to a more generic version. It's fairly common to realize a function with narrowed arguments would benefit from being renamed. That is however left as an exercise to you!

This rule scans all use of arguments inside function heads and bodies, and will suggest narrowing down any argument passed to the minimal value which the function depends on. Incrementally defined functions are scanned in their entirety to make sure the narrowing is valid for all definitions. Example:

package policy

country_code(user) := 61 if user.country == "Australia"
country_code(user) := 81 if user.country == "Japan"

In the above example, the functions only depends on user.country, and this rule (when enabled) will thus recommend narrowing the argument passed:

package policy

country_code(country) := 61 if country == "Australia"
country_code(country) := 81 if country == "Japan"

Instead of passing around potentially large user objects, our function now only needs to consider a country string, which perhaps may prove useful for more than just users. Another benefit of this approach is that it's often possible to simplify functions even further by moving the equality comparison directly into the function's arguments — a simple form of pattern matching:

package policy

country_code("Australia") := 61
country_code("Japan") := 81

Reference prefix narrowing

So far we have looked only at functions using an identical reference to one of its arguments. That's not always the case, but it doesn't mean the value passed can't be narrowed! Consider the following example:

package policy

internal_user(context) if endswith(context.user.email, "@acmecorp.com")
internal_user(context) if "staff" in context.user.roles

In the example above, the narrow-argument rule would point out that while two different references to the context argument are used, they both have the context.user prefix in common, and the value passed could thus be narrowed to that:

package policy

internal_user(user) if endswith(user.email, "@acmecorp.com")
internal_user(user) if "staff" in user.roles

Caveats

Narrowing the types passed as function arguments may come with unintended and/or undesired side-effects. More specifically, the way OPA evaluates functions means that arguments are evaluated before the function is called. "Big" objects, like a user tend to be less likely to be undefined than e.g. a user.fax attribute. Aborting evaluation only because a user is without a fax machine is probably not what we want! But could be an unfortunate consequence of our change unless we are careful (or better, have extensive test coverage). Consider the following example:

package policy

is_unreachable(user) if {
not has_phone(user)
not has_fax(user)
}

has_phone(user) if
is_string(user.phone)
user.phone != ""
}

has_fax(user) if {
is_string(user.fax)
user.fax != ""
}

While it's tempting to try and narrow the arguments passed to has_phone and has_fax only to what they need:

package policy

is_unreachable(user) if {
not has_phone(user.phone)
not has_fax(user.fax)
}

has_phone(phone) if
is_string(phone)
phone != ""
}

has_fax(fax) if {
is_string(fax)
fax != ""
}

We have now changed the behavior of is_unreachable, and a user without phone or fax will no longer be considered unreachable. Why? Again, because OPA evaluates the function arguments before they are passed to the function, and before the result is negated by not, an expression like:

not has_phone(user.phone)

Will be rewritten by OPA to something like this:

arg1 := user.phone
not has_phone(arg1)

If the user.phone attribute doesn't exist, evaluation will never reach the next line where the function is called!

Before narrowing arguments, always consider the impact of undefined values, negation and how functions are evaluated. And make sure to not rewrite any function that isn't extensively covered by unit tests! With that said, there are often ways to deal with undefined attributes even when passing narrower argument types. In the example above, we could for example rewrite is_unreachable to is_reachable, and then use not to negate that to answer if the user is impossible to reach.

Find what works best for you, and use the exclude-args configuration option (see below) to exclude arg names that you commonly don't want to narrow, or ignore directives for single locations.

Configuration Options

This linter rule provides the following configuration options:

rules:
custom:
narrow-argument:
# note that all rules in the "custom" category are disabled by default
# (i.e. level "ignore")
#
# one of "error", "warning", "ignore"
level: error
# exclude args by name
# example below excludes any argument named 'config' or 'user'
exclude-args:
- config
- user

Community

If you think you've found a problem with this rule or its documentation, would like to suggest improvements, new rules, or just talk about Regal in general, please join us in the #regal channel in the Styra Community Slack!