DEV Community

authagonal
authagonal

Posted on • Originally published at authagonal.io

We turned off a dangerous default without migrating a single row

Just-in-time provisioning is the feature that makes enterprise SSO feel like magic. A new employee signs in through their company's identity provider, no account exists for them in your app yet, and one is created on the spot from the assertion. Nobody files a ticket, nobody gets invited, the person just works.

It is also, read from the other direction, a feature that lets whoever controls that identity provider create accounts in your customer's tenant by asserting that a person exists. That is fine when the connection is scoped tightly to one company's directory and every human in it should have access. It is less fine when the connection is a shared directory, or a contractor tenant, or one of those sprawling federations where the set of people the identity provider is willing to vouch for is much larger than the set of people your customer meant to let in.

Ours defaulted to on. Not because anybody decided it should, which is the part worth dwelling on. It defaulted to on because when the field was added, the boolean that expressed it was called DisableJitProvisioning, and an unset boolean is false, and false meant "do not disable." The safest reading of a default nobody chose is that it is an accident, and this one had settled in as behaviour.

To be precise about the exposure, because it was never as bad as "anyone can create anyone": two gates already ran before provisioning. A connection can carry a list of allowed email domains, and an assertion outside them is rejected. A connection can require an invitation attribute, and an uninvited user is rejected. The real default-on risk was a connection with neither of those configured, which is exactly the shape of a connection somebody set up quickly to get SSO working.

Flipping the default is one word. Flipping it safely is not.

The change everyone imagines is renaming the field to JitProvisioningEnabled and letting it default to false. New connections are secure by default, done.

Except that field is persisted, and there are connections in storage that were written before it existed. Their rows do not have the column at all. What happens to them depends entirely on which direction the boolean points, because a missing column deserializes to false either way. Under the old negative name, missing means "not disabled" and provisioning continues. Under a new positive name, missing means "not enabled" and provisioning stops.

So a straight rename silently turns off just-in-time provisioning for every connection a customer configured back when it was on. They chose nothing, they were not told, and the first they hear about it is an employee who cannot sign in, at whatever hour that happens. That is not a security improvement, it is an outage delivered by deploy.

The obvious answer is a backfill: walk every stored connection, write the column explicitly, then flip the default. It works, and it is a migration you have to write, test, run against every tenant's storage, and be sure completed everywhere before the code that depends on it ships. For a boolean.

The double negative

We did not write the migration. The stored column keeps its old negative meaning forever, and the model gains a positive property in front of it:

public bool JitProvisioningEnabled { get; set; }

public bool DisableJitProvisioning
{
    get => !JitProvisioningEnabled;
    set => JitProvisioningEnabled = !value;
}
Enter fullscreen mode Exit fullscreen mode

The positive property is the real one, with real storage behind it, and it defaults to false, which is the new safe default. The negative name is now a computed alias that inverts in both directions.

Follow an old row through. The column is missing, so it reads as false, so the setter for DisableJitProvisioning runs with false, so JitProvisioningEnabled becomes true. The connection keeps provisioning, exactly as its owner configured it, and nothing was migrated. Follow a new connection through. Nobody sets either property, JitProvisioningEnabled stays at its default of false, and the connection rejects unknown users until somebody opts in.

Both behaviours come out of the same code with no branch, no version flag, and no data touched. The persisted bit never changed meaning. Only the field it lands in did, and the inversion happens in a property setter that runs on every load.

What it cost

This is not free, and the bill arrives at the API boundary. Both properties are public, so both get serialized, and a client that reads a connection, changes something, and writes it back is now sending two properties that describe the same thing. Deserialization applies them in the order they appear in the payload, so the last one wins. Set the positive property to true while leaving a stale negative one in the object you fetched, and your change is silently undone by a field you did not think you were sending.

We found that the way you find these things, in a test that flipped the flag on and then asserted it was on. The rule that came out of it is to set both forms explicitly on any read-modify-write, which our own end-to-end test now does with a comment explaining why. If you adopt this trick, budget for that. A two-way alias buys you a free migration and charges you an ambiguity on the wire.

The bug the flip uncovered

Here is the part that generalises past booleans. While making the change, we found that the admin endpoint for creating an OIDC connection had never set this flag at all. Not incorrectly, not to the wrong value. It simply never assigned it, and the request object had no field for it to assign.

That was invisible for as long as the default was the value everyone wanted. Every connection came out provisioning, which is what the code that forgot to wire it would have produced anyway, so there was nothing to notice and no test that could have failed. The moment the default flipped, that same gap became "every newly created OIDC connection has provisioning off and no way to turn it on," which is not a subtle bug at all.

A default is the value of every code path that forgot to set the field. While the default is convenient, those paths are indistinguishable from the ones that set it deliberately. Changing a default does not just change new behaviour, it develops the photograph: everything that was silently relying on the default becomes visible at once, and some of it is broken.

The checkbox that did not move

The last place a default hides is the user interface. Our portal had a checkbox that read "Disable JIT provisioning," unchecked by default. It now reads "Enable JIT provisioning," and it is still unchecked by default. Same widget in the same position with the same initial state, and the opposite meaning.

That is a genuinely dangerous kind of change, so the list view gained a badge. Any connection not opted in is now labelled, so the state is visible without opening anything, rather than being inferred from an unchecked box that used to mean the other thing.

And when a connection with provisioning off receives an assertion for somebody unknown, the user is not dumped on a stack trace. They go back to the application they came from with an error that says the account was not found and to contact their administrator, which is the true and actionable version of what just happened.

The lesson, factored out

Defaults are API surface. They are inherited by stored rows that predate the field, by configuration files that omit it, by code paths that never set it, and by user interface controls whose unchecked state encodes them. Before you move one, enumerate those four populations and decide, for each, whether it should follow the new default or keep the old behaviour. Usually the answer is different for each, and that is the design work.

And if you find you are about to write a data migration to move a boolean, look first at whether the meaning can stay put while the name and the default move in front of it. Storage is the expensive place to change your mind. A property setter is the cheap one.

If you would rather your identity provider shipped with the careful defaults already chosen, Authagonal makes every SSO connection opt in to provisioning, and tells you plainly which ones have.

Top comments (0)