authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-05 18:01:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-05 18:01:34-07:00
log3ea6adf17d5528d9c90ed53b6dead650d5931941
tree6cd08b21cc8f01f4f99f4606ff4a330ca0eef7f5
parenta56496c7f20d53369ef87101681ee2a99b3a4337
signature Signed by PGP key B5690EEEBB952194

[codex] Enrich relay authorization diagnostics (#2977)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 188 insertions(+), 25 deletions(-)

infra/relay/src/environments/EnvironmentConnector.test.ts+47-3
......@@ -22,6 +22,7 @@ import * as Redacted from "effect/Redacted";
2222import * as Result from "effect/Result";
2323import * as Schema from "effect/Schema";
2424import * as TestClock from "effect/testing/TestClock";
25import * as Tracer from "effect/Tracer";
2526import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
2627
2728import * as EnvironmentLinks from "./EnvironmentLinks.ts";
......@@ -50,6 +51,9 @@ const decodeHealthRequestBody = Schema.decodeUnknownSync(
5051const decodeMintRequestBody = Schema.decodeUnknownSync(
5152 Schema.fromJsonString(RelayCloudMintCredentialRequest),
5253);
54const isEnvironmentConnectNotAuthorized = Schema.is(
55 EnvironmentConnector.EnvironmentConnectNotAuthorized,
56);
5357
5458function requestBodyText(request: HttpClientRequest.HttpClientRequest): string {
5559 return request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}";
......@@ -280,7 +284,13 @@ describe("EnvironmentConnector", () => {
280284
281285 expect(Result.isFailure(result)).toBe(true);
282286 if (Result.isFailure(result)) {
283 expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized);
287 expect(isEnvironmentConnectNotAuthorized(result.failure)).toBe(true);
288 if (isEnvironmentConnectNotAuthorized(result.failure)) {
289 expect(result.failure).toMatchObject({
290 operation: "status",
291 reason: "endpoint_provider_not_managed",
292 });
293 }
284294 }
285295 expect(requestCount).toBe(0);
286296 }).pipe(
......@@ -300,6 +310,14 @@ describe("EnvironmentConnector", () => {
300310
301311 it.effect("rejects stale managed endpoints before sending a mint request", () => {
302312 let requestCount = 0;
313 const spans: Array<Tracer.NativeSpan> = [];
314 const tracer = Tracer.make({
315 span: (options) => {
316 const span = new Tracer.NativeSpan(options);
317 spans.push(span);
318 return span;
319 },
320 });
303321 const execute = () =>
304322 Effect.sync(() => {
305323 requestCount += 1;
......@@ -318,8 +336,27 @@ describe("EnvironmentConnector", () => {
318336
319337 expect(Result.isFailure(result)).toBe(true);
320338 if (Result.isFailure(result)) {
321 expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized);
339 expect(isEnvironmentConnectNotAuthorized(result.failure)).toBe(true);
340 if (isEnvironmentConnectNotAuthorized(result.failure)) {
341 expect(result.failure).toMatchObject({
342 operation: "connect",
343 reason: "managed_endpoint_mismatch",
344 });
345 }
322346 }
347 const resolutionSpan = spans.find(
348 (span) => span.name === "relay.environment_connector.resolve_managed_endpoint",
349 );
350 expect(Object.fromEntries(resolutionSpan?.attributes ?? [])).toMatchObject({
351 "relay.authorization.allocation_hostname": "env.example.test",
352 "relay.authorization.allocation_has_ready_at": true,
353 "relay.authorization.allocation_has_tunnel_id": true,
354 "relay.authorization.allocation_has_dns_record_id": true,
355 "relay.authorization.linked_http_base_url": "https://attacker.example.test/",
356 "relay.authorization.linked_ws_base_url": "wss://attacker.example.test/ws",
357 "relay.authorization.resolved_http_base_url": "https://env.example.test/",
358 "relay.authorization.resolved_ws_base_url": "wss://env.example.test/ws",
359 });
323360 expect(requestCount).toBe(0);
324361 }).pipe(
325362 Effect.provide(
......@@ -333,6 +370,7 @@ describe("EnvironmentConnector", () => {
333370 }),
334371 }),
335372 ),
373 Effect.provideService(Tracer.Tracer, tracer),
336374 );
337375 });
338376
......@@ -355,7 +393,13 @@ describe("EnvironmentConnector", () => {
355393
356394 expect(Result.isFailure(result)).toBe(true);
357395 if (Result.isFailure(result)) {
358 expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized);
396 expect(isEnvironmentConnectNotAuthorized(result.failure)).toBe(true);
397 if (isEnvironmentConnectNotAuthorized(result.failure)) {
398 expect(result.failure).toMatchObject({
399 operation: "status",
400 reason: "managed_endpoint_allocation_not_ready",
401 });
402 }
359403 }
360404 expect(requestCount).toBe(0);
361405 }).pipe(
infra/relay/src/environments/EnvironmentConnector.ts+132-12
......@@ -40,15 +40,54 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http";
4040import * as EnvironmentLinks from "./EnvironmentLinks.ts";
4141import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
4242import * as RelayConfiguration from "../Config.ts";
43import { isManagedEndpointHostname } from "../deploymentConfig.ts";
44
45export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([
46 "client_proof_key_thumbprint_missing",
47 "environment_link_not_found",
48 "endpoint_provider_not_managed",
49 "managed_endpoint_allocation_not_found",
50 "managed_endpoint_base_domain_not_configured",
51 "managed_endpoint_allocation_not_ready",
52 "managed_endpoint_hostname_invalid",
53 "managed_endpoint_mismatch",
54]);
55export type EnvironmentConnectNotAuthorizedReason =
56 typeof EnvironmentConnectNotAuthorizedReason.Type;
57
58function environmentConnectNotAuthorizedReasonMessage(
59 reason: EnvironmentConnectNotAuthorizedReason,
60): string {
61 switch (reason) {
62 case "client_proof_key_thumbprint_missing":
63 return "the client proof key thumbprint is missing";
64 case "environment_link_not_found":
65 return "no active environment link was found";
66 case "endpoint_provider_not_managed":
67 return "the linked endpoint is not relay-managed";
68 case "managed_endpoint_allocation_not_found":
69 return "no managed endpoint allocation was found";
70 case "managed_endpoint_base_domain_not_configured":
71 return "the managed endpoint base domain is not configured";
72 case "managed_endpoint_allocation_not_ready":
73 return "the managed endpoint allocation is incomplete";
74 case "managed_endpoint_hostname_invalid":
75 return "the managed endpoint hostname is invalid";
76 case "managed_endpoint_mismatch":
77 return "the linked endpoint does not match its managed allocation";
78 }
79}
4380
4481export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass<EnvironmentConnectNotAuthorized>()(
4582 "EnvironmentConnectNotAuthorized",
4683 {
4784 environmentId: Schema.String,
85 operation: Schema.Literals(["connect", "status"]),
86 reason: EnvironmentConnectNotAuthorizedReason,
4887 },
4988) {
5089 override get message(): string {
51 return `Environment '${this.environmentId}' is not authorized to connect`;
90 return `Environment '${this.environmentId}' is not authorized for ${this.operation}: ${environmentConnectNotAuthorizedReasonMessage(this.reason)}`;
5291 }
5392}
5493
......@@ -257,30 +296,91 @@ const make = Effect.gen(function* () {
257296 const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")(
258297 function* (input: {
259298 readonly userId: string;
299 readonly operation: "connect" | "status";
260300 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;
261301 }) {
262302 if (input.link.endpoint.providerKind !== "cloudflare_tunnel") {
303 yield* Effect.annotateCurrentSpan({
304 "relay.authorization.endpoint_provider_kind": input.link.endpoint.providerKind,
305 });
263306 return yield* new EnvironmentConnectNotAuthorized({
264307 environmentId: input.link.environmentId,
308 operation: input.operation,
309 reason: "endpoint_provider_not_managed",
265310 });
266311 }
267312 const allocation = yield* allocations.get({
268313 userId: input.userId,
269314 environmentId: input.link.environmentId,
270315 });
271 const endpoint = allocation
272 ? ManagedEndpointAllocations.resolveReadyManagedEndpoint({
273 allocation,
274 baseDomain: settings.managedEndpointBaseDomain,
275 })
276 : null;
316 if (!allocation) {
317 return yield* new EnvironmentConnectNotAuthorized({
318 environmentId: input.link.environmentId,
319 operation: input.operation,
320 reason: "managed_endpoint_allocation_not_found",
321 });
322 }
323 const allocationAttributes = {
324 "relay.authorization.allocation_hostname": allocation.hostname,
325 "relay.authorization.allocation_has_ready_at": allocation.readyAt !== null,
326 "relay.authorization.allocation_has_tunnel_id": allocation.tunnelId !== null,
327 "relay.authorization.allocation_has_dns_record_id": allocation.dnsRecordId !== null,
328 } as const;
329 if (!settings.managedEndpointBaseDomain) {
330 yield* Effect.annotateCurrentSpan(allocationAttributes);
331 return yield* new EnvironmentConnectNotAuthorized({
332 environmentId: input.link.environmentId,
333 operation: input.operation,
334 reason: "managed_endpoint_base_domain_not_configured",
335 });
336 }
337 if (
338 allocation.readyAt === null ||
339 allocation.tunnelId === null ||
340 allocation.dnsRecordId === null
341 ) {
342 yield* Effect.annotateCurrentSpan(allocationAttributes);
343 return yield* new EnvironmentConnectNotAuthorized({
344 environmentId: input.link.environmentId,
345 operation: input.operation,
346 reason: "managed_endpoint_allocation_not_ready",
347 });
348 }
349 if (!isManagedEndpointHostname(allocation.hostname, settings.managedEndpointBaseDomain)) {
350 yield* Effect.annotateCurrentSpan({
351 ...allocationAttributes,
352 "relay.authorization.managed_endpoint_base_domain": settings.managedEndpointBaseDomain,
353 });
354 return yield* new EnvironmentConnectNotAuthorized({
355 environmentId: input.link.environmentId,
356 operation: input.operation,
357 reason: "managed_endpoint_hostname_invalid",
358 });
359 }
360 const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({
361 allocation,
362 baseDomain: settings.managedEndpointBaseDomain,
363 });
277364 if (
278365 endpoint === null ||
279366 endpoint.httpBaseUrl !== input.link.endpoint.httpBaseUrl ||
280367 endpoint.wsBaseUrl !== input.link.endpoint.wsBaseUrl
281368 ) {
369 yield* Effect.annotateCurrentSpan({
370 ...allocationAttributes,
371 "relay.authorization.linked_http_base_url": input.link.endpoint.httpBaseUrl,
372 "relay.authorization.linked_ws_base_url": input.link.endpoint.wsBaseUrl,
373 ...(endpoint
374 ? {
375 "relay.authorization.resolved_http_base_url": endpoint.httpBaseUrl,
376 "relay.authorization.resolved_ws_base_url": endpoint.wsBaseUrl,
377 }
378 : {}),
379 });
282380 return yield* new EnvironmentConnectNotAuthorized({
283381 environmentId: input.link.environmentId,
382 operation: input.operation,
383 reason: "managed_endpoint_mismatch",
284384 });
285385 }
286386 return endpoint;
......@@ -295,9 +395,17 @@ const make = Effect.gen(function* () {
295395 });
296396 const link = yield* links.getForUser(input);
297397 if (!link) {
298 return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId });
398 return yield* new EnvironmentConnectNotAuthorized({
399 environmentId: input.environmentId,
400 operation: "status",
401 reason: "environment_link_not_found",
402 });
299403 }
300 const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link });
404 const endpoint = yield* resolveManagedEndpoint({
405 userId: input.userId,
406 operation: "status",
407 link,
408 });
301409 const now = yield* DateTime.now;
302410 const expiresAt = DateTime.add(now, { minutes: 2 });
303411 const nonce = yield* crypto.randomUUIDv4.pipe(
......@@ -404,13 +512,25 @@ const make = Effect.gen(function* () {
404512 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
405513 });
406514 if (input.clientProofKeyThumbprint.trim().length === 0) {
407 return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId });
515 return yield* new EnvironmentConnectNotAuthorized({
516 environmentId: input.environmentId,
517 operation: "connect",
518 reason: "client_proof_key_thumbprint_missing",
519 });
408520 }
409521 const link = yield* links.getForUser(input);
410522 if (!link) {
411 return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId });
523 return yield* new EnvironmentConnectNotAuthorized({
524 environmentId: input.environmentId,
525 operation: "connect",
526 reason: "environment_link_not_found",
527 });
412528 }
413 const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link });
529 const endpoint = yield* resolveManagedEndpoint({
530 userId: input.userId,
531 operation: "connect",
532 link,
533 });
414534 const now = yield* DateTime.now;
415535 const expiresAt = DateTime.add(now, { minutes: 2 });
416536 const nonce = yield* crypto.randomUUIDv4.pipe(
infra/relay/src/http/Api.ts+4-4
......@@ -830,7 +830,7 @@ const currentTraceId = Effect.currentParentSpan.pipe(
830830 Effect.orElseSucceed(() => "unavailable"),
831831);
832832
833const COMMON_AUTH_INVALID_REASONS = [
833const RelayCommonPersistenceError = Schema.Union([
834834 Devices.DeviceRegistrationPersistenceError,
835835 Devices.DeviceUnregistrationPersistenceError,
836836 Devices.DeviceListPersistenceError,
......@@ -850,9 +850,9 @@ const COMMON_AUTH_INVALID_REASONS = [
850850 AgentActivityRows.AgentActivityRowListPersistenceError,
851851 LiveActivities.LiveActivityDeliveryMarkPersistenceError,
852852 DeliveryAttempts.DeliveryAttemptRecordPersistenceError,
853] as const;
854type RelayCommonPersistenceError = InstanceType<(typeof COMMON_AUTH_INVALID_REASONS)[number]>;
855const isRelayCommonPersistenceError = Schema.is(Schema.Union(COMMON_AUTH_INVALID_REASONS));
853]);
854type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
855const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
856856
857857type MapRelayCommonApiError<E> =
858858 | Exclude<E, HttpApiError.Unauthorized | RelayCommonPersistenceError>
infra/relay/src/observability.test.ts+5-6
......@@ -9,7 +9,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
99import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
1010import type { OtlpTracer } from "effect/unstable/observability";
1111
12import { EnvironmentMintRequestFailed } from "./environments/EnvironmentConnector.ts";
12import { EnvironmentConnectNotAuthorized } from "./environments/EnvironmentConnector.ts";
1313import { makeRelayTraceLayer } from "./observability.ts";
1414
1515interface ExportedRequest {
......@@ -43,10 +43,10 @@ it.effect("exports schema error fields as span attributes", () =>
4343 );
4444
4545 yield* Effect.fail(
46 new EnvironmentMintRequestFailed({
46 new EnvironmentConnectNotAuthorized({
4747 environmentId: "environment-1",
4848 operation: "connect",
49 cause: new Error("upstream unavailable"),
49 reason: "managed_endpoint_allocation_not_ready",
5050 }),
5151 ).pipe(
5252 Effect.withSpan("relay.test.schema_error"),
......@@ -76,11 +76,10 @@ it.effect("exports schema error fields as span attributes", () =>
7676 expect(request.authorization).toBe("Bearer test-token");
7777 expect(request.dataset).toBe("relay-test-traces");
7878 expect(attributes).toMatchObject({
79 "error.type": "EnvironmentMintRequestFailed",
79 "error.type": "EnvironmentConnectNotAuthorized",
8080 "error.environmentId": "environment-1",
8181 "error.operation": "connect",
82 "error.cause.name": "Error",
83 "error.cause.message": "upstream unavailable",
82 "error.reason": "managed_endpoint_allocation_not_ready",
8483 });
8584 }).pipe(Effect.provide(NodeHttpServer.layerTest), Effect.scoped),
8685);