Skip to main content

Validation

WIP

validatePolicy, validateRoles, validateRole, parsePolicyRow, parseRoleRow - every issue code, every message string, and the limits they enforce

@gentleduck/iam/core/validate deep-checks policies and roles that came from somewhere you do not fully control: an admin dashboard, a hand-written JSON file, a column in a shared database, a migration tool, a staging preview endpoint. Every validator returns the same IamValidate.IResult shape with machine-readable issue codes, so callers can branch on the code instead of parsing prose.

Import path: not re-exported from core

// runtime: the subpath entry, always
import { validatePolicy, validateRoles, parsePolicyRow } from '@gentleduck/iam/core/validate'

// types: either spelling works
import type { IamValidate } from '@gentleduck/iam'

The package publishes five core subpaths: core, core/validate, core/builder, core/explain and core/schema.

You still get validation without importing anything: PolicyBuilder.build() and RoleBuilder.build() call the validators internally and throw on any error, and engine.admin.savePolicy / saveRole / import load the chunk with a dynamic import() on the write path. The explicit import is for your boundaries.

Where validation sits

Loading diagram...

The validator runs at three moments: when a builder builds, when an admin writes, and when one of the file, HTTP, Redis, Drizzle or Prisma adapters reads a row back. parsePolicyRow is the read-path form: it returns the typed row or null instead of a result object.

The two asymmetries there matter more than the shared shape.

A null from parsePolicyRow is refused, not skipped: the adapter reports the row and then throws iamUnreadablePolicy, so one bad policy row denies every request until it is repaired. A null from parseRoleRow drops that role and continues. The reason is which direction each row can fail in: rolesToPolicy emits allow and nothing else, so a lost role can only cost a subject a grant, while a lost policy may have been the rule saying no.

API reference

validatePolicy

export function validatePolicy(input: unknown): IamValidate.IResult

Deep-validates an untrusted candidate policy. Accepts unknown: the first check is that the input is a non-null, non-array object. Never throws; every problem comes back as an issue.

What it checks, in order:

CheckSeverityCode
Input is a plain objecterrorINVALID_TYPE (returns immediately)
id is a non-empty stringerrorMISSING_FIELD
name is a non-empty stringerrorMISSING_FIELD
algorithm is one of the four valid nameserrorINVALID_ALGORITHM
version, if present, is a numbererrorINVALID_TYPE
rules is an arrayerrorMISSING_FIELD
rules.length within POLICY_LIMITS.rulesPerPolicyerrorLIMIT_EXCEEDED
Each rule's shape (see below)error/warningvarious
Duplicate rule idswarningDUPLICATE_RULE_ID
targets, if present, is a non-array objecterrorINVALID_TYPE
targets.actions / .resources / .roles are arrayserrorINVALID_TYPE
Every targeted (action, resource) pair is covered by an allow ruleerrorUNREACHABLE_TARGET

Per rule, validateRuleShape adds:

CheckSeverityCode
Rule is an objecterrorINVALID_RULE
id non-empty stringerrorMISSING_FIELD
effect is allow or denyerrorINVALID_EFFECT
priority is a finite numbererrorINVALID_TYPE
actions is a non-empty array of strings, within actionsPerRuleerrorMISSING_FIELD / LIMIT_EXCEEDED / INVALID_TYPE
resources is a non-empty array of strings, within resourcesPerRuleerrorMISSING_FIELD / LIMIT_EXCEEDED / INVALID_TYPE
conditions key is presenterrorMISSING_FIELD
actions.length * resources.length within cartesianPerRuleerrorLIMIT_EXCEEDED
Unconditional allow * on *warningBROAD_ALLOW
Condition tree (see below)error/warningvarious

Per condition leaf:

CheckSeverityCode
field is a non-empty stringerrorMISSING_FIELD
field.length within MAX_FIELD_LENGTHerrorLIMIT_EXCEEDED
field root resolves (subject, resource, environment, or the action / scope shorthands)warningUNRESOLVABLE_FIELD
operator is in VALID_OPERATORSerrorINVALID_OPERATOR
Any operator but exists / not_exists carries a valueerrorMISSING_VALUE
The operand's type matches what the operator compareserrorOPERAND_TYPE_MISMATCH
No key outside the leaf shapeerrorUNKNOWN_FIELD
String value within MAX_CONDITION_VALUE_LENGTHerrorLIMIT_EXCEEDED
Array value: first oversized string element onlyerrorLIMIT_EXCEEDED
$-prefixed value resolveswarningUNRESOLVABLE_VALUE
matches with a literal string pattern passes detectCatastrophicRegexerrorERR_REGEX_CATASTROPHIC
matches pattern compileserrorERR_REGEX_INVALID
matches pattern is not $-sourced from the requesterrorERR_REGEX_USER_SOURCED

