From a4bcb3cf56896ce002bb4448e0c0bb2652be1ab9 Mon Sep 17 00:00:00 2001 From: Hargun Singh Date: Tue, 3 Nov 2026 22:54:26 -0500 Subject: [PATCH] feat: let a trusted delegator create a session for the person it names --- .env.example | 5 ++ apps/server/src/auth/identity.test.ts | 77 +++++++++++++++++++++ apps/server/src/auth/identity.ts | 34 +++++++++ apps/server/src/config.ts | 24 +++++++ apps/server/src/routes/sessions/handlers.ts | 17 +++-- docs/server/index.md | 1 + 6 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/auth/identity.test.ts diff --git a/.env.example b/.env.example index 7f96c795..00f68186 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,11 @@ AUTH_JWT_TOKEN_COOKIE_NAME= # Comma-separated admin emails. Admins manage bundle editors and edit global # memory; leave empty to disable the role gate. ADMIN_EMAILS= +# Comma-separated service identities allowed to create a session for someone +# else, and the header (set by their egress proxy, never by the caller) that +# names that person. Both must be set; leave empty to disable. +SESSION_DELEGATORS= +SESSION_DELEGATE_EMAIL_HEADER= INSTANCE_PROXY_URL= # Default bundle used by the Sessions page New session button. diff --git a/apps/server/src/auth/identity.test.ts b/apps/server/src/auth/identity.test.ts new file mode 100644 index 00000000..400380da --- /dev/null +++ b/apps/server/src/auth/identity.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { UserIdentity } from "@tangent/shared/contracts.ts"; + +import { resolveSessionCreator, type SessionDelegation } from "./identity.ts"; + +const HEADER = "x-forward-user-email"; +const DELEGATION: SessionDelegation = { + delegators: new Set(["bot@example.com"]), + header: HEADER, +}; + +const BOT: UserIdentity = { + email: "Bot@example.com", + first_name: "Bot", + last_name: "Service", +}; +const ALICE: UserIdentity = { + email: "alice@example.com", + first_name: "Alice", + last_name: "Smith", +}; + +test("a trusted delegator creates the session for the person it names", () => { + assert.deepEqual( + resolveSessionCreator(BOT, { [HEADER]: " Carol@Example.com " }, DELEGATION), + { email: "carol@example.com", first_name: "", last_name: "" }, + ); +}); + +test("anyone else creates the session for themselves, header or not", () => { + assert.equal( + resolveSessionCreator(ALICE, { [HEADER]: "carol@example.com" }, DELEGATION), + ALICE, + ); +}); + +test("a delegator that names nobody, or no valid address, stays itself", () => { + for (const named of [undefined, "", "not-an-email", "a@x.com, b@y.com"]) { + assert.equal( + resolveSessionCreator(BOT, { [HEADER]: named }, DELEGATION), + BOT, + String(named), + ); + } + assert.equal( + resolveSessionCreator( + BOT, + { [HEADER]: ["a@example.com", "b@example.com"] }, + DELEGATION, + ), + BOT, + ); +}); + +test("delegation is off unless both settings are configured", () => { + const headers = { [HEADER]: "carol@example.com" }; + assert.equal( + resolveSessionCreator(BOT, headers, { ...DELEGATION, header: "" }), + BOT, + ); + assert.equal( + resolveSessionCreator(BOT, headers, { + ...DELEGATION, + delegators: new Set(), + }), + BOT, + ); +}); + +test("an anonymous caller cannot delegate", () => { + assert.equal( + resolveSessionCreator(null, { [HEADER]: "carol@example.com" }, DELEGATION), + null, + ); +}); diff --git a/apps/server/src/auth/identity.ts b/apps/server/src/auth/identity.ts index ce559973..01adad06 100644 --- a/apps/server/src/auth/identity.ts +++ b/apps/server/src/auth/identity.ts @@ -1,3 +1,5 @@ +import type { IncomingHttpHeaders } from "node:http"; + import type { UserIdentity } from "@tangent/shared/contracts.ts"; import { AUTH_JWT_TOKEN_COOKIE_NAME } from "../config.ts"; @@ -99,3 +101,35 @@ export function resolveUserIdentity( parseCookies(cookieHeader)[AUTH_JWT_TOKEN_COOKIE_NAME], ); } + +/** Who may create a session for someone else, and where they name that person. */ +export interface SessionDelegation { + delegators: ReadonlySet; + header: string; +} + +const EMAIL_SHAPE = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/; + +/** + * Resolves who a new session is created for. A caller listed in + * `delegation.delegators` may name another person in `delegation.header`; that + * header is trusted only because the delegator's egress proxy overwrites it. + * Anyone else, or a delegator naming nobody, creates the session for + * themselves. Only session creation honors this, so the delegator keeps its own + * identity everywhere else and never gains the owner's privileges. + */ +export function resolveSessionCreator( + caller: UserIdentity | null, + headers: IncomingHttpHeaders, + delegation: SessionDelegation, +): UserIdentity | null { + if (!caller || !delegation.header) return caller; + if (!delegation.delegators.has(caller.email.toLowerCase())) return caller; + + const named = headers[delegation.header]; + if (typeof named !== "string") return caller; + const email = named.trim().toLowerCase(); + if (!EMAIL_SHAPE.test(email)) return caller; + + return { email, first_name: "", last_name: "" }; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 62256cac..48c13f37 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -217,6 +217,30 @@ export const ADMIN_EMAILS: ReadonlySet = new Set( .filter(Boolean), ); +/** + * Callers (lowercased emails) trusted to create a session on behalf of the + * person named in {@link SESSION_DELEGATE_EMAIL_HEADER}, e.g. an agent + * platform's service identity whose egress proxy stamps that header. Both + * settings must be set; empty by default, which turns delegation off. + */ +export const SESSION_DELEGATORS: ReadonlySet = new Set( + (process.env.SESSION_DELEGATORS ?? "") + .split(",") + .map((email) => email.trim().toLowerCase()) + .filter(Boolean), +); + +/** + * Request header (lowercased) naming the person a trusted delegator creates a + * session for. It must be set by infrastructure the delegator cannot control; + * see {@link SESSION_DELEGATORS}. + */ +export const SESSION_DELEGATE_EMAIL_HEADER = ( + process.env.SESSION_DELEGATE_EMAIL_HEADER ?? "" +) + .trim() + .toLowerCase(); + /** * Base URL of the Tangle API reached by the bundle-UI/agent egress allowlist. * The OpenAPI doc declares no `servers`, so this is supplied per environment. diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 20f4fb82..8779214a 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -15,9 +15,14 @@ import { PI_AGENT, sourceFromAuthor } from "@tangent/shared/contracts.ts"; import type { Request, Response } from "express"; import multer from "multer"; -import { resolveUserIdentity } from "../../auth/identity.ts"; +import { + resolveSessionCreator, + resolveUserIdentity, +} from "../../auth/identity.ts"; import { ARTIFACTS_DIRNAME, + SESSION_DELEGATE_EMAIL_HEADER, + SESSION_DELEGATORS, SESSIONS_ROOT, UPLOADS_DIRNAME, } from "../../config.ts"; @@ -288,10 +293,14 @@ export async function handleCreateSession( } // Resolve the creator's identity from their bearer token or Oktasso JWT - // cookie so every agent spawned for the session knows who it's helping. + // cookie so every agent spawned for the session knows who it's helping. A + // trusted delegator creates it for the person it names instead. const user = - resolveUserIdentity(req.headers.cookie, req.headers.authorization) ?? - undefined; + resolveSessionCreator( + resolveUserIdentity(req.headers.cookie, req.headers.authorization), + req.headers, + { delegators: SESSION_DELEGATORS, header: SESSION_DELEGATE_EMAIL_HEADER }, + ) ?? undefined; const session = await deps.store.createSession({ name: body.name, user, diff --git a/docs/server/index.md b/docs/server/index.md index a2193592..8761abe7 100644 --- a/docs/server/index.md +++ b/docs/server/index.md @@ -254,6 +254,7 @@ From [apps/server/src/config.ts](../../apps/server/src/config.ts): connector; `A2A_TOKEN` — the outbound bearer for A2A peers. - `EMBED_ALLOWED_ORIGINS` — cross-origin embed hosts (CORS + Socket.IO handshake). - `TANGLE_API_URL` — origin for the egress allowlist. +- `SESSION_DELEGATORS` + `SESSION_DELEGATE_EMAIL_HEADER` — callers trusted to create a session for the person a proxy-stamped header names (off unless both are set). ## Glossary