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:
@@ -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 ?? ""),
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,26 @@
|
||||
*/
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
import { logAccess, enabled as accessLogEnabled } from "./access-log";
|
||||
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.
|
||||
*
|
||||
@@ -31,7 +49,10 @@ export async function docCreate(
|
||||
store?: unknown,
|
||||
): Promise<Nuri> {
|
||||
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,
|
||||
query: string,
|
||||
anchor?: Nuri,
|
||||
label = "sparqlUpdate",
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -60,7 +84,16 @@ export async function sparqlQuery(
|
||||
query: string,
|
||||
base?: string,
|
||||
anchor?: Nuri,
|
||||
label = "sparqlQuery",
|
||||
): Promise<unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
<${P.payload}> "${payloadLiteral}" ;
|
||||
<${P.ts}> "${ts}"${fromTriple} .
|
||||
}`;
|
||||
await sparqlUpdate(sid, update, targetInbox);
|
||||
await sparqlUpdate(sid, update, targetInbox, "deposit");
|
||||
}
|
||||
|
||||
// --- read --------------------------------------------------------------
|
||||
@@ -166,7 +166,7 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
<${P.ts}> ?ts .
|
||||
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[] = [];
|
||||
for (const row of readBindings(result)) {
|
||||
const rawPayload = row.payload?.value ?? "null";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
||||
import type { RegistrySession } from "./store-registry";
|
||||
import { CapRegistry } from "./caps";
|
||||
import { setAccessLog } from "./access-log";
|
||||
|
||||
/**
|
||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||
@@ -34,6 +35,13 @@ export interface EventuallyConfig {
|
||||
sharedWallet?: { name: string; secret: string };
|
||||
/** Initial current user; may also be set later via {@link setCurrentUser}. */
|
||||
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()`. */
|
||||
init?: (...args: any[]) => any;
|
||||
/** 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 {
|
||||
cfg = c;
|
||||
currentUser = c.currentUser ?? null;
|
||||
setAccessLog(c.debugAccessLog ?? false);
|
||||
}
|
||||
|
||||
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
|
||||
|
||||
@@ -111,6 +111,7 @@ async function readDoc(
|
||||
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||
undefined,
|
||||
nuri,
|
||||
"readDoc",
|
||||
);
|
||||
return bindings(res);
|
||||
} catch (error) {
|
||||
|
||||
@@ -185,7 +185,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||
}`;
|
||||
const map = new Map<string, AccountRecord>();
|
||||
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)) {
|
||||
const id = bindingValue(row, "id");
|
||||
if (!id) continue;
|
||||
@@ -238,7 +238,7 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
|
||||
const rows = readBindings(result);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0]!;
|
||||
@@ -305,7 +305,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, anchor);
|
||||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
||||
} catch (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.
|
||||
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
|
||||
indexDoc,
|
||||
"createEntityDoc",
|
||||
);
|
||||
} catch (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 }`,
|
||||
undefined,
|
||||
indexDoc,
|
||||
"readScopeIndex",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const v = bindingValue(row, "e");
|
||||
|
||||
Reference in New Issue
Block a user