MISSING_VALUE covers both value absent and value: undefined; JSON.stringify drops the latter, so the two arrive at a store identically. Both then read as null at evaluation, which compares equal to a missing attribute; the guard passes for exactly the subjects it was written to exclude.

The operand-type check is skipped when the value is a $-prefixed string, because its type is unknowable until the request arrives. For array operands the elements are checked too, not just the container: nin: 'gold' is a mismatch, and in: [{ id: 1 }] matched nothing by reference and quietly retired the rule holding it. The table it checks against is OPERAND_TYPES in core/conditions, the same one evalCondition applies at read time; one table, so write-time and read-time verdicts cannot drift.

UNKNOWN_FIELD fires on the five places POLICY_JSON_SCHEMA sets additionalProperties: false: the policy, its targets, each rule, each leaf condition and each condition group. A key explicitly set to undefined is not reported, since JSON.stringify drops it before it reaches a store or an external validator.

Per condition group:

CheckSeverityCode
Nesting within MAX_CONDITION_DEPTH (10)errorLIMIT_EXCEEDED
Group is an objecterrorINVALID_CONDITION
Group has exactly one of all / any / noneerrorINVALID_CONDITION
The group key's value is an arrayerrorINVALID_CONDITION
import { validatePolicy } from '@gentleduck/iam/core/validate'
import type { AccessControl } from '@gentleduck/iam'

const row: unknown = await db.policy.findUnique({ where: { id } })
const result = validatePolicy(row)

if (!result.valid) {
  for (const issue of result.issues) {
    console.error(`[${issue.type}] ${issue.code} at ${issue.path ?? '<root>'}: ${issue.message}`)
  }
  throw new Error(`policy ${id} rejected`)
}

await engine.admin.savePolicy(row as AccessControl.IPolicy)

The array-value check reports one LIMIT_EXCEEDED per condition, not one per oversized element, and never interpolates the offending value into the message: a 10 MiB string value produces a short diagnostic, not a 10 MiB one. Both behaviours are pinned by validate-value-length.test.ts.

validateRole

export function validateRole(input: unknown): IamValidate.IResult

Shape guard for a single role. Cheaper than validateRoles and cross-role blind: it never looks at other roles, so it cannot detect duplicates, dangling inherits, or cycles.

CheckSeverityCode
Input is a plain objecterrorINVALID_TYPE (returns immediately)
id is a non-empty stringerrorMISSING_FIELD
id carries no control characterserrorINVALID_TYPE
scope, if present, is a non-empty stringerrorINVALID_TYPE
permissions is an arrayerrorMISSING_FIELD
Each permissions[i] is a non-null objecterrorINVALID_TYPE
permissions[i].action / .resource are non-empty stringserrorMISSING_FIELD
…and carry no control characterserrorINVALID_TYPE
permissions[i].scope, if present, is a non-empty stringerrorINVALID_TYPE
permissions[i].conditions, if present, is a valid condition grouperror/warningvarious
inherits, if present and not null, is an arrayerrorINVALID_TYPE
Each inherits[i] is a stringerrorINVALID_TYPE

inherits: null is treated as absent, not as an error. An empty inherits: [] is accepted.

scope: '' is refused rather than normalised, on a role and on a permission alike. The empty string is a scope value, not a missing one: the Redis encoding spells "no scope" as '' and matchesScope used to read an empty pattern as global. Omit the field for a global permission or an unscoped role.

Control characters are refused for the same reason on both: a NUL is the Redis assignment member separator, so saveRole used to store a role that assignRole then threw on, and a control char is invisible in any UI that would display the id.

A permission's conditions group is validated starting at depth IAM_RBAC_CONDITION_DEPTH (1), not 0, because rolesToPolicy nests the author's group one level inside the generated rule's own all. Validating from 0 would accept a group one level past what evalConditionGroup will match.

validateRoles

export function validateRoles(
  roles: readonly AccessControl.IRole[],
  declared?: IamValidate.IDeclaredSurface,
): IamValidate.IResult

