Skip to content

fix(storage): route the last two id sanitisers through the single source + scope every record-id fetch to the brain#63

Open
RobertSigmundsson wants to merge 1 commit into
acidkill:mainfrom
RobertSigmundsson:upstream-pr/id-consolidation-scope
Open

fix(storage): route the last two id sanitisers through the single source + scope every record-id fetch to the brain#63
RobertSigmundsson wants to merge 1 commit into
acidkill:mainfrom
RobertSigmundsson:upstream-pr/id-consolidation-scope

Conversation

@RobertSigmundsson

Copy link
Copy Markdown
Contributor

Hi Toni!

The two follow-ups you tracked on the id-sanitiser consolidation — thank you again for that review. Both are small and neither is reachable with today's inputs, but they each nick a guarantee, so here they are closed together.

1. Two sanitisers still bypassed the single choke point

_ids._to_surreal_id is meant to be the only place a caller-supplied id is folded to [A-Za-z0-9_]. Two copies still lived outside it:

  • migrations._sanitize_id — a dash-only .replace("-", "_"). Replaced with _to_surreal_id. Besides restoring the single-source guarantee, this fixes a latent mismatch: neuron records are stored folded (_to_surreal_id), so a legacy v7 endpoint id containing anything other than - (e.g. a .) used to migrate to a neuron:{id} edge endpoint that did not match the stored neuron. For uuid4 / content-hash ids (the only ids in practice) the output is unchanged.
  • tool_events.insert_tool_events — an inline event_id.replace("-", "_") on a server-generated uuid4. Routed through _to_surreal_id; output is identical for a uuid4, but the id no longer has a private folding path.

2. Record-id fetches were not brain-scoped

Every fetch-by-record-id did a bare select (neuron:{sid}, synapse:{sid}, …) with no WHERE brain_id, so a caller in brain A could read a record owned by brain B just by knowing (or guessing) its id — the same gap the store-layer guard closed for consolidation.py. You flagged get_neurons_batch / find_neurons_by_ids; the same class was open in get_neuron, get_neuron_state, get_synapse and get_fiber, so I closed the whole class here rather than leave a half-guarantee. All six now add WHERE brain_id = $brain_id (parameterised; brain_id from _get_brain_id()_safe_brain_id, fail-closed). (device fetch was already brain-keyed in its record id.)

They stay direct record fetches — the ids are still pinned in FROM neuron:{sid}, …, so this is not a table scan and the 2.7.2 dashboard-perf property is preserved; the WHERE only rejects a cross-brain hit on records already pinned by id. The single-record getters also gain the reconnect-on-dropped-transport retry (they route through _query now instead of a bare select).

Type of Change

  • Security hardening (defence-in-depth; non-breaking for legitimate callers)

Testing

  • tests/unit/test_id_consolidation_and_brain_scope.py — brain-scope query shape + bound param for all six methods; fail-closed brain-id path; single-source routing for migrations (dotted-id fold) and tool_events.
  • Updated test_dashboard_perf_queries.py::test_fetches_records_directly_with_omit to pin the scoped-but-still-direct fetch (was asserting WHERE not in sql).
  • Full unit suite (rebased on main): 6,090 passed, 33 skipped in ~29 s (-n0), clean exit. ruff + mypy clean.
  • Cross-brain isolation slice-tested against a live surrealdb:v3.2.0: for all six methods the owning brain reads the record and a foreign brain gets None.

Checklist

  • Style (ruff/mypy). [x] Self-review. [x] Tests prove the fix.
  • CHANGELOG.md — happy to add a Security/Fixed line if you'd like.

Callers audited (why the scope is a no-op for legitimate use)

All ids reaching these methods are derived from the current brain's own graph/fibers: get_neuron/get_neuron_state (anchor ids from this brain's fibers), the batch methods (retrieval_context, activation, consolidation, dashboard_api, compression, query_expansion), find_neurons_by_ids (the graph-view route). None cross a brain.

Robert

…rce + scope every record-id fetch to the brain

Follow-ups to the _to_surreal_id consolidation.

Single-source sanitisation:
* migrations._sanitize_id (dash-only .replace) and an inline .replace("-","_")
  in tool_events were the two id sanitisers that still bypassed the _ids.py
  choke point. Both now call _to_surreal_id. migrations gains the full
  [A-Za-z0-9_] fold instead of a dash-only replace, which also fixes a latent
  mismatch: neuron records are stored folded, so a legacy endpoint id with a '.'
  used to migrate to a non-matching neuron:{id} edge. For uuid4 / hash ids the
  output is unchanged.

Brain-scoped record fetches:
* Every fetch-by-record-id did a bare select with no brain filter, so a caller
  could read another brain's record just by knowing its id. All of them now add
  WHERE brain_id = $brain_id (bound param; brain_id from _get_brain_id ->
  _safe_brain_id, fail-closed): get_neuron, get_neuron_state, get_synapse,
  get_fiber, get_neurons_batch, find_neurons_by_ids. They stay direct record
  fetches (ids pinned in FROM, not a table scan), so the dashboard-perf property
  is preserved; the WHERE only rejects a cross-brain hit. (device fetch was
  already brain-keyed in its record id.)

Tests: brain-scope query shape + bound param for all six methods; the
fail-closed brain-id path; single-source routing for migrations (dotted-id fold)
and tool_events. Full unit suite green (-n0).
if result and isinstance(result, list) and len(result) > 0:
return _row_to_neuron(result[0])
rows = await self._query(
f"SELECT * FROM neuron:{sid} WHERE brain_id = $brain_id",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You flagged the two batch methods; the same bare-select read-by-id class was also open in get_neuron, get_neuron_state, get_synapse and get_fiber, so I scoped all of them here rather than leave a half-guarantee — a caller could otherwise still read a foreign brain's record via the single-record path. brain_id is a bound param from _get_brain_id() (→ _safe_brain_id, fail-closed), and the record stays pinned in FROM, so it's still a direct fetch, not a scan. Routing through _query also gives the single getters the reconnect-on-dropped-transport retry they lacked. (device was already brain-keyed in its record id.)

things = ", ".join(f"neuron:{s}" for s in safe[i : i + 1000])
rows = await self._query(f"{projection} FROM {things}")
rows = await self._query(
f"{projection} FROM {things} WHERE brain_id = $brain_id",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ids in FROM already pass _to_surreal_id + an isalnum()/_ filter, so the WHERE only adds the brain check on records already pinned by id — no change to the 2.7.2 dashboard-perf profile (still a pinned-record fetch, not a FROM neuron scan).

sid = _record_part(row["id"])
src = _sanitize_id(str(row.get("source_id", "")))
tgt = _sanitize_id(str(row.get("target_id", "")))
src = _to_surreal_id(str(row.get("source_id", "")))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second follow-up: the old _sanitize_id was dash-only, so it both duplicated the fold and under-folded. _to_surreal_id here also quietly corrects endpoints — neuron records are stored folded, so a legacy source_id/target_id with a . (or any non-- char) previously produced a neuron:{id} endpoint that didn't match the stored record. uuid4 / hash ids are unaffected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant