Skip to content
Merged
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
29 changes: 29 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,35 @@ before it becomes the `aud`. `https` rules are unchanged. The flag widens only
which http hosts are accepted. It does not make a public http origin acceptable,
so turn it on only where History is reached over a trusted private network.

### Machine binding (`meta.machineId`)

History assertions for courier writes carry the machine they were minted for
as `meta.machineId`, so ingest can check that a manifest POST or status call
comes from the machine named in the manifest key's `m=` component. Read-only
and control-plane History paths may omit the request body field because they do
not represent a courier machine. When `machineId` is supplied it is:

- 1 to 128 characters of `A-Za-z0-9._-`, the same form as the `m=` component;
- no leading `.`, and no `relay_` prefix (machine ids have no RelayAuth alias);
- exact bytes: values that would need trimming, case folding or prefix
stripping are refused (400 `invalid_machine_id`), never rewritten.

An omitted `machineId` produces no `meta.machineId` claim and the audit row is
marked `machineIdMissing: "true"`. Consumers of machine-bound operations must
require the claim and match it to their own authenticated machine context;
RelayAuth cannot infer that context for read-only or control-plane callers.

**Who is trusted to assert it.** RelayAuth validates the form only. It cannot
know which machine a courier runs on, so it signs the `machineId` that the
holder of the dedicated `relayauth:assertion:create:relayhistory` API key sends
(the control plane / Cloud's broker), exactly as it already signs that caller's
`orgId`, `workspaceId` and `sponsorId`. That caller must establish the machine
from its own authenticated session with the courier before asking, and must
never pass through a value the courier chose without that check. The route
accepts only that dedicated key over `x-api-key` (a bearer token carrying the
scope is refused with `assertion_key_required`), so couriers and other callers
cannot choose a `machineId`. Never hand the assertion key to a courier.

## Session directory search issuer

RelayAuth can mint 60-second tokens for one PostgREST RPC,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ test("bootstrap api key is seeded idempotently, hash-only, and mints History ass
orgId: "org_a",
workspaceId: "ws_a",
sponsorId: "user_a",
machineId: "machine-a",
scopes: ["rth:sync"],
expiresIn: 60,
historyOrigin: "http://127.0.0.1:18780",
Expand Down
197 changes: 197 additions & 0 deletions packages/server/src/__tests__/tokens-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
seedPlainApiKey,
seedWorkspaceContext,
TEST_RS256_PRIVATE_KEY_PEM,
TEST_RS256_PUBLIC_KEY_PEM,
} from "./test-helpers.js";

type JwtHeader = {
Expand Down Expand Up @@ -2271,6 +2272,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
machineId: "machine-a",
scopes: ["rth:sync", "rth:read"],
expiresIn: 45,
},
Expand Down Expand Up @@ -2315,6 +2317,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
requestedOrgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
grantedScopes: JSON.stringify(["rth:read", "rth:sync"]),
machineId: "machine-a",
});
assert.equal(
await countStoredTokens(app),
Expand Down Expand Up @@ -2346,6 +2349,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
actorOrgId: "org_tokens_route",
sponsorId: "user_cloud_login",
grantedScopes: JSON.stringify(["rth:read", "rth:sync"]),
machineId: "machine-a",
});
},
);
Expand All @@ -2369,6 +2373,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
machineId: "machine-a",
scopes: ["rth:read"],
},
headers: { "x-api-key": assertionKey.key },
Expand Down Expand Up @@ -2399,6 +2404,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
machineId: "machine-a",
scopes: ["rth:read"],
},
headers: authHeaders,
Expand Down Expand Up @@ -2611,6 +2617,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
machineId: "machine-a",
scopes: ["rth:read", "rth:admin"],
},
headers: {
Expand Down Expand Up @@ -2644,6 +2651,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
machineId: "machine-a",
scopes: ["rth:read"],
expiresIn: 61,
},
Expand Down Expand Up @@ -2675,6 +2683,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
machineId: "machine-a",
scopes: ["rth:read"],
historyOrigin,
},
Expand Down Expand Up @@ -2895,6 +2904,194 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => {
});
});

function verifyRs256Token(token: string): boolean {
const [header, payload, signature] = token.split(".");
return crypto.verify(
"RSA-SHA256",
Buffer.from(`${header}.${payload}`),
TEST_RS256_PUBLIC_KEY_PEM,
Buffer.from(signature ?? "", "base64url"),
);
}

test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => {
const assertionScope = "relayauth:assertion:create:relayhistory";
const baseBody = {
orgId: "org_relayhistory_target",
workspaceId: "ws_relayhistory_target",
sponsorId: "user_cloud_login",
scopes: ["rth:sync"],
};

async function harness() {
const { app, authHeaders } = await createHarness({
authClaims: {
scopes: ["relayauth:api-key:manage:*", assertionScope],
},
});
// Broker keys are provisioned out of band. Current main deliberately
// refuses bearer/admin callers that try to delegate this literal scope.
const assertionKey = await seedPlainApiKey(app, {
orgId: "org_tokens_route",
name: "relayhistory-machine-binding-broker",
scopes: [assertionScope],
});
const mint = (body: Record<string, unknown>, headers?: HeadersInit) =>
requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", {
body,
headers: headers ?? { "x-api-key": assertionKey.key },
});
return { app, authHeaders, assertionKey, mint };
}