Cross-role checks over the whole bundle. Run this once at boot over whatever role set you load from your adapter or config file.

Malformed rows come back as issues, not exceptions. Each row is shape-checked before anything reads into it: a row that is not a plain object, has no non-empty string id, has no permissions array, or has an inherits that is not an array of strings is reported as INVALID_TYPE with the message Role at index N <why>. Those rows are skipped and the cross-role checks run over the rest, so one bad entry does not cost you the answer about the others.

The optional second argument is the declared vocabulary. createIam(...).validateRoles supplies it from your config and the bare export does not, so only the config-bound form reports UNREACHABLE_TARGET for a grant naming an undeclared action, resource or scope. See access config.

CheckSeverityCode
A row is not a well-formed roleerrorINVALID_TYPE
Two roles share an iderrorDUPLICATE_ROLE_ID
inherits names a role not in the seterrorDANGLING_INHERIT
Inheritance forms a cyclewarningCIRCULAR_INHERIT
Role has no permissions and no inheritswarningEMPTY_ROLE
Longest inheritance chain exceeds MAX_INHERITANCE_DEPTH (32)errorINHERITANCE_TOO_DEEP

Cycles are warnings because the runtime cuts them during the inheritance walk with a shallowest-depth memo rather than a visited set: the configuration is wrong but not dangerous. Depth is an error because chains deeper than the cap truncate silently at runtime, dropping permissions with no signal: the symptom is a mysterious denial far from the role definition. validate.test.ts pins both the rejection past the cap and the acceptance exactly at it.

import { validateRoles } from '@gentleduck/iam/core/validate'
import { defineRole } from '@gentleduck/iam/core/builder'

const viewer = defineRole('viewer').grant('read', 'post').build()
const editor = defineRole('editor').inherits('viewer').grant('update', 'post').build()

const result = validateRoles([viewer, editor])
if (!result.valid) {
  throw new Error(result.issues.map((i) => `${i.code}: ${i.message}`).join('\n'))
}

A dangling inherit, spelled out:

const orphan = defineRole('editor').inherits('viewer').grant('update', 'post').build()
const result = validateRoles([orphan])

result.valid   // false
result.issues
// [{
//   type: 'error',
//   code: 'DANGLING_INHERIT',
//   message: 'Role "editor" inherits from "viewer" which does not exist',
//   roleId: 'editor',
// }]

A cycle, which stays valid:

const a = { id: 'a', name: 'A', permissions: [], inherits: ['b'] }
const b = { id: 'b', name: 'B', permissions: [], inherits: ['a'] }

const result = validateRoles([a, b])
result.valid   // true - CIRCULAR_INHERIT is a warning

parsePolicyRow

export function parsePolicyRow<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
>(raw: unknown): AccessControl.IPolicy<TAction, TResource, TRole> | null

Runs validatePolicy and returns the typed row when valid is true, null otherwise. Warnings do not cause a null; it gates on valid, so a BROAD_ALLOW policy still parses. It returns the identical object on success (a validated narrowing, not a copy or a normaliser) and replaces the validatePolicy(row)-then-cast pattern that was a type-safety hole at every adapter boundary.

The three generics are TypeScript-only constraints. Runtime validation cannot verify that a string belongs to a closed union; the adapter trusts them because the same library wrote the row through savePolicy.

parseRoleRow

export function parseRoleRow<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
>(raw: unknown): AccessControl.IRole<TAction, TResource, TRole, TScope> | null

Mirror of parsePolicyRow, backed by validateRole. See utility helpers for the adapter pattern in full.

detectCatastrophicRegex

export function detectCatastrophicRegex(pattern: string): { safe: boolean; reason?: string }

Heuristic ReDoS screen. validatePolicy calls it on every matches condition whose value is a literal string (a $-prefixed value is skipped, because the matches operator refuses $-resolved right-hand sides at evaluation time anyway).

The checks run in this order, so the most specific reason wins:

