Authorization
Published
A role-based policy is usually a list of small objects: an action, a subject, some conditions, a field list, and a flag that decides whether the rule permits or forbids. Every key on that object narrows something, and the engine's default for a missing key is the permissive one. That makes a policy rule the densest arrangement of security decisions per property name in a normal application. We protected a file that defines one, renamed the keys a group at a time, and read back who could do what.
What the sample actually does
The file defines four rules for a finance user in one organisation: read invoices in this organisation and only these five columns, update invoices in this organisation while they are still drafts, delete invoices in this organisation, and - authored last, the way these policies are normally written - a prohibition on deleting an invoice that has been paid, carrying a reason string for the audit trail.
Six decisions are then requested, arranged so that each key in the policy decides exactly one of them: reading an invoice in the user's own organisation, reading one in somebody else's, updating a paid invoice, deleting a draft, deleting a paid invoice, and reading a restricted column. The sample also prints two values that leave the process: the row filter the policy hands to the database for a list query, and the field list serialised into an API response.
The authorization engine is copied in unprotected. Its defaults are the permissive ones this family of libraries documents: a rule with no conditions matches every instance of the subject, a rule with no field list covers every field, and a rule with no inversion flag is a permission rather than a prohibition.
Protection alone was applied first on five presets. All five behaved identically to the unprotected file, so nothing in this article is caused by protection by itself.
A prohibition quietly became a permission
Renaming inverted is the single most compact result this series has produced. That key is the only thing distinguishing a rule that forbids from a rule that allows. It sits on the object next to three keys that read exactly the same way, and it is a boolean.
With it renamed, the rule that says a paid invoice cannot be deleted became a rule that says a paid invoice can be deleted. The decision went from allowed=false with the reason string the policy author wrote to allowed=true with the reason permitted. The audit trail records an approval. There is no error and no warning, because from the engine's point of view a rule matched and permitted the action, which is a perfectly ordinary thing for a rule to do.
This is a different failure shape from the ones this series has catalogued so far. A lost option that reverts to a default loses a narrowing. A lost option that throws is loud. A lost key that is serialised into an output surface is ignored downstream. This one reverses the meaning of the object it sits on, and the reversal is expressed as a successful, well-formed, logged approval.
The mitigation is worth stating precisely because it is unusual: express prohibitions so that losing a key fails closed. A policy that lists what is permitted and denies by default cannot suffer this, because a rule whose meaning is lost simply stops matching. A policy that permits broadly and then subtracts with prohibitions has a single boolean holding the subtraction.
The row filter stopped filtering, and one prohibition over-tightened
Renaming conditions is the widest result in the area and it moves in two directions at once, which is what makes it interesting.
In the permissive direction: reading an invoice belonging to another organisation went from refused to allowed=true, updating a paid invoice went from refused to permitted, and the row filter the policy hands to the database went from orgId=org-7 to the string this sample prints as (none - every row). A tenant boundary implemented as a policy condition ceased to exist. Nothing in the application failed; the finance user simply began seeing an unbounded list.
In the restrictive direction, in the same run: deleting a draft invoice went from allowed to refused. The prohibition on deleting paid invoices lost its condition too, so it stopped meaning "paid invoices" and started meaning "invoices", and it now beats the delete permission on every row. One rename simultaneously widened three decisions and over-tightened a fourth.
The over-tightening is the only part anyone would notice. A finance user who cannot delete a draft invoice files a support ticket that afternoon. Nobody files a ticket because a list has more rows in it than it should, and the two symptoms have the same cause. If you ever investigate the first, check the second in the same sitting.
The field list, the reason string, and the loud arms
Renaming fields took the readable-column list from five columns to all seven, adding the two the policy exists to withhold: a bank account number and internal notes. The decision for a direct read of the restricted column went from refused to permitted. The row was always retrievable; what the field list controlled was how much of it was serialised, and that control is one property name.
Renaming reason leaves every decision correct and empties the explanation. The refusal still happens, and the audit record carries the engine's placeholder instead of the sentence the policy author wrote. That is the output-surface shape this series has seen before: nothing is less safe, and every human and every dashboard downstream is now reading a policy that will not say why.
Renaming action or subject is the loud direction. No rule matches anything, every decision becomes no-rule-matched, the readable field list comes back empty and the row filter comes back unbounded. That last detail deserves attention: an engine with no matching rules denies every decision, but a filter builder that collects conditions from matching rules returns nothing to filter on. Deny-everything and filter-on-nothing are the same state expressed in two subsystems, and only one of them is safe.
The control arm that produced the sharpest secondary result
One arm was included as a control: the names of the condition keys themselves, the organisation identifier and the status. The sample writes those names on the rule objects and it also writes them on the resource objects, so both sides of every comparison are inside the protected bundle. The prediction was that every decision would be unchanged, and every decision was.
The decisions were not the whole story. The row filter the policy hands to the database came out as _0x1=org-7. The comparison inside the process is consistent and correct; the moment that filter is serialised and sent to a database, it names a column that does not exist. Depending on the driver that is an error, an empty result set, or - in a document store that tolerates unknown fields - a query with no tenant restriction at all.
That is the same lesson a control arm produced two passes ago in a different area, and it is now worth stating as a rule of its own: a name your own code owns on both sides is safe inside the process and unsafe the moment it is written into anything that leaves it. Query filters, log fields, metric labels, response bodies and header values are all the outside.
The second control - the resource's own identifier and total columns - measured genuinely inert, because the policy only ever names those columns as strings in a list and string contents are not renamed.
What to do about it
None of this is a defect in the obfuscator, and none of it is specific to one authorization library. Member renaming rewrites property names in the code it is given; a policy object is data handed to an engine that reads fixed names, and an engine that receives a key it does not recognise applies the documented default. The defaults are permissive because a rule that names nothing has to mean something, and "applies broadly" is the only sensible reading.
Scope the renaming. RenameMembers takes a MemberRegexp, and the keys of policy rule objects belong outside it. Alternatively write policy rules as data - JSON loaded at runtime, or a table in your database - which takes them out of the transform entirely and is usually the better design anyway.
Then add the test that would have caught all of this: assert on decisions, not on the presence of a policy. Two assertions cover the whole article. Ask for something the policy forbids and require a refusal with the specific reason string. Ask for a list and require the row filter to be exactly the tenant condition you configured, checked as a value rather than as a truthy object. A test that only checks the permitted path passes on every arm measured here.
Frequently asked questions
Does protecting my JavaScript change authorization decisions on its own?
Not in this measurement. Protection alone was applied on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result here required member renaming aimed at the rule keys.
How can a deny rule turn into an allow rule?
The flag that marks a rule as a prohibition is one property name on the rule object. If the engine cannot find it, the rule is a permission, because that is what a rule with no inversion flag means. The decision that follows is a normal, well-formed approval with no error attached.
Why did losing the conditions key make one action stricter and three looser?
Because conditions narrow prohibitions as well as permissions. The permissions lost their tenant and status restrictions and started matching everything; so did the prohibition, which then beat the delete permission on every row. Only the stricter symptom generates a support ticket.
Does an empty row filter mean the query fails?
Not usually. A filter with no conditions is a valid query that returns every row the caller's connection can see. That is why this arm is quiet: the application keeps working and the list is simply larger than the tenant boundary intended.
Our condition keys are written and read entirely in our own code. Are we safe?
Inside the process, yes - both sides are renamed consistently. The measured caveat is that the row filter built from those keys is serialised and sent to a database, where the renamed key names a column that does not exist. A name is only self-owned until it is written into something that leaves the process.
What is the cheapest detection for this?
Assert on a refusal, not on a permission. Ask the policy for something it forbids and require both the refusal and its specific reason string, and separately assert that the row filter equals the tenant condition you configured. Run it against the protected artifact.
Is there a policy style that resists this?
Deny by default and enumerate permissions. A rule whose meaning is lost then stops matching and the decision falls through to a denial. A policy that permits broadly and subtracts with prohibitions has its entire subtraction resting on a single boolean key.
Related reading