Initial Commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# `allowlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiled per-plugin allow-list of secret names. The vault-backed
|
||||
adapter calls `assertAllowed` BEFORE consulting the backend so a
|
||||
non-permitted name never even discovers whether a corresponding
|
||||
secret exists, and the watcher calls `isAllowed` as a filter on every
|
||||
forwarded `secret.updated` event. Decoupling the allow-list into its
|
||||
own module lets the adapter and watcher share the same compiled set
|
||||
without re-running Zod option validation per call site.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAllowlist({ names })` — Zod-validated factory returning a
|
||||
frozen `Allowlist` (`isAllowed`, `assertAllowed`, `names`).
|
||||
- O(1) lookups via internal `Set<string>` — `assertAllowed` is on
|
||||
the hot read path of every `get` / `require` call.
|
||||
- Deduplicated + sorted public `names` array, frozen so a consumer
|
||||
cannot mutate the surface back upstream.
|
||||
- Empty input is permitted: a plugin manifest that declares no
|
||||
secrets compiles to an allow-list that rejects every read at the
|
||||
gate (strict-mode behaviour by design).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
|
||||
|
||||
const allow = createAllowlist({ names: ["STRIPE", "JWT_SIGNING"] });
|
||||
allow.assertAllowed("STRIPE"); // ok
|
||||
allow.assertAllowed("AWS"); // throws SECRETS_VAULT.NOT_ALLOWED
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the predicate form as a watcher filter:
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
|
||||
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
|
||||
backend.onSecretUpdated((payload) => {
|
||||
if (!allow.isAllowed(payload.name)) return;
|
||||
bus.emit("secret.changed", { name: payload.name });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateAllowlistOptions { readonly names: readonly string[] }`
|
||||
- `createAllowlist(options): Allowlist`
|
||||
- `interface Allowlist`
|
||||
- `readonly names: readonly string[]`
|
||||
- `isAllowed(name: string): boolean`
|
||||
- `assertAllowed(name: string): void`
|
||||
— throws `SecretsVaultError(SECRETS_VAULT.NOT_ALLOWED)`
|
||||
- Construction failures raise
|
||||
`SecretsVaultError(SECRETS_VAULT.INVALID_OPTIONS)` carrying the raw
|
||||
Zod issue list on `error.context.issues`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `names` snapshot is built via `new Set(names).sort()` so callers
|
||||
who accidentally pass duplicates get a clean public list, and the
|
||||
sort makes the snapshot's iteration order deterministic — important
|
||||
for tests that snapshot the allow-list shape, and for cache keys
|
||||
keyed on the allow-list ordering.
|
||||
|
||||
The Zod schema rejects empty-string entries because a zero-length
|
||||
allow-list entry would collide with the conformance harness's
|
||||
"missing-name" probe and silently grant access to a "" key in the
|
||||
backend. Real plugin manifests never declare a "" secret, so the
|
||||
check is free strictness.
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createAllowlist } from "./allowlist.js";
|
||||
|
||||
describe("createAllowlist", () => {
|
||||
it("rejects non-string names with INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
const bad = 123 as unknown;
|
||||
createAllowlist({ names: [bad as string] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty-string entries", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createAllowlist({ names: ["A", ""] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
});
|
||||
|
||||
it("accepts an empty list", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: [] });
|
||||
expect(allow.names).toEqual([]);
|
||||
expect(allow.isAllowed("anything")).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates and sorts names", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: ["B", "A", "B", "A"] });
|
||||
expect(allow.names).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("isAllowed is true for in-list names", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
expect(allow.isAllowed("STRIPE")).toBe(true);
|
||||
expect(allow.isAllowed("AWS")).toBe(false);
|
||||
});
|
||||
|
||||
it("assertAllowed raises NOT_ALLOWED with the name in context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
let caught: unknown;
|
||||
try {
|
||||
allow.assertAllowed("AWS");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsVaultError);
|
||||
expect((caught as SecretsVaultError).code).toBe(
|
||||
SECRETS_VAULT_CODES.NOT_ALLOWED
|
||||
);
|
||||
expect((caught as SecretsVaultError).context).toEqual({
|
||||
name: "AWS",
|
||||
});
|
||||
});
|
||||
|
||||
it("un-manifested read raises SECRETS_VAULT_NOT_ALLOWED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const allow = createAllowlist({ names: ["STRIPE"] });
|
||||
expect(() => allow.assertAllowed("AWS")).toThrow(SecretsVaultError);
|
||||
});
|
||||
|
||||
it("names array is frozen", { timeout: 5000 }, () => {
|
||||
const allow = createAllowlist({ names: ["A"] });
|
||||
expect(Object.isFrozen(allow.names)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
SECRETS_VAULT_CODES,
|
||||
SecretsVaultError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Construction options for {@link createAllowlist}. `names` is the
|
||||
* per-plugin whitelist drawn from the plugin manifest's `secrets[]`
|
||||
* declarations. The set MAY be empty — a manifest that declares no
|
||||
* secrets ends up with an allow-list that rejects every read at the
|
||||
* gate, which is the correct strict-mode behaviour.
|
||||
*/
|
||||
export interface CreateAllowlistOptions {
|
||||
readonly names: readonly string[];
|
||||
}
|
||||
|
||||
const optionsSchema = z.object({
|
||||
names: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Compiled per-plugin allow-list. `assertAllowed` throws
|
||||
* `SECRETS_VAULT.NOT_ALLOWED` for any name not in the set;
|
||||
* `isAllowed` is the predicate form for filter pipelines
|
||||
* (e.g. the watcher's allow-list-filtered fan-out).
|
||||
*/
|
||||
export interface Allowlist {
|
||||
/** Frozen sorted snapshot of allowed names. */
|
||||
readonly names: readonly string[];
|
||||
/** Predicate form — returns `true` when the name is allowed. */
|
||||
isAllowed(name: string): boolean;
|
||||
/** Throwing form — raises `SECRETS_VAULT.NOT_ALLOWED` when not
|
||||
* allowed. Used at the adapter's `get` / `require` gate before
|
||||
* the vault is consulted. */
|
||||
assertAllowed(name: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-plugin {@link Allowlist} from a list of permitted
|
||||
* names. The constructor:
|
||||
*
|
||||
* 1. validates `options` with Zod (rejects non-string / empty-name
|
||||
* entries) and raises `SECRETS_VAULT.INVALID_OPTIONS` on
|
||||
* failure;
|
||||
* 2. deduplicates and freezes the input list so a later mutation
|
||||
* of the caller's array cannot expand the allowed set;
|
||||
* 3. compiles the names into a `Set<string>` for O(1) lookups —
|
||||
* `assertAllowed` is on the hot read path so the set form
|
||||
* matters under realistic plugin sizes.
|
||||
*/
|
||||
export function createAllowlist(options: CreateAllowlistOptions): Allowlist {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.INVALID_OPTIONS,
|
||||
"error.secrets_vault.invalid_options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const names = Object.freeze([...new Set(parsed.data.names)].sort());
|
||||
const allowSet = new Set<string>(names);
|
||||
return {
|
||||
names,
|
||||
isAllowed(name: string): boolean {
|
||||
return allowSet.has(name);
|
||||
},
|
||||
assertAllowed(name: string): void {
|
||||
if (!allowSet.has(name)) {
|
||||
throw new SecretsVaultError(
|
||||
SECRETS_VAULT_CODES.NOT_ALLOWED,
|
||||
"error.secrets_vault.not_allowed",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type Allowlist,
|
||||
type CreateAllowlistOptions,
|
||||
createAllowlist,
|
||||
} from "./allowlist.js";
|
||||
Reference in New Issue
Block a user