Skip to main content

File adapter

WIP

Single-JSON-file adapter for CLIs, dev fixtures, and single-process apps - options, on-disk format, path hardening, and the durability and concurrency caveats

IamFileAdapter persists the whole authorization store as one human-editable JSON file. It reads through an in-memory cache and rewrites the entire document on every mutation, which makes it ideal for a CLI that ships pre-baked policies or a fixture committed to a repo, and unsuitable for anything with two writers.

Install

No dependency beyond the package - you supply the filesystem driver yourself.


npm i @gentleduck/iam
import { IamFileAdapter, iamFileAdapter } from '@gentleduck/iam/adapters/file'
import type { IamFile } from '@gentleduck/iam/adapters/file'

Basic usage

import * as path from 'node:path'
import { IamEngine } from '@gentleduck/iam'
import { IamFileAdapter } from '@gentleduck/iam/adapters/file'

const stateDir = '/var/lib/myapp'

const adapter = new IamFileAdapter({
  fs: await import('node:fs/promises'),
  path: path.join(stateDir, 'iam.json'),
  rootDir: stateDir,
  onPolicyError: (err, ctx) => logger.error({ err, rowId: ctx.rowId }, 'iam row dropped'),
})

const engine = new IamEngine({ adapter })

await adapter.savePolicy({
  id: 'allow-read',
  name: 'Allow Read',
  algorithm: 'deny-overrides',
  rules: [
    { id: 'r1', effect: 'allow', priority: 10, actions: ['read'], resources: ['post'], conditions: { all: [] } },
  ],
})

const allowed = await engine.can('user-1', 'read', { type: 'post', attributes: {} })

API reference

new IamFileAdapter(init)

class IamFileAdapter<
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
  TFS extends IamFile.IFS = IamFile.IFS,
> implements IamAdapter.IAdapter<TAction, TResource, TRole, TScope> {
  constructor(init: IamFile.IInit<TFS>)
}

The constructor is synchronous and does no I/O, but it does validate init.path and throws on a bad one - see Path hardening. The factory iamFileAdapter(init) is equivalent and propagates the same constructor errors.

IamFile.IInit

OptionTypeDefaultMeaning
pathstringrequiredAbsolute path of the JSON store. Rejected if it is not absolute, or if any segment is .., or if rootDir is set and it escapes it. The file is created on first write.
rootDirstringundefinedAbsolute containment root. When set, path must resolve inside it, re-checked through realpath on every read and write. When omitted the adapter accepts any absolute path and logs one console.warn per process.
fsIamFile.IFSrequiredThe filesystem driver. Pass await import('node:fs/promises') in Node or Bun, or any object with the same shape.
onPolicyError(err: Error, ctx: { adapter: 'file'; rowId: string }) => voidundefinedCalled for every row dropped at load time. Without it the adapter falls back to console.warn with the message [@gentleduck/iam:file] dropped malformed row "<id>": <reason>.

IamFile.IFS

The minimal node:fs/promises surface the adapter uses. realpath is optional; when it is absent - as in most test fakes - the symlink half of the containment check is skipped and only the constructor's textual check applies.

export interface IFS {
  readFile(path: string, encoding: 'utf8'): Promise<string>
  writeFile(path: string, data: string, encoding: 'utf8'): Promise<void>
  /** Always called WITHOUT options - the immediate parent must already exist. */
  mkdir(path: string, options?: { recursive?: boolean }): Promise<unknown>
  /** Optional. Used for the rootDir symlink check when available. */
  realpath?(path: string): Promise<string>
  /** Optional. Its absence turns the tmp-file-plus-rename write into an in-place overwrite. */
  rename?(from: string, to: string): Promise<void>
}

Both optional members disable a protection when absent, which is the reason to pass the real node:fs/promises rather than a hand-rolled driver in production.

An in-memory driver, the shape the package's own tests use:

import type { IamFile } from '@gentleduck/iam/adapters/file'

