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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user