BetterIAM

Feature flags

Turn product features on and off per organization without a deploy, from the platform or each organization, and gate them in the UI and in authorization.

new@better-iam/server@better-iam/react@better-iam/clientfeature-flags.mdfeatures.tsfeatures.tslifecycle.ts

A new feature rarely reaches every customer on the same day. You want to try it with a design partner first, roll it out to a quarter of your customers, let each organization opt in to a beta, and switch it off everywhere in seconds when something goes wrong, all without a deploy. Feature flags do that, and because they live in Better IAM next to your organizations, a flag can decide what the UI shows and what authorization allows with the same switch.

Every flag is a boolean, and flags work at two levels:

  • Platform flags are defined on the root tenant by root administrators (or anyone the root tenant grants iam:features:manage). They reach every organization and project.
  • Organization flags are defined by an organization (or a project) for its own subtree, for example to roll a feature out to some of its projects, or to let each project choose.

Flags are per tenant. To entitle individual people, combine a flag with license seats (see Licenses).

Setup

Feature flags are always on: the features API group and iam.features exist on every instance, and there is no option to set.

Migrate

Flags, targets, and overrides are stored in the shared iam_records table, which the ordinary migration creates.

npx better-iam migrate --config better-iam.config.mjs

Create the client and the provider

Browsers read flags through the typed client, and React components through hooks rendered inside IamProvider.

app/providers.tsx
import { createIamClient } from 'better-iam/client';
import { IamProvider } from 'better-iam/react';
import type { ReactNode } from 'react';
import type { iam } from '../iam';

const client = createIamClient<typeof iam>({ baseURL: 'https://app.acme.test' });

export function Providers({ children }: { children: ReactNode }) {
  return <IamProvider client={client}>{children}</IamProvider>;
}

How a value is decided

A flag's value for a tenant comes from the first of four rules that applies:

  1. Kill switch. killSwitch: true turns the flag off everywhere. Targets and overrides stay stored and apply again when the switch is lifted.
  2. The closest target or override. Walking from the tenant up to (but not including) the tenant that defines the flag, the first tenant with a value decides:
    • A target is a value the flag's managers pin for a tenant below them with features.setTarget, such as "on for Acme until the end of the trial". It applies to that tenant and everything below it, unless a closer target or override decides, and it can lapse (expiresAt).
    • An override is a tenant's own choice with features.setOverride, allowed only for flags defined with tenantOverridable: true. On the same tenant, the tenant's override beats an unlocked target. Overrides are refused for internal flags and on the tenant that defines the flag (change the flag itself there with features.update). Withdrawing a choice with value: null is always allowed, even while locked.
    • A locked target (locked: true) silences overrides at its tenant and below, so an organization cannot undo it. A closer target from the flag's own managers still wins over it.
  3. Rollout. rolloutPercentage (0 to 100) turns an off-by-default flag on for a stable share of the branches directly below the defining tenant, and off for the rest. For a platform flag those branches are organizations, so an organization's projects always land on the same side as the organization. Raising the percentage only adds tenants; nobody who had the feature loses it. rolloutBucket(key, tenantId) from @better-iam/server shows where a tenant falls.
  4. Default. When no rollout is set, defaultValue.

A flag with a rollout must default to off (defaultValue: false), because the rollout is the share that is turned on. Each evaluation reports its reason: KILL_SWITCH, TARGET, OVERRIDE, ROLLOUT, DEFAULT, or UNKNOWN (no flag with that key reaches the tenant).