function makeMemoryFS(initial?: string): IamFile.IFS {
  const files = new Map<string, string>()
  if (initial) files.set('/store.json', initial)
  return {
    async readFile(p) {
      const v = files.get(p)
      if (v == null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
      return v
    },
    async writeFile(p, data) {
      files.set(p, data)
    },
    async mkdir() {},
  }
}

const adapter = new IamFileAdapter({ path: '/store.json', fs: makeMemoryFS('{}'), rootDir: '/' })

The same seam takes an S3-backed or encrypted driver. Give it rename and the adapter does the atomic swap itself - see Write durability.

IamFile.IState

The on-disk shape, exported so you can type a migration script. It is not a wire API and the adapter does not version it.

export interface IState<TAction extends string, TResource extends string, TRole extends string, TScope extends string> {
  policies: Record<string, AccessControl.IPolicy<TAction, TResource, TRole>>
  roles: Record<string, AccessControl.IRole<TAction, TResource, TRole, TScope>>
  assignments: Record<string, Array<{ role: TRole; scope?: TScope }>>
  attributes: Record<string, IamPrimitives.Attributes>
  /** Runtime only, never serialised: subject id to the raw corrupt value, written back verbatim on every flush. */
  corruptAttributes?: Map<string, unknown>
}

On-disk format

One JSON object with four maps, written with JSON.stringify(state, null, 2) so it stays diffable and hand-editable.

{
  "policies": {
    "allow-read": {
      "id": "allow-read",
      "name": "Allow Read",
      "algorithm": "deny-overrides",
      "rules": [
        {
          "id": "r1",
          "effect": "allow",
          "priority": 10,
          "actions": ["read"],
          "resources": ["post"],
          "conditions": { "all": [] }
        }
      ]
    }
  },
  "roles": {
    "viewer": {
      "id": "viewer",
      "name": "Viewer",
      "permissions": [{ "action": "read", "resource": "post" }]
    }
  },
  "assignments": {
    "user-1": [{ "role": "viewer" }, { "role": "editor", "scope": "org-acme" }]
  },
  "attributes": {
    "user-1": { "department": "engineering", "status": "active" }
  }
}

Points that matter when you edit it by hand:

  • Both maps are keyed by id, and the adapter reads rows back by their map key, not by the id field inside the row. Keep them the same.
  • Assignments carry the scope inline. An entry with no scope is a global grant and surfaces through getSubjectRoles; an entry with a scope surfaces only through getSubjectScopedRoles. A scope must be a non-empty string when present.
  • Missing top-level keys are fine. {} is a valid store; every absent map defaults to empty with no warning.
  • A hand edit is picked up on the next load, not immediately. Once the adapter has read the file it serves from its in-memory cache until the process restarts, and the next write overwrites your edit with the cached state. Edit while the process is stopped.
  • A damaged attributes row is written back verbatim. The corruptAttributes marker is runtime-only and never reaches the file; the raw value stays exactly as it was on every flush. Otherwise an unrelated write would quietly repair a store the adapter had refused to read.

Loading and validation

Every read goes through _loadState, which is where the adapter decides what to trust. Not every problem is treated the same way: a missing file is normal, a damaged row is dropped, and a damaged file refuses to load.

Loading diagram...

Reading the branches from the top: the containment check runs first, so a hostile symlink is rejected before any bytes are read. ENOENT is the only recoverable read error - a genuinely missing file becomes an empty store. Any other errno (EACCES, EISDIR, EIO) throws IAM_FILE_READ_FAILED (meta: { code, detail }) rather than presenting an empty store, because an empty store means "deny everything" under defaultEffect: 'deny' and "allow everything" under 'allow'. A file that exists but is not parseable JSON, or whose root is not an object, throws IAM_FILE_STORE_CORRUPT (meta: { path, reason: 'parse-failed' | 'not-object', got? }) and - crucially - does not populate the cache, so a later _flush cannot overwrite a recoverable file. Only individual rows are dropped silently-but-reported.

What happens to bad data

SituationResultReported
File does not existempty store, then created on first writeno
readFile fails with a non-ENOENT errnothrows IAM_FILE_READ_FAILED (meta: { code, detail }); nothing is cached, so a later read can still succeedno
File is not valid JSONthrows IAM_FILE_STORE_CORRUPT (meta: { path, reason: 'parse-failed' })yes, with the path as rowId
Root is an array, string, or nullthrows IAM_FILE_STORE_CORRUPT (meta: { path, reason: 'not-object', got })yes
policies or roles is present but not an objectthrows IAM_FILE_STORE_FIELD_INVALID (meta: { field, got })yes
A policy row fails parsePolicyRowreported, then the read throws IAM_UNREADABLE_POLICY (meta: { adapter: 'file', policyId, detail }) - no policy is returnedyes, with the row's key and the validator's issues
A role row fails parseRoleRowthat row is dropped, the rest of the catalog loadsyes
assignments is not an objectall assignments load emptyyes
One assignments entry is malformedthat entry alone is dropped, reported with its index; the subject's other grants surviveyes
attributes is not an objectall attributes load emptyyes
One attributes row is not an objectthat subject is marked corrupt; getSubjectAttributes throws IAM_ATTRIBUTES_CORRUPT (meta: { adapter: 'file', subjectId, reason: 'not-object' }) until an admin write replaces ityes
Subject id is __proto__ or constructorreads return [] / {}; writes do not touch Object.prototypeno

Two of those are deliberate fail-closed choices rather than conveniences. A corrupt policies field throws rather than loading empty, because zero policies reads as "no denies exist"; a malformed policy row throws for the same reason, while a malformed role row is only dropped, because role permissions are allow-only and losing one can only cost a subject a grant. And a corrupt attributes row throws instead of returning {}, because {} would silently strip every ABAC condition about that subject - which usually widens access. An admin write recovers it:

await adapter.setSubjectAttributes('user-bad', { tier: 'pro' })
await adapter.getSubjectAttributes('user-bad') // { tier: 'pro' } - the corrupt marker is cleared

All the maps built during a load use Object.create(null), so an attacker-controlled row id such as __proto__ can neither read Object.prototype back nor pollute the prototype chain.

Write durability

Every mutation is a full read-modify-write of the entire document.

Loading diagram...

When the driver exposes rename, the write is not an overwrite: the adapter writes ${path}.<base36-time>-<random>.tmp and renames it over the store, so a crash mid-write leaves the previous file intact rather than a truncated one that would load as zero policies - that is, as if every deny had been deleted. Without rename it writes in place, which a crash can truncate. That is the reason to pass the real node:fs/promises.

Three consequences either way:

  • Write cost is proportional to the whole store, not the change. Assigning one role serialises every policy, role, assignment, and attribute. A store with thousands of policies pays that on every admin call.
  • The realpath containment check runs on every write, not only the first, so a file swapped for a symlink after startup cannot steer later writes outside rootDir.
  • A failed flush discards the cache. Every mutating method - savePolicy, deletePolicy, saveRole, deleteRole, assignRole, revokeRole, updateAssignmentScope, setSubjectAttributes - rejects with the driver's errno, and the adapter throws its in-memory state away rather than serving it. It used to keep the mutation: the caller was told the write failed, and the adapter then answered every later read as though it had succeeded, until an unrelated assignRole for a different subject serialised the whole cache and committed the refused grant to disk permanently.

The cost of discarding is one behaviour worth knowing. A write already queued behind a failing one mutated the cache that was discarded, so it is neither on disk nor replayable, and it rejects with IamFileAdapter discarded its in-memory state after an earlier write failed, so this write did not reach the store. Reload and retry it. A write issued after the failure reloads from disk and succeeds normally.

Attribute reads share the cache

getSubjectAttributes returns the object held in the cache, not a copy. Editing what it returns edits adapter state directly: a later read answers the edited bag, and because every flush serialises the whole cache, the next write of any kind - for any subject - persists that edit to disk, with no setSubjectAttributes call, no shape guard, and nothing to invalidate an engine cache from. Treat the returned bag as read-only, or copy it before touching it. The memory adapter copies on the way out; this one does not.

Concurrency

Single writer. The adapter holds the store in memory and rewrites it whole; two processes pointed at the same file will each write their own view and the later write wins outright. There is no file locking, no compare-and-swap, and no detection - the loser's changes are gone.

Within one process the picture is better:

  • Concurrent first reads are coalesced. A single-flight latch means a burst of cold reads produces exactly one readFile; the tests assert this. It is cleared on any throw, including a symlink-escape rejection, so a transient failure cannot pin the adapter in a permanently broken state - a later read retries from scratch.
  • Flushes are serialised, writes are not independent. A flush chain makes one write/rename pair complete before the next begins, so at most one .tmp file exists at a time and a reader never observes the store mid-swap. It does not make two concurrent writers independent: the cache is serialised at flush time, not at issue time, so both writers' mutations end up in both payloads.
  • The engine adds its own coalescing on top. Cold subject loads for the same subject collapse into one adapter call, so the file is read far less often than requests arrive.

If you need concurrent writers, the answer is a different adapter, not a lock file:

  • Several processes on one machine, or several instances - Redis, Prisma, or Drizzle.
  • Hand-editable JSON and horizontal scale - keep the JSON in version control as the source, and write a custom adapter that reads a deployed artefact while writes go through your CI pipeline.

Path hardening

The constructor rejects a bad path before any I/O happens. Every row below throws IAM_FILE_PATH_INVALID, distinguished by meta.reason:

Inputmeta.reasonOther meta fields
A segment equal to ..'dotdot-segment'path
path.resolve(path) does not land on an absolute path'not-resolvable-absolute'path
A relative path'not-absolute'path
A rootDir that is not absolute'rootdir-not-absolute'rootDir
A path outside rootDir'escapes-rootdir'path (resolved), rootDir (resolved)

.. is checked textually before path.resolve, because resolve would collapse it and hide the intent. At read and write time _assertWithinRoot canonicalises through realpath (falling back to the parent directory when the file does not exist yet) and throws the same IAM_FILE_PATH_INVALID with meta.reason: 'symlink-escapes-rootdir' if the canonical path is outside. A non-ENOENT realpath failure - ELOOP, EACCES - propagates rather than falling through to the parent, so a hostile link cannot bypass the check by failing.

Omitting rootDir logs once per process:

[@gentleduck/iam:file] IamFileAdapter constructed without rootDir. Any caller deriving the path from request data should set rootDir for defence in depth.

The warning deliberately does not echo the resolved path, so log scrapers cannot use it as a path-existence oracle, and it fires at most once so operators do not learn to filter it out.

When to use it

Good fits:

  • CLI tools that ship pre-baked policies alongside the binary
  • Dev fixtures committed to a repo and reviewed as a diff
  • Single-process apps: Electron, desktop daemons, sidecars
  • Tests that need persistence between assertions but not between runs

Bad fits:

  • Multi-process or multi-instance servers - writes clobber each other
  • Write-heavy workloads - every mutation serialises the whole document
  • Anything needing transactional updates across policies, roles, and assignments - there is no withClient here, so engine.withTransaction throws the coded IAM_ENGINE_ADAPTER_NOT_TRANSACTIONAL
  • Anywhere the store must survive a crash on a driver with no rename

Gotchas

  • The parent directory is not created recursively. This is the most common first-run failure; create it in your deploy step.
  • A hand edit made while the process is running is lost on the next write, because the cache wins.
  • A failed write discards the cache, and a write already queued behind it is rejected as unreachable. Reload and retry it.
  • getSubjectAttributes hands back the cached bag itself. Edit the copy, not the return value.
  • assignRole refuses a role that is not stored, and refuses scope: '' and scope: '*' on a grant.
  • deleteRole takes the subject grants with it.
  • revokeRole without a scope removes every grant of that role, scoped and unscoped, matching the cross-adapter contract.
  • IamAdapter.IAssignOptions is refused, not ignored. startsAt / expiresAt / attributes throw and name the option rather than being dropped. Use Drizzle for temporal grants.
  • IReadOptions.signal is ignored. There is nothing to cancel; the engine still enforces adapterTimeoutMs.

See also