Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions apps/server/src/auth/identity.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
34 changes: 34 additions & 0 deletions apps/server/src/auth/identity.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string>;
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: "" };
}
24 changes: 24 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,30 @@ export const ADMIN_EMAILS: ReadonlySet<string> = 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<string> = 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.
Expand Down
17 changes: 13 additions & 4 deletions apps/server/src/routes/sessions/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down