Keys belong to the tenant closest to the root. A tenant cannot define a key an ancestor already defines, and a platform flag created later takes precedence over an organization flag with the same key (features.list shows the organization's flag as shadowed). So an organization can never switch a platform-gated feature on for itself. Keys start with a lowercase letter and join lowercase letters and digits with single -, _, or . characters (new-billing, reports.v2; not 1st, a--b, or trailing-), up to 64 characters, with at most 200 flags per tenant.

Usage

Define a flag

features.create (iam:features:manage) defines a flag in the tenant you name; a platform flag is defined on the root tenant. features.update changes settings (omitted fields are kept, and null clears description or rolloutPercentage), and features.delete removes the flag with all its targets and overrides, after which code that asks for it gets false.

const root = { token: rootSessionToken };

// A platform flag that organizations may turn on for themselves.
await iam.api.features.create(root, {
  tenantId: rootTenantId,
  key: 'new-billing',
  description: 'The redesigned billing pages',
  tenantOverridable: true,
});

// A gradual rollout to a quarter of all organizations.
await iam.api.features.update(root, { tenantId: rootTenantId, key: 'reports-v2', rolloutPercentage: 25 });

Route: POST /api/iam/features/create (also /features/update, /features/delete)

Target a tenant

features.setTarget (iam:features:manage in the defining tenant) pins a value for a tenant below the defining one; value: null removes it. features.listTargets (iam:features:read in the defining tenant) lists every target and override below the defining tenant, with active for those that count right now.

// A two-week trial for one organization.
await iam.api.features.setTarget(root, {
  tenantId: rootTenantId,
  key: 'fast-search',
  targetTenantId: acmeId,
  value: true,
  expiresAt: Date.now() + 14 * 86_400_000,
  note: 'Design partner trial',
});

const targets = await iam.api.features.listTargets(root, { tenantId: rootTenantId, key: 'fast-search' });

Route: POST /api/iam/features/setTarget (also /features/listTargets)

Target notes are for the flag's managers. An organization sees that a target exists, its value, whether it is locked, and when it ends, but never the note.

Let an organization choose

features.setOverride (iam:features:override, authorized in the tenant making the choice) records a tenant's own value for a flag defined with tenantOverridable: true. It answers FEATURE_LOCKED when the flag does not allow overrides or a locked target covers the tenant.

// Acme's administrators opt in to the new billing pages.
await iam.api.features.setOverride(acmeAdmin, { tenantId: acmeId, key: 'new-billing', value: true });
// And return to the default later.
await iam.api.features.setOverride(acmeAdmin, { tenantId: acmeId, key: 'new-billing', value: null });

Route: POST /api/iam/features/setOverride

Turn a feature off everywhere

The kill switch is a flag setting, so lifting it restores every target and override.

await iam.api.features.update(root, { tenantId: rootTenantId, key: 'fast-search', killSwitch: true });

Route: POST /api/iam/features/update

List the flags that reach a tenant

features.list (iam:features:read) returns the flags that reach the tenant, each with its evaluation, the tenant's override, and its target, plus the tenant's own flags that an ancestor shadowed.

const { flags, shadowed } = await iam.api.features.list(acmeAdmin, { tenantId: acmeId });

Route: POST /api/iam/features/list

Read flags in your application

On the server, deployment code reads flags without a credential, internal flags included:

import { iam } from './iam';

if (await iam.features.isEnabled(tenantId, 'new-billing')) showNewBilling();
const values = await iam.features.values(tenantId); // { 'new-billing': true, ... }
const [detail] = await iam.features.evaluate(tenantId, { keys: ['new-billing'] });
// { key, value, reason: 'OVERRIDE', scope: 'platform', definedBy, decidedBy, locked, overridable }

From a browser or another service, features.evaluate needs only a credential of the tenant: a user session, an API key, a role session, or a session token whose tenant it is (root administrators may evaluate any tenant). It is not audited and leaves out ancestors' internal flags:

const { flags } = await iam.api.features.evaluate({ headers: request.headers }, { tenantId });

Route: POST /api/iam/features/evaluate

The hooks return false while loading, when signed out, and for unknown keys. Hiding a button is not enforcement, so gate the server side as well, in code or in a policy.

Internal flags

Internal flags (internal: true) are for trusted server code and policies only. They are hidden from features.evaluate and from the features.list of tenants below the defining one (root administrators still see them). Targets can reach them, but tenants cannot override them, so tenantOverridable is refused for them.

Each flag is the resource iam/features/{key} (features.list uses iam/features), so a policy can delegate one flag: for example iam:features:override on iam/features/new-* for a product team. Every change is audited (feature:create, feature:update with the settings before and after, feature:delete, feature:target, feature:override) and reaches webhooks like any audit event.

Flags in policies

Decisions see the keys of every flag that is on for the decision's tenant, internal flags included, as the list tenant.features. A policy can then allow an action only where a feature is on:

Exports only where the exports feature is on
{
  "effect": "allow",
  "actions": ["documents:export"],
  "resources": ["*"],
  "conditions": { "ArrayContains": { "tenant.features": "exports" } }
}

The server reads flags only for decisions whose documents name tenant.features, so policies that do not use flags pay nothing. Applications cannot supply the key through resolveContext or plugins, because the server removes it. Policy lint treats it as a list, so test it with ArrayContains, not string operators. policies.test fills it with the tenant's current flags when the candidate document names it, and a tenant.features value you pass in the test's context takes precedence.

Options

There is no server option for feature flags. Each flag carries these settings (features.create, features.update):

OptionTypeDefaultDescription
keystringrequiredLowercase flag key, up to 64 characters. Never changes.
descriptionstringnoneUp to 512 characters; null clears it on update.
defaultValuebooleanfalseThe value when no target, override, or rollout decides.
rolloutPercentagenumbernone0 to 100; requires defaultValue: false; null clears it on update.
tenantOverridablebooleanfalseTenants below may choose their own value. Refused for internal flags.
killSwitchbooleanfalseOff everywhere, whatever targets and overrides say.
internalbooleanfalseOnly server code and policies see the flag.

Targets (features.setTarget) take targetTenantId, value (null removes the target), locked, expiresAt, and note (up to 512 characters).

Permissions

ActionWhereMethods
iam:features:manageThe defining tenantcreate, update, delete, setTarget
iam:features:readThe tenant read (listTargets: the defining tenant)list, listTargets
iam:features:overrideThe tenant making the choicesetOverride
NoneAny credential of the tenantevaluate

Schema

Records live in the iam_records table under two tenant-scoped collections. A target or override lives in the tenant it applies to, so purging that tenant removes it; deleting the flag removes all of them.

CollectionHoldsKey fields
featureFlagsA flag the tenant defineskey, description, defaultValue, rolloutPercentage, tenantOverridable, killSwitch, internal, createdAt, createdBy, updatedAt, updatedBy
featureTargetsA target or override for one tenant and its descendantsflagId, key, definerId (the defining tenant), source (target or override), value, locked, expiresAt, note, setAt, setBy

Errors

CodeStatusWhen
FEATURE_LOCKED409setOverride on a flag that does not let tenants choose, or under a locked target
CONFLICT409The key exists in the tenant, or an ancestor (such as the platform) already defines it
LIMIT_EXCEEDED409More than 200 flags in a tenant
NOT_FOUND404An unknown flag, or an internal flag of another tenant
ACCESS_DENIED403Missing permission, or evaluate with a credential of another tenant
INVALID_INPUT400A malformed key, a rollout with defaultValue: true, an override on the defining tenant, a target for the defining tenant itself or a tenant outside its subtree, or tenantOverridable on an internal flag
INVALID_TRANSITION409Changing flags of a deleted tenant

In the console

  • Administration → Feature flags (root): platform flags with their settings and kill switch, every target and organization choice, a form to target an organization or project (optionally locked or lapsing), and new flags.
  • Organization → Features: the platform's flags as they apply to the organization, with Turn on, Turn off, and Use default where the platform allows a choice, plus the organization's own flags with per-project targets.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page