Skip to content

Multi-token prefix-scoped access for the Control Plane (per-user iframe embedding) - #1

Closed
logical-and wants to merge 4 commits into
mainfrom
feat/multi-token-prefix-scope
Closed

Multi-token prefix-scoped access for the Control Plane (per-user iframe embedding)#1
logical-and wants to merge 4 commits into
mainfrom
feat/multi-token-prefix-scope

Conversation

@logical-and

Copy link
Copy Markdown
Owner

Summary

Adds multi-token, prefix-scoped access to the Control Plane so it can be embedded per-user via an iframe (each token is limited to its own bank namespace), while the existing admin access key keeps seeing every bank. A scoped token maps to a bank-id prefix (e.g. u2), and the CP now enforces that scope at the API level, not just the UI, across every route that takes a bank_id.

This is built for a self-hosted deployment fronted by a reverse proxy (nginx) where the CP is embedded cross-origin from a separate app.

What changed

Token resolver and session

  • New HINDSIGHT_CP_TOKENS JSON env ([{"token","prefix","label?"}]) parsed defensively; the admin HINDSIGHT_CP_ACCESS_KEY resolves to the empty prefix (all banks). resolveToken() does a constant-time compare across all entries; a single bankAllowed(prefix, bankId) predicate (empty prefix = all; else exact match or <prefix>--*, so u2 never matches u20) is reused everywhere so the layers cannot drift.
  • Prefix-aware HMAC session cookie: format <issuedAt>.<prefixB64url>.<hmac> signed with the admin key. verifySessionToken returns {valid, prefix}; added getSessionPrefix. Legacy 2-part cookies simply verify as invalid (re-login).
  • sessionCookieOptions emits SameSite=None; Secure; Partitioned when HINDSIGHT_CP_COOKIE_SAMESITE=none (for the cross-site iframe / CHIPS), otherwise the previous SameSite=Lax behavior.

Auth routes

  • Login route resolves via the token resolver and bakes the prefix into the cookie (admin key still works).
  • New POST /api/auth/embed-login (form-encoded or JSON token + optional returnTo) force-recreates the session and 302s to a sanitized, relative returnTo (defaults to /dashboard). The relative Location resolves against the public origin, so it works behind a reverse proxy and inside the cross-site iframe.
  • New GET /api/auth/whoami returns {isAdmin, prefix, label?} for the client UI.

