Skip to content

Changelog - 2026-08-30 ​

PolicyDefinition.variant Accepts App-Declared Extra Values ​

Enhancement

In one line. extraPolicyDefinitionColumns now takes an extraVariants option, so an application can store its own edge kinds in the same PolicyDefinition table without losing the compile-time check that catches a wrong variant value.

The problem it solves ​

Three wrong vocabularies for the variant column had shipped in docs and an example seed script over time: 'p'/'g' (casbin's own rule prefixes) and 'group'/'policy'. A wrong value is not a startup error - ScopedCasbinAdapter filters every query with an explicit variant = <value> or variant IN (...), so a typo just selects zero rows. The result is a permanent 403 with nothing in any log to point at why.

kernel@0.2.0-6 closed that gap by narrowing variant's type to IGNIS's seven values (grant, assign_role, role_inherits, join_domain, domain_inherits, resource_inherits, action_inherits). A typo became a compile error.

But the narrowing over-reached: it said only IGNIS may define a variant, which was never the intent. An application can have its own edge type stored in the same table - ScopedCasbinAdapter never reads it, so storing it alongside the seven IGNIS kinds was always safe. After 0.2.0-6, declaring that type became impossible without reaching for an unsafe cast.

What changed ​

  • extraPolicyDefinitionColumns takes a new extraVariants option, an array of the application's own variant strings. The variant column's type becomes IGNIS's seven values plus whatever the array lists.
  • The default is unchanged. Call extraPolicyDefinitionColumns() or extraPolicyDefinitionColumns({ idType: 'string' }) exactly as before, and the column still accepts only the seven IGNIS values - 'group', 'policy', 'p', and 'g' are still compile errors.
  • No as const needed at the call site. The array is inferred as a literal tuple automatically.
typescript
// An application with its own `merchant_role` edge, alongside IGNIS's seven:
extraPolicyDefinitionColumns({ idType: 'string', extraVariants: ['merchant_role'] });

Who is affected ​

  • Every application that calls extraPolicyDefinitionColumns() today. No action needed - the default shape is exactly what 0.2.0-6 shipped.
  • An application storing its own edge kind in PolicyDefinition, blocked by 0.2.0-6's narrowing. Add extraVariants with that value. AuthorizationPolicyBuilder's output (grant(), assignRole(), and the rest) keeps assigning cleanly to the column either way.

Expect the errors to move outward, not disappear ​

A narrowed column does not only reject a wrong value at the line that writes it. It rejects every loose type on the path that carries the value there, and those surface one layer at a time. Two shapes account for most of it, both reported from a real migration:

A container typed loosely. const rows: Array<Record<string, unknown>> = [], filled in a loop, then passed to createAll. Record<string, unknown> no longer satisfies the narrowed parameter. Nothing here is wrong about the value - only about how loosely it was described. This is a common shape wherever an application sorts rows into create-versus-update batches before writing.

A widened field restated across service layers. A field declared effect?: string on a request type, then again on each service method that forwards it, lets any string travel the whole way and only meet a real type at the column. Tightening the innermost layer moves the error out one layer; tightening that one moves it out again. A four-layer chain therefore takes four build rounds, and each round looks like a new failure.

Fix these at the source - the type where the value first enters - rather than casting at the write. Casting silences the layer you are looking at and leaves the rest of the chain accepting anything. None of these are new defects: they are pre-existing gaps that nothing had ever been strict enough to catch.

Details ​

  • effect is not getting the same treatment. variant is a discriminator ScopedCasbinAdapter filters on - an unknown value is simply never selected, which is exactly what made the original bug silent and safe to widen back open. effect is different: its value (allow / deny / abstain) is written directly into the raw casbin policy line and read by casbin's own effect evaluator. An application-defined fourth value would not be filtered out - it would reach the evaluator and produce an authorization decision nobody defined the meaning of. That is a correctness risk in the enforcement path itself, not a harmless unselected row, so effect stays closed to IGNIS's three values.

  • AuthorizationPolicyBuilder.grant() and .customGrant() now declare an explicit return type. Without one, TypeScript's return-type inference widened effect from TAuthorizationDecision to plain string for these two methods (a general quirk of conditional-type-derived literal unions passed through an unannotated method return), which would have made a correctly-typed row fail to assign to the narrowed column. assignRole() and the other builder methods carry no effect field and were unaffected.

  • The check covers the write path, not the filter path. variant is checked wherever a row is written - $inferInsert, and AuthorizationPolicyBuilder's output - which is where all three wrong vocabularies were authored: a seed script and doc examples both write rows. It is NOT checked inside a where clause. TWhere<T> types every value as any, so where: { variant: 'joindomain' } compiles and returns zero rows in silence. A filter typo still fails the same quiet way a seed typo used to. Stated because "the column is typed now" reads like both paths are covered, and one is not.

FilePackage
packages/core-server/src/components/auth/models/entities/policy-definition.model.tscore-server
packages/kernel/src/base/auth/authorize/builders/policy.builder.tskernel

See also ​