feat(client): OFF-by-default document access log, prefixed by active identity

Observability probe for the shared-wallet isolation footgun: on one physical
wallet several virtual identities coexist, and a read must never surface a doc
scoped to another identity. When it does (B reading A's doc), the leak is
invisible in the data — it looks like a normal read. This makes it VISIBLE.

Every real read/write is logged, prefixed by the ACTIVE virtual identity
(getCurrentUser → the account the op is scoped under, NOT the constant shared
physical wallet id). Reads append the row count — a strong leak signal:

  [urn:festipod:user:bob] READ did:ng:o:docA (readDoc) → 3 rows

Instrumented at the LOW common point in docs.ts: every read routes through
sparqlQuery, every write through sparqlUpdate, container creation through
docCreate. Callers pass a semantic label (readDoc|readUnion|listMyEntityDocs|
writeEntity|deposit|…) that is a lib-internal probe param, NOT forwarded to the
real `ng` (preserves docs.test.ts exact-forwarding assertions).

OFF by default → one boolean read on the hot path, zero output. On via
configure({ debugAccessLog: true }) or env NG_EVENTUALLY_ACCESS_LOG=1 (no code
change). Polyfill-era; removed at the real multi-store migration.

tsc --noEmit: 0 errors. bun test: 91 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-07 21:20:47 +02:00
parent dd1313258f
commit d8c36bac3b
6 changed files with 133 additions and 7 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* access-log — an OFF-by-default observability probe for document access.
*
* Diagnostic tool for the shared-wallet isolation footgun: on ONE physical
* wallet, several virtual identities coexist, and a read must never surface a
* document scoped to another identity. When it does (identity B reading identity
* A's doc), the leak is invisible in the data — it looks like a normal read. This
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
* identity (the discriminating virtual identity, NOT the constant physical wallet
* id), so replaying the scenario shows the exact line where a doc is accessed
* under the wrong identity.
*
* OFF by default → zero overhead, zero output. Turned on either by the SDK config
* option `debugAccessLog: true` (via {@link setAccessLog}) or, without touching
* the calling code, by the env var `NG_EVENTUALLY_ACCESS_LOG=1`. The `enabled()`
* gate is a single boolean read on the hot path when off.
*
* Polyfill-era, like the rest of /polyfill; removed at the real multi-store
* migration where the broker/verifier enforces isolation natively.
*/
import { getCurrentUser } from "./polyfill";
/** Access kind: a document READ or a document WRITE. */
export type AccessOp = "READ" | "WRITE";
// Config-driven toggle (set by configure() via setAccessLog); default OFF.
let configEnabled = false;
/**
* Env override: `NG_EVENTUALLY_ACCESS_LOG=1` (or `true`) turns the log on without
* a code change in the caller. Read once, tolerant of env access throwing (e.g.
* a locked-down runtime), so it never breaks the hot path.
*/
function envEnabled(): boolean {
try {
const v = (globalThis as any)?.process?.env?.NG_EVENTUALLY_ACCESS_LOG;
return v === "1" || v === "true";
} catch {
return false;
}
}
/** Set the config-driven toggle (called from configure()). */
export function setAccessLog(on: boolean): void {
configEnabled = on;
}
/** Whether access logging is currently on (config OR env). */
export function enabled(): boolean {
return configEnabled || envEnabled();
}
/**
* The identity to prefix an access line with: the ACTIVE virtual identity
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
* the discriminating signal for the isolation leak. NOT the physical wallet id
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
*/
function activeIdentity(): string {
return getCurrentUser() ?? "(none)";
}
/**
* Log one document access — but ONLY when {@link enabled}. Off → returns
* immediately, prints nothing. Format:
* `[<identity>] READ <nuri> (<label>)` — optionally with `<extra>` appended
* (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity that
* should see nothing).
*/
export function logAccess(
op: AccessOp,
nuri: string,
label: string,
extra?: string,
): void {
if (!enabled()) return;
console.log(
"[" + activeIdentity() + "] " + op + " " + nuri + " (" + label + ")" + (extra ?? ""),
);
}
+35 -2
View File
@@ -14,8 +14,26 @@
*/ */
import { getConfig } from "./polyfill"; import { getConfig } from "./polyfill";
import { logAccess, enabled as accessLogEnabled } from "./access-log";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
// access log is therefore instrumented HERE so no access path escapes it. Callers
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
// log is OFF (default) the extra param is inert and costs one boolean read.
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
function rowCount(result: unknown): number {
if (!result) return 0;
if (Array.isArray(result)) return result.length;
const anyRes = result as { results?: { bindings?: unknown[] } };
return anyRes.results?.bindings?.length ?? 0;
}
/** /**
* Create one document → its NURI. * Create one document → its NURI.
* *
@@ -31,7 +49,10 @@ export async function docCreate(
store?: unknown, store?: unknown,
): Promise<Nuri> { ): Promise<Nuri> {
const { ng } = getConfig(); const { ng } = getConfig();
return ng.doc_create(sessionId, crdt, cls, dest, store); const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
} }
/** /**
@@ -44,8 +65,11 @@ export async function sparqlUpdate(
sessionId: string, sessionId: string,
query: string, query: string,
anchor?: Nuri, anchor?: Nuri,
label = "sparqlUpdate",
): Promise<void> { ): Promise<void> {
const { ng } = getConfig(); const { ng } = getConfig();
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor); return ng.sparql_update(sessionId, query, anchor);
} }
@@ -60,7 +84,16 @@ export async function sparqlQuery(
query: string, query: string,
base?: string, base?: string,
anchor?: Nuri, anchor?: Nuri,
label = "sparqlQuery",
): Promise<unknown> { ): Promise<unknown> {
const { ng } = getConfig(); const { ng } = getConfig();
return ng.sparql_query(sessionId, query, base, anchor); // `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " rows");
}
return result;
} }
+2 -2
View File
@@ -142,7 +142,7 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
<${P.payload}> "${payloadLiteral}" ; <${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} . <${P.ts}> "${ts}"${fromTriple} .
}`; }`;
await sparqlUpdate(sid, update, targetInbox); await sparqlUpdate(sid, update, targetInbox, "deposit");
} }
// --- read -------------------------------------------------------------- // --- read --------------------------------------------------------------
@@ -166,7 +166,7 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
<${P.ts}> ?ts . <${P.ts}> ?ts .
OPTIONAL { ?d <${P.from}> ?from } OPTIONAL { ?d <${P.from}> ?from }
}`; }`;
const result = await sparqlQuery(sid, query, undefined, targetInbox); const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
const deposits: Deposit[] = []; const deposits: Deposit[] = [];
for (const row of readBindings(result)) { for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null"; const rawPayload = row.payload?.value ?? "null";
+9
View File
@@ -11,6 +11,7 @@
import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry"; import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps"; import { CapRegistry } from "./caps";
import { setAccessLog } from "./access-log";
/** /**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The * Consumer-injected dependencies of the storeRegistry (polyfill-era). The
@@ -34,6 +35,13 @@ export interface EventuallyConfig {
sharedWallet?: { name: string; secret: string }; sharedWallet?: { name: string; secret: string };
/** Initial current user; may also be set later via {@link setCurrentUser}. */ /** Initial current user; may also be set later via {@link setCurrentUser}. */
currentUser?: PrincipalId; currentUser?: PrincipalId;
/**
* Turn on the OFF-by-default document access log (see {@link ./access-log}):
* every real read/write is printed, prefixed by the active identity, to
* diagnose the shared-wallet isolation leak. Also enablable without a code
* change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false.
*/
debugAccessLog?: boolean;
/** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */ /** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */
init?: (...args: any[]) => any; init?: (...args: any[]) => any;
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */ /** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
@@ -50,6 +58,7 @@ let caps = new CapRegistry();
export function configure(c: EventuallyConfig): void { export function configure(c: EventuallyConfig): void {
cfg = c; cfg = c;
currentUser = c.currentUser ?? null; currentUser = c.currentUser ?? null;
setAccessLog(c.debugAccessLog ?? false);
} }
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */ /** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
+1
View File
@@ -111,6 +111,7 @@ async function readDoc(
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined, undefined,
nuri, nuri,
"readDoc",
); );
return bindings(res); return bindings(res);
} catch (error) { } catch (error) {
+5 -3
View File
@@ -185,7 +185,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
}`; }`;
const map = new Map<string, AccountRecord>(); const map = new Map<string, AccountRecord>();
try { try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor); const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
for (const row of readBindings(result)) { for (const row of readBindings(result)) {
const id = bindingValue(row, "id"); const id = bindingValue(row, "id");
if (!id) continue; if (!id) continue;
@@ -238,7 +238,7 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
} }
}`; }`;
try { try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor); const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
const rows = readBindings(result); const rows = readBindings(result);
if (rows.length === 0) return null; if (rows.length === 0) return null;
const row = rows[0]!; const row = rows[0]!;
@@ -305,7 +305,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
} }
}`; }`;
try { try {
await sparqlUpdate(s.sessionId, update, anchor); await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
} catch (error) { } catch (error) {
console.error("[storeRegistry] ensureAccount persist failed:", error); console.error("[storeRegistry] ensureAccount persist failed:", error);
} }
@@ -432,6 +432,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
// a literal → escapeLiteral. // a literal → escapeLiteral.
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`, `INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
indexDoc, indexDoc,
"createEntityDoc",
); );
} catch (error) { } catch (error) {
console.error("[storeRegistry] createEntityDoc index append failed:", error); console.error("[storeRegistry] createEntityDoc index append failed:", error);
@@ -451,6 +452,7 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`, `SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
undefined, undefined,
indexDoc, indexDoc,
"readScopeIndex",
); );
for (const row of readBindings(res)) { for (const row of readBindings(res)) {
const v = bindingValue(row, "e"); const v = bindingValue(row, "e");