file adapter
Filesystem-backed JSON adapter for CLIs, dev fixtures, and single-process apps with modest policy counts.
Install
import { IamFileAdapter } from '@gentleduck/iam/adapters/file'
JSON-on-disk store. Single-file persistence with read-through cache + write-back on every mutation. Single-writer: multiple processes against the same file will clobber each other without external locking. Suitable for CLIs, dev fixtures, and single-process apps.
For production with concurrent writers, use Redis, Prisma, Drizzle, or build a custom adapter.
Basic usage
import fs from 'node:fs/promises'
import { IamFileAdapter } from '@gentleduck/iam/adapters/file'
import { IamEngine } from '@gentleduck/iam'
const adapter = new IamFileAdapter({
path: '/var/lib/myapp/iam.json',
fs,
})
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: {} })
The adapter creates the parent directory on first write (fs.mkdir(..., { recursive: true })), so you don't need to ensure the path exists.
Configuration
import type { IamFile } from '@gentleduck/iam/adapters/file'
const init: IamFile.IInit = {
path: '/var/lib/myapp/iam.json',
fs: await import('node:fs/promises'),
}
| Option | Type | Notes |
|---|---|---|
path | string | Absolute path to the JSON store. Created on first write if missing. |
fs | IamFile.IFS | Filesystem driver - node:fs/promises-compatible. See "Custom FS" below. |
On-disk layout
The adapter writes a single JSON file with four top-level keys:
{
"policies": {
"allow-read": { "id": "allow-read", "name": "Allow Read", ... }
},
"roles": {
"viewer": { "id": "viewer", "name": "Viewer", "permissions": [...] }
},
"assignments": {
"user-1": [
{ "role": "viewer" },
{ "role": "editor", "scope": "org-acme" }
]
},
"attributes": {
"user-1": { "department": "engineering", "status": "active" }
}
}
Indented with JSON.stringify(state, null, 2) so the file is human-editable. Hand-edits between process restarts work - the adapter rereads on next mutation.
Custom filesystem
IamFile.IFS is the minimal node:fs/promises surface the adapter needs. Implement it for in-memory testing, S3-backed durability, or any other backend:
import type { IamFile } from '@gentleduck/iam/adapters/file'
const inMemoryFS: IamFile.IFS = {
files: new Map<string, string>(),
async readFile(path) {
const v = this.files.get(path)
if (v == null) throw new Error('ENOENT')
return v
},
async writeFile(path, data) {
this.files.set(path, data)
},
async mkdir() {
/* no-op */
},
}
const adapter = new IamFileAdapter({ path: '/store.json', fs: inMemoryFS })
Tests in the package use this pattern - no real filesystem touched.
Error tolerance
The adapter starts with empty state when the file is missing or malformed:
// File doesn't exist
const adapter = new IamFileAdapter({ path: '/nope/iam.json', fs })
await adapter.listPolicies() // -> []
// File is invalid JSON
fs.writeFileSync('/store.json', 'not-json{')
const adapter2 = new IamFileAdapter({ path: '/store.json', fs })
await adapter2.listPolicies() // -> [] (does not throw)
This makes it safe to deploy alongside a missing store, and resistant to partial-write corruption on the next start.
Concurrency model
Single-writer. The adapter caches state in memory and writes the full JSON document on every mutation. If two processes share the same file, the second write clobbers the first.
For concurrent-writer scenarios:
- Same machine, multiple processes - use Redis or Prisma.
- Hand-editable + horizontally scaled - keep the JSON in version control, build a custom adapter that pulls from it on
refresh()and writes go through a CI pipeline.
The adapter coalesces concurrent in-process reads via a single-flight loadInFlight promise, so the first read populates the cache for everyone else without duplicating disk reads.
When to use it
yes CLI tools that ship pre-baked policies yes Dev fixtures committed to a repo yes Single-process apps (Electron, desktop daemons, sidecars) yes Tests that need persistence between assertions but not between runs
no Multi-process production servers no Apps with >10k policy mutations/sec (pays a full JSON serialize on every write) no Apps requiring transactional cross-key updates
See also
- Memory adapter - same shape, in-process only, no persistence.
- Custom adapter - implement
IamAdapter.IAdapterfor any backend. - Adapter comparison - feature matrix across all storage backends.