Shapereason
Not a stringpattern must be a string
Longer than MAX_REGEX_LENGTH (128)pattern length <n> exceeds MAX_REGEX_LENGTH (128)
Backreference followed by a quantifier, e.g. (\w+)\1+ or \k<n>+backref-quantifier
Lookaround whose body holds a quantified group, e.g. (?=(a+)+)lookaround-with-quantified-group
{n,m} or {n,} bound over MAX_BOUNDED_QUANTIFIER (1000)bounded-large-quantifier
Quantified group whose body holds a quantifier, e.g. (a+)+nested quantifier (e.g. `(a+)+`) - catastrophic backtracking risk
Quantified group whose body contains an alternationalternation inside a quantified group - catastrophic backtracking risk
More than MAX_UNBOUNDED_QUANTIFIERS (4) unbounded quantifiers<n> unbounded quantifiers exceed limit of 4
Adjacent unbounded quantifiers over overlapping characters, e.g. \w+\d+adjacent unbounded quantifiers over overlapping characters (<pair>) - polynomial backtracking risk
A chain of unbounded quantifiers competing for the same characters<n>+ unbounded quantifiers competing for the same characters (<chain>) - polynomial backtracking risk
Otherwise{ safe: true }

a{5} (an exact count) is not a range and is never flagged. Escapes and character-class bodies are stripped before the quantifier scan, so the literal * in a glob-shaped ^([a-z0-9*-])+$ does not read as a quantifier, and (\+)+ is accepted.

An unsafe pattern surfaces as an error issue whose message is Condition "matches" pattern rejected: <reason>, so it never reaches the policy store. The guarantee is load-bearing: a matches condition that failed at evaluation time would make its leaf false, which inside a deny rule flips the rule to "does not apply"; a ReDoS pattern would become an availability and an authorization problem.

Exported constants

Everything the module exports at runtime:

ExportTypeValueCaps
POLICY_LIMITS.rulesPerPolicynumber1_000rules in one policy
POLICY_LIMITS.actionsPerRulenumber100actions on one rule
POLICY_LIMITS.resourcesPerRulenumber100resources on one rule
POLICY_LIMITS.cartesianPerRulenumber1_000actions x resources per rule, and the target-reachability budget
MAX_FIELD_LENGTHnumber256condition field dot-path length
MAX_CONDITION_VALUE_LENGTHnumber1024string condition value length
MAX_UNBOUNDED_QUANTIFIERSnumber4unbounded quantifiers in one matches pattern
MAX_BOUNDED_QUANTIFIERnumber1_000upper bound of a {n,m} quantifier
VALID_ALGORITHMSSet<string>deny-overrides, allow-overrides, first-match, highest-prioritycombining algorithms
VALID_EFFECTSSet<string>allow, denyrule effects
VALID_OPERATORSSet<string>the 19 condition operatorscondition operators
detectCatastrophicRegexfunction—ReDoS screen
import { POLICY_LIMITS, MAX_FIELD_LENGTH, VALID_OPERATORS } from '@gentleduck/iam/core/validate'

Two more caps this module enforces are defined elsewhere and are not re-exported from ./core/validate. Import them from the root barrel, where the house IAM_ prefix is applied on the way out:

Import from @gentleduck/iamDefined inValue
IAM_MAX_CONDITION_DEPTHcore/conditions10
IAM_MAX_REGEX_LENGTHcore/conditions128
MAX_INHERITANCE_DEPTHcore/rbac32

The root barrel also re-exports IAM_MAX_BOUNDED_QUANTIFIER and IAM_MAX_UNBOUNDED_QUANTIFIERS, so a form that pre-flights a pattern against the same thresholds the evaluator uses can collect the whole set from one entry point instead of importing half of it unprefixed from ./core/validate.

Result and issue types

namespace IamValidate {
  interface IResult {
    readonly valid: boolean
    readonly issues: readonly IIssue[]
  }

  interface IIssue {
    readonly type: 'error' | 'warning'
    readonly code: ValidationCode
    readonly message: string
    readonly roleId?: string
    readonly path?: string
  }
}
FieldMeaning
validtrue when no issue has type: 'error'. Warnings never flip it.
type'error' blocks usage; 'warning' is informational.
codeClosed set, see below. The compiler enforces exhaustiveness when you switch on it.
messageHuman-readable, safe to log. Never contains the offending oversized value.
roleIdSet by role validation only.
pathDot-path into the offending field, set by policy validation only. Examples: id, rules[2].effect, rules[0].conditions.all[1].value, targets.actions.

Every issue code

IamValidate.ValidationCode is a closed 24-member union. This table gives every code, the validator that emits it, its severity, and the message template.