async function lastAssertionAudit(app: ReturnType<typeof createTestApp>) {
const row = await app.storage.DB.prepare(
`SELECT metadata_json FROM audit_logs
WHERE resource = 'relayhistory-assertion'
ORDER BY timestamp DESC LIMIT 1`,
).first<{ metadata_json: string }>();
return JSON.parse(row?.metadata_json ?? "{}") as Record<string, string>;
}

for (const machineId of [
"machine-a",
"M",
"unknown-origin-42",
"host.example_01",
"a".repeat(128),
"019a1f3c-8e2b-7c41-9d3a-5b6f0e2a7c18",
]) {
await t.test(`signs a canonical machineId (${machineId.slice(0, 24)})`, async () => {
const { app, mint } = await harness();
const body = await assertJsonResponse<RelayhistoryAssertionResponse>(
await mint({ ...baseBody, machineId }),
201,
);
assert.equal(verifyRs256Token(body.accessToken), true);
const claims = decodeJwtJsonSegment<RelayAuthTokenClaims>(body.accessToken, 1);
assert.equal(claims.meta?.machineId, machineId);
assert.equal((await lastAssertionAudit(app)).machineId, machineId);
});
}

for (const machineId of [
"",
" machine-a",
"machine-a ",
"machine a",
"machine/a",
"m=machine-a",
".hidden",
"..",
".",
"relay_machine-a",
"a".repeat(129),
"machine-\u00e4",
"machine-a\n",
null,
42,
true,
["machine-a"],
{ id: "machine-a" },
]) {
await t.test(`refuses a malformed machineId (${JSON.stringify(machineId)})`, async () => {
const { app, mint } = await harness();
await assertJsonResponse<ErrorBody>(
await mint({ ...baseBody, machineId }),
400,
(body) => {
assert.equal(body.code, "invalid_machine_id");
},
);
assert.equal(await countStoredTokens(app), 0);
});
}

await t.test("allows and audits an omitted machineId for non-machine paths", async () => {
const { app, mint } = await harness();
const body = await assertJsonResponse<RelayhistoryAssertionResponse>(
await mint(baseBody),
201,
);
const claims = decodeJwtJsonSegment<RelayAuthTokenClaims>(body.accessToken, 1);
assert.equal(claims.meta?.machineId, undefined);
assert.equal("machineId" in (claims.meta ?? {}), false);
const audit = await lastAssertionAudit(app);
assert.equal(audit.machineIdMissing, "true");
assert.equal(audit.machineId, undefined);

// A supplied machineId is still validated and signed exactly.
const withMachine = await assertJsonResponse<RelayhistoryAssertionResponse>(
await mint({ ...baseBody, machineId: "machine-a" }),
201,
);
assert.equal(
decodeJwtJsonSegment<RelayAuthTokenClaims>(withMachine.accessToken, 1).meta?.machineId,
"machine-a",
);
});

await t.test("machineId does not widen who may mint or for which org", async () => {
const { app, authHeaders, assertionKey, mint } = await harness();
// The claim is only ever minted through the dedicated assertion key.
await assertJsonResponse<ErrorBody>(
await mint({ ...baseBody, machineId: "machine-a" }, authHeaders),
403,
(body) => {
assert.equal(body.code, "assertion_key_required");
},
);
const otherKey = await issueApiKey(app, authHeaders, ["relayauth:api-key:manage:*"]);
const wrongScope = await mint(
{ ...baseBody, machineId: "machine-a" },
{ "x-api-key": otherKey.key },
);
assert.equal(wrongScope.status, 403);

// A machine id never stands in for, or rewrites, org/workspace/sponsor.
for (const [field, code] of [
["orgId", "orgId_required"],
["workspaceId", "workspaceId_required"],
["sponsorId", "sponsorId_required"],
] as const) {
const { [field]: _omitted, ...partial } = baseBody;
await assertJsonResponse<ErrorBody>(
await mint({ ...partial, machineId: "org_relayhistory_target" }),
400,
(body) => {
assert.equal(body.code, code);
},
);
}
const body = await assertJsonResponse<RelayhistoryAssertionResponse>(
await mint({
...baseBody,
machineId: "org_other",
meta: { machineId: "injected", org: "org_other" },
org: "org_other",
}),
201,
);
const claims = decodeJwtJsonSegment<RelayAuthTokenClaims>(body.accessToken, 1);
assert.equal(claims.org, baseBody.orgId);
assert.equal(claims.wks, baseBody.workspaceId);
assert.equal(claims.sponsorId, baseBody.sponsorId);
assert.equal(claims.meta?.requestedOrgId, baseBody.orgId);
assert.equal(claims.meta?.actorOrgId, "org_tokens_route");
assert.equal(claims.meta?.actorId, "api_key:" + assertionKey.apiKey.id);
assert.equal(claims.meta?.machineId, "org_other");
assert.deepEqual(Object.keys(claims.meta ?? {}).sort(), [
"actorId",
"actorOrgId",
"grantedScopes",
"machineId",
"requestedOrgId",
"tokenClass",
"workspaceId",
]);
});
});

test("POST /v1/tokens/refresh", async (t) => {
await t.test(
"refreshes a RS256 token pair without requiring a bearer token",
Expand Down
Loading
Loading