API-level prefix isolation (three entry points)

  • Middleware path/query: authenticated scoped /api/* requests get a 403 when the target bank (from /api/{banks,operations,stats,profile}/<id> path segments, URL-decoded, or ?bank_id=/?agent_id=) is outside the prefix. Page navigation to a foreign /banks/<id> redirects to the dashboard.
  • Body-route guard assertBankAllowed applied to the routes that read bank_id from the JSON/form body: recall, reflect, extract, memories/retain, memories/retain_async, files/retain, memories/[memoryId] PATCH, banks POST.
  • Bank-list filter: GET /api/banks filters the returned list to the session prefix (admin passes through).

UI and framing

  • Scoped sessions hide the create-bank affordance and foreign chrome (GitHub link) via whoami.
  • Runtime Content-Security-Policy: frame-ancestors driven by HINDSIGHT_CP_FRAME_ANCESTORS, stamped in middleware per-request (not baked into the build) so the embedding origin is configurable at container runtime. Falls back to 'self' when unset (never *).

New env vars

  • HINDSIGHT_CP_TOKENS - JSON array of { "token": string, "prefix": string, "label"?: string }. Unset/empty/invalid -> no scoped tokens. Entries missing token/prefix are ignored.
  • HINDSIGHT_CP_COOKIE_SAMESITE - set to none for cross-site iframe embedding (adds Secure + Partitioned; requires HTTPS). Unset keeps SameSite=Lax for local http dev.
  • HINDSIGHT_CP_FRAME_ANCESTORS - space- or comma-separated origins allowed to embed the CP. Falls back to 'self'.

All three are documented in .env.example.

Backwards compatibility

  • The admin HINDSIGHT_CP_ACCESS_KEY works unchanged: it resolves to prefix "" (all banks), and admin behavior short-circuits every enforcement layer.
  • Deployments that set no HINDSIGHT_CP_TOKENS behave exactly as before (single shared key).
  • Legacy 2-part session cookies are treated as invalid, so existing users simply log in again once.

Testing and verification

  • tsc --noEmit clean; 45 unit tests pass (session round-trip incl. prefix, tampered/expired/legacy rejection, plus i18n message-catalog parity across all 10 locales).
  • Live-verified on a self-hosted deployment behind nginx:
    • Admin sees all banks and the create-bank control.
    • Scoped tokens (u2, u3, u5) see only their own u<id>[--*] banks and no create/foreign chrome.
    • Crafted foreign access as a scoped session returns 403 for all three entry points: GET /api/banks/u5--x/memories, ?bank_id=u5, and a body {"bank_id":"u5"}.
    • embed-login lands on the real public dashboard (no internal upstream host leaking into the redirect) and the cross-site cookie survives.

Build note

Built against the standalone Docker image (docker/standalone/Dockerfile) with INCLUDE_LOCAL_MODELS=true (the deployment uses a local cross-encoder reranker) and PRELOAD_ML_MODELS=false (the model is pulled at runtime into a persistent cache volume).

And added 4 commits July 13, 2026 18:15
- Add src/lib/auth/tokens.ts: resolveToken (admin key -> "" prefix, HINDSIGHT_CP_TOKENS entries constant-time matched) and bankAllowed(prefix, bankId) predicate
- Rework session cookie to 3-part `<issuedAt>.<prefixB64url>.<hmac>`; verifySessionToken now returns {valid, prefix}; add getSessionPrefix; legacy 2-part tokens verify invalid
- sessionCookieOptions emits SameSite=None; Secure; Partitioned when HINDSIGHT_CP_COOKIE_SAMESITE=none, else Lax
- Update middleware verifySessionToken call sites to use .valid
- Login route resolves via resolveToken and bakes the prefix into the cookie
- Add public embed-login route: form/JSON token POST, force-recreates session, 302 to sanitized returnTo (default /dashboard)
- Update session tests for the 3-part token, prefix round-trip, and legacy-token rejection
- Add src/lib/auth/bank-guard.ts (assertBankAllowed) for body-based bank_id routes; add labelForPrefix + reuse bankAllowed from tokens.ts
- Filter GET /api/banks by session prefix; guard POST /api/banks create
- Middleware: 403 scoped sessions on path (banks/operations/stats/profile) and ?bank_id=/?agent_id= mismatches (URL-decoded); redirect foreign /banks/<id> page nav to dashboard
- Apply assertBankAllowed to recall, reflect, extract, memories/retain, memories/retain_async, files/retain, memories/[memoryId] PATCH
- Add GET /api/auth/whoami returning {isAdmin, prefix, label?}; gate create-bank + GitHub link behind isAdmin in bank-selector
- next.config: frame-ancestors CSP from HINDSIGHT_CP_FRAME_ANCESTORS (space/comma list, defaults to 'self')
- Add api.errors.auth.forbidden key; document HINDSIGHT_CP_TOKENS/COOKIE_SAMESITE/FRAME_ANCESTORS in .env.example
- Move frame-ancestors CSP out of next.config headers() (baked at build time) into middleware, stamped per-request from HINDSIGHT_CP_FRAME_ANCESTORS so the container-time env controls embedding without a rebuild
- Apply the CSP header to every matched response (pages, api, redirects, 401/403, intl) via a single DRY helper; falls back to 'self' when unset
- Add api.errors.auth.forbidden to all 9 non-en locale catalogs for messages parity
- embed-login: emit a relative (path-only) Location instead of an absolute URL built from request.url, so the browser resolves it against the public origin (fixes redirect to https://0.0.0.0:9999/dashboard behind nginx and inside the cross-site iframe)
- middleware: build login/dashboard redirects from x-forwarded-proto/x-forwarded-host (fallback host) instead of request.url, since middleware redirects must be absolute
- sanitizeReturnTo still blocks off-origin targets; relative path prevents open redirect
@logical-and

Copy link
Copy Markdown
Owner Author

Superseded by the upstream PR against vectorize-io/hindsight: vectorize-io#2698

logical-and pushed a commit that referenced this pull request Aug 3, 2026
…ain deadlock (vectorize-io#2948)

* ci(oracle): free runner disk space before Oracle jobs

The three Oracle jobs run the Oracle 23ai `free` service image, which
together with the Python ML deps (torch) exhausts the runner's ~14 GB root
disk. Two symptoms, one cause:

- uv fails to extract a wheel with "No space left on device (os error 28)"
  (fast ~2 min failure), and
- a near-full disk starves I/O badly enough to trip the 30-minute job
  timeout.

test-python-client-oracle and test-typescript-client-oracle have been red on
every open PR (vectorize-io#2941, vectorize-io#2942, vectorize-io#2943) from this, independent of the code under
test. Reclaim ~20 GB of preinstalled tooling (the same jlumbroso action the
Docker build job already uses) before the Oracle setup step.

docker-images stays false here: unlike the Docker build job, the Oracle
service container is already running by the time steps execute, so pruning
images could disrupt it. The savings come from the tool cache, Android SDK,
.NET, Haskell, large apt packages and swap.

* ci(oracle): trim disk reclaim to the fast, high-yield options

The first pass enabled every reclaim, which cost ~4 minutes of job time —
counterproductive on jobs that are already fighting a 30-minute limit.

android + dotnet + haskell + swap are a few rm -rf's worth ~16-21 GB, which
is ample headroom for the Oracle image plus torch. Dropped:
- large-packages: apt-get remove, costs minutes for little extra space;
- tool-cache: deletes the preinstalled Python that actions/setup-python then
  re-downloads, making the job slower rather than faster.

* fix(retain): flush entity stats after releasing the connection (Oracle hang)

Retain hung forever on the Oracle backend: every retain test burned its 120s
client timeout while the server sat idle, so test-python-client-oracle and
test-typescript-client-oracle only ever reached ~5% of the suite before the
30-minute job limit.

The server was not slow — it was deadlocked. flush_pending_stats() acquires
its own connection, but it was being called while the enclosing
acquire_with_retry(...) block still held one:

  async with acquire_with_retry(pool) as conn:   # conn checked out
      async with conn.transaction():             # SAVEPOINT only
          ...write facts/entities...
      await entity_resolver.flush_pending_stats()  # takes a 2nd connection

oracledb does not autocommit and OracleConnection.transaction() is only a
SAVEPOINT, so the write is committed by OracleBackend.acquire() when its block
exits. Connection vectorize-io#2's `UPDATE entities ...` therefore waits on row locks held
by the still-open connection #1, which cannot commit until the call returns —
a circular wait. Oracle never reports ORA-00060 because session #1 is blocked
in Python, not on the database, so it hangs indefinitely instead of erroring.

Move the flush after the acquire block in all three call sites (streaming
retain, delta retain, transfer importer), which is what its own docstring
already required ("must be called AFTER the retain transaction commits") and
which PostgreSQL satisfied only by accident via asyncpg autocommit.

Guarded with an AST lint test rather than a behavioural one: the deadlock
cannot be reproduced against PostgreSQL, which is what the suite runs on.

* test(repair): retry the concurrent index drop on deadlock

test_dry_run_creates_nothing still flaked in test-api shard 3. CONCURRENTLY
avoids ACCESS EXCLUSIVE but still takes ShareUpdateExclusive, which conflicts
with the ShareLock a fresh bank's plain CREATE INDEX holds — and that one
cannot be made concurrent, since it runs inside the bank-create transaction.
So _drop_bank_indexes can still be picked as the deadlock victim while another
xdist worker seeds a bank:

  Process A waits for ShareUpdateExclusiveLock on memory_units; blocked by B.
  Process B waits for ShareLock on virtual transaction; blocked by A.

The bank-create side already retries (vectorize-io#2943); give the drop the same treatment.
The drop is idempotent, so retrying is safe.
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