CodeEmitted bySeverityMessage
BROAD_ALLOWvalidatePolicywarningRule allows every action on every resource with no conditions. This is the broadest possible grant - confirm it is intentional.
CIRCULAR_INHERITvalidateRoleswarningCircular inheritance detected involving role "<id>" (cycle includes "<id>")
DANGLING_INHERITvalidateRoleserrorRole "<id>" inherits from "<parent>" which does not exist
DUPLICATE_ROLE_IDvalidateRoleserrorDuplicate role ID "<id>"
DUPLICATE_RULE_IDvalidatePolicywarningDuplicate rule ID "<id>"
EMPTY_ROLEvalidateRoleswarningRole "<id>" has no permissions and no inheritance
ERR_REGEX_CATASTROPHICvalidatePolicyerrorCondition "matches" pattern rejected: <reason>
ERR_REGEX_INVALIDvalidatePolicyerrorCondition "matches" pattern is not a valid regular expression
ERR_REGEX_USER_SOURCEDvalidatePolicyerrorCondition "matches" pattern is read from request data. This is refused at evaluation time (a caller-supplied pattern is a ReDoS vector), so the condition would always be false and the rule would never fire. Use a literal pattern.
INHERITANCE_TOO_DEEPvalidateRoleserrorRole "<id>" has an inheritance chain <n> deep; the runtime caps at 32 and silently drops anything past it
INVALID_ALGORITHMvalidatePolicyerrorInvalid algorithm "<value>". Must be one of: deny-overrides, allow-overrides, first-match, highest-priority
INVALID_CONDITIONvalidatePolicyerrorCondition must be an object / Condition group must be an object / Condition group must have "all", "any", or "none" key / "<key>" must be an array
INVALID_EFFECTvalidatePolicyerrorInvalid effect "<value>". Must be "allow" or "deny"
INVALID_OPERATORvalidatePolicyerrorInvalid operator "<value>"
INVALID_RULEvalidatePolicyerrorRule must be an object
INVALID_TYPEvalidatePolicy, validateRoleerrorPolicy must be a non-null object / Role must be a non-null object / "version" must be a number if provided / "targets" must be an object if provided / targets.<key> must be an array / Action must be a string / Resource must be a string / Rule "priority" must be a finite number (NaN/Infinity break highest-priority ranking) / "inherits" must be an array of strings if provided / "inherits[<i>]" must be a string / Role "id" must not contain control characters / "scope" must be a non-empty string if provided (omit it for an unscoped role) / "permissions[<i>]" must be a non-null object / "permissions[<i>].<action|resource>" must not contain control characters / "permissions[<i>].scope" must be a non-empty string if provided (omit it for a global permission)
LIMIT_EXCEEDEDvalidatePolicyerrorPolicy has <n> rules; limit is 1000 / Rule has <n> actions; limit is 100 / Rule has <n> resources; limit is 100 / Rule actionxresource cartesian is <n>; limit is 1000 / Condition field is <n> chars; limit is 256 / Condition value is <n> chars; limit is 1024 / Condition value[<i>] is <n> chars; limit is 1024 / Condition nesting exceeds MAX_CONDITION_DEPTH (10)
MISSING_FIELDvalidatePolicy, validateRoleerrorPolicy must have a non-empty string "id" / Policy must have a non-empty string "name" / Policy must have a "rules" array / Rule must have a non-empty string "id" / Rule must have a non-empty "actions" array / Rule must have a non-empty "resources" array / Rule must have a "conditions" object (use { all: [] } for an unconditional rule) / Condition must have a non-empty string "field" / Role must have a non-empty string "id" / Role must have a "permissions" array / "permissions[<i>].<action|resource>" must be a non-empty string
MISSING_VALUEvalidatePolicyerrorOperator "<op>" requires a "value"
OPERAND_TYPE_MISMATCHvalidatePolicyerrorOperator "<op>" expects a <scalar|array|number|string> value (a number or ISO-8601 string for before / after)
UNKNOWN_FIELDvalidatePolicyerrorUnknown field "<key>"; the policy schema forbids additional properties here
UNREACHABLE_TARGETvalidatePolicy, validateRoleserror (warning in one case)see below
UNRESOLVABLE_FIELDvalidatePolicywarningCondition field "<path>" does not resolve at evaluation time (expected a subject/resource/environment root or the shorthand action/scope, and no __proto__/constructor/prototype segment)
UNRESOLVABLE_VALUEvalidatePolicywarningCondition value "<value>" references an unresolvable path

UNREACHABLE_TARGET in detail

The one code with two severities, and the one worth understanding before it bites.

evaluatePolicy folds defaultEffect (which is deny) when a policy's target matches and none of its rules do. A target therefore widens what a policy refuses, not only what it inspects. Adding a resource to .target({ resources: [...] }) without an allow rule covering it denies every caller for that resource, and the denial surfaces far from the policy that caused it.

SituationSeverityMessage
A targeted (action, resource) pair no allow rule coverserrorTarget admits "<action>" on "<resource>" but no allow rule covers it, so every request matching it is denied by this policy. Add a rule that allows it, or narrow the target.
The target's pair count exceeds cartesianPerRule (1000)warningTarget has <n> (action, resource) pairs, over the 1000 checked for unreachable coverage - skipping the check. Narrow the target to validate it.

The same code carries a second, unrelated meaning on the role side. createIam(...).validateRoles (the config-bound form, which has your declared vocabulary) emits UNREACHABLE_TARGET for a grant naming an action, resource or scope the config never declared. Such a grant reads as access granted and behaves as access denied, because engine.check is constrained to the declared unions and nothing will ever ask the question it answers. Three things are never reported: an axis the config left empty (it constrains nothing), a '*' grant (the wildcard, not a member), and an absent scope (an unscoped permission is global, not scoped to nowhere).

Behaviour details on the policy side:

  • The check runs only when the policy has at least one allow rule. A purely restrictive policy (where denying everything the target names is the point) is untouched.
  • A dimension the target omits is one it does not constrain, so the rules decide it. A target naming only actions: ['impersonate'] is not reported unreachable just because its allow rule is scoped .of('users').
  • A rule list that is empty, or contains '*', or contains the literal value, counts as covering that dimension.
  • One issue per uncovered pair, not one per policy.

It is an error rather than a warning so PolicyBuilder.build() throws where the policy is written. As a warning the only visible symptom was a denial, which reads as the permission system working. A CHANGELOG note that still says "Warning rather than error" describes the superseded 5.3.0 behaviour.

Builder integration

Because the builders validate on build(), most authoring mistakes never reach a store:

definePolicy('p1')
  .name('P1')
  .target({ actions: ['publish'], resources: ['post'] })
  .rule('r1', (r) => r.allow().on('read').of('post'))
  .build()
// throws the coded IamError IAM_VALIDATION_FAILED
// meta: { kind: 'policy', issues: [
//   'UNREACHABLE_TARGET at "targets": Target admits "publish" on "post" but no allow rule
//    covers it, so every request matching it is denied by this policy. ...'
// ] }

RoleBuilder.build() throws through the identical path: the same coded IamError IAM_VALIDATION_FAILED, with meta.kind: 'role' and meta.issues formatted the same way (CODE at "path": message) - there is no longer a format difference between the three builders' failures.

Gotchas

  • Warnings are silent by default. valid ignores them, and neither builder nor engine.admin fails on them. BROAD_ALLOW, UNRESOLVABLE_FIELD, UNRESOLVABLE_VALUE, DUPLICATE_RULE_ID, EMPTY_ROLE, and CIRCULAR_INHERIT are worth logging in CI.
  • validateRole cannot see the bundle. Duplicates, dangling inherits, cycles, and depth only come from validateRoles. Run both.
  • The reserved names validate clean. A rule with actions: ['unknown'], a permission { action: 'unknown', resource: 'unknown' }, and a policy with the id '__rbac__' all return valid: true and store. The engine refuses any request naming 'unknown' before consulting a policy, so that grant is written, visible, and unanswerable; '__rbac__' is the id of the synthetic policy rolesToPolicy generates, and an authored policy under it makes traces unreadable. Keep both out of your vocabulary.
  • Control characters are checked on some fields and not others. A rule's actions and resources, a role id, and a permission's action / resource are screened; a policy id or name and a rule id are not. Screen those in your admin form: a control character is invisible in a UI, so the id reads as a different id than it is.
  • parsePolicyRow returns the row when only warnings fired. It gates on valid, not on issues.length.
  • The generics are not enforced. parsePolicyRow<TAction> narrows the TypeScript type; the runtime never checks the strings.
  • conditions is required on a rule. Use { all: [] } for an unconditional rule; the builders emit that by default. A missing key is a MISSING_FIELD error, and the message names the unconditional spelling.
  • priority must be finite. NaN and Infinity break highest-priority ranking, so they are INVALID_TYPE errors rather than being coerced.

See also