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";...@@ -22,6 +22,7 @@ import * as Redacted from "effect/Redacted";
22import * as Result from "effect/Result";22import * as Result from "effect/Result";
23import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
24import * as TestClock from "effect/testing/TestClock";24import * as TestClock from "effect/testing/TestClock";
25import * as Tracer from "effect/Tracer";
25import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";26import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
2627
27import * as EnvironmentLinks from "./EnvironmentLinks.ts";28import * as EnvironmentLinks from "./EnvironmentLinks.ts";
...@@ -50,6 +51,9 @@ const decodeHealthRequestBody = Schema.decodeUnknownSync(...@@ -50,6 +51,9 @@ const decodeHealthRequestBody = Schema.decodeUnknownSync(
50const decodeMintRequestBody = Schema.decodeUnknownSync(51const decodeMintRequestBody = Schema.decodeUnknownSync(
51 Schema.fromJsonString(RelayCloudMintCredentialRequest),52 Schema.fromJsonString(RelayCloudMintCredentialRequest),
52);53);
54const isEnvironmentConnectNotAuthorized = Schema.is(
55 EnvironmentConnector.EnvironmentConnectNotAuthorized,
56);
5357
54function requestBodyText(request: HttpClientRequest.HttpClientRequest): string {58function requestBodyText(request: HttpClientRequest.HttpClientRequest): string {
55 return request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}";59 return request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}";
...@@ -280,7 +284,13 @@ describe("EnvironmentConnector", () => {...@@ -280,7 +284,13 @@ describe("EnvironmentConnector", () => {
280284
281 expect(Result.isFailure(result)).toBe(true);285 expect(Result.isFailure(result)).toBe(true);
282 if (Result.isFailure(result)) {286 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 }
284 }294 }
285 expect(requestCount).toBe(0);295 expect(requestCount).toBe(0);
286 }).pipe(296 }).pipe(
...@@ -300,6 +310,14 @@ describe("EnvironmentConnector", () => {...@@ -300,6 +310,14 @@ describe("EnvironmentConnector", () => {
300310
301 it.effect("rejects stale managed endpoints before sending a mint request", () => {311 it.effect("rejects stale managed endpoints before sending a mint request", () => {
302 let requestCount = 0;312 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 });
303 const execute = () =>321 const execute = () =>
304 Effect.sync(() => {322 Effect.sync(() => {
305 requestCount += 1;323 requestCount += 1;
...@@ -318,8 +336,27 @@ describe("EnvironmentConnector", () => {...@@ -318,8 +336,27 @@ describe("EnvironmentConnector", () => {
318336
319 expect(Result.isFailure(result)).toBe(true);337 expect(Result.isFailure(result)).toBe(true);
320 if (Result.isFailure(result)) {338 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 }
322 }346 }
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 });
323 expect(requestCount).toBe(0);360 expect(requestCount).toBe(0);
324 }).pipe(361 }).pipe(
325 Effect.provide(362 Effect.provide(
...@@ -333,6 +370,7 @@ describe("EnvironmentConnector", () => {...@@ -333,6 +370,7 @@ describe("EnvironmentConnector", () => {
333 }),370 }),
334 }),371 }),
335 ),372 ),
373 Effect.provideService(Tracer.Tracer, tracer),
336 );374 );
337 });375 });
338376
...@@ -355,7 +393,13 @@ describe("EnvironmentConnector", () => {...@@ -355,7 +393,13 @@ describe("EnvironmentConnector", () => {
355393
356 expect(Result.isFailure(result)).toBe(true);394 expect(Result.isFailure(result)).toBe(true);
357 if (Result.isFailure(result)) {395 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 }
359 }403 }
360 expect(requestCount).toBe(0);404 expect(requestCount).toBe(0);
361 }).pipe(405 }).pipe(
infra/relay/src/environments/EnvironmentConnector.ts+132-12
...@@ -40,15 +40,54 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http";...@@ -40,15 +40,54 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http";
40import * as EnvironmentLinks from "./EnvironmentLinks.ts";40import * as EnvironmentLinks from "./EnvironmentLinks.ts";
41import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";41import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
42import * as RelayConfiguration from "../Config.ts";42import * 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
44export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass<EnvironmentConnectNotAuthorized>()(81export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass<EnvironmentConnectNotAuthorized>()(
45 "EnvironmentConnectNotAuthorized",82 "EnvironmentConnectNotAuthorized",
46 {83 {
47 environmentId: Schema.String,84 environmentId: Schema.String,
85 operation: Schema.Literals(["connect", "status"]),
86 reason: EnvironmentConnectNotAuthorizedReason,
48 },87 },
49) {88) {
50 override get message(): string {89 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)}`;
52 }91 }
53}92}
5493
...@@ -257,30 +296,91 @@ const make = Effect.gen(function* () {...@@ -257,30 +296,91 @@ const make = Effect.gen(function* () {
257 const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")(296 const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")(
258 function* (input: {297 function* (input: {
259 readonly userId: string;298 readonly userId: string;
299 readonly operation: "connect" | "status";
260 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;300 readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord;
261 }) {301 }) {
262 if (input.link.endpoint.providerKind !== "cloudflare_tunnel") {302 if (input.link.endpoint.providerKind !== "cloudflare_tunnel") {
303 yield* Effect.annotateCurrentSpan({
304 "relay.authorization.endpoint_provider_kind": input.link.endpoint.providerKind,
305 });
263 return yield* new EnvironmentConnectNotAuthorized({306 return yield* new EnvironmentConnectNotAuthorized({
264 environmentId: input.link.environmentId,307 environmentId: input.link.environmentId,
308 operation: input.operation,
309 reason: "endpoint_provider_not_managed",
265 });310 });
266 }311 }
267 const allocation = yield* allocations.get({312 const allocation = yield* allocations.get({
268 userId: input.userId,313 userId: input.userId,
269 environmentId: input.link.environmentId,314 environmentId: input.link.environmentId,
270 });315 });
271 const endpoint = allocation316 if (!allocation) {
272 ? ManagedEndpointAllocations.resolveReadyManagedEndpoint({317 return yield* new EnvironmentConnectNotAuthorized({
273 allocation,318 environmentId: input.link.environmentId,
274 baseDomain: settings.managedEndpointBaseDomain,319 operation: input.operation,
275 })320 reason: "managed_endpoint_allocation_not_found",
276 : null;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 });
277 if (364 if (
278 endpoint === null ||365 endpoint === null ||
279 endpoint.httpBaseUrl !== input.link.endpoint.httpBaseUrl ||366 endpoint.httpBaseUrl !== input.link.endpoint.httpBaseUrl ||
280 endpoint.wsBaseUrl !== input.link.endpoint.wsBaseUrl367 endpoint.wsBaseUrl !== input.link.endpoint.wsBaseUrl
281 ) {368 ) {
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 });
282 return yield* new EnvironmentConnectNotAuthorized({380 return yield* new EnvironmentConnectNotAuthorized({
283 environmentId: input.link.environmentId,381 environmentId: input.link.environmentId,
382 operation: input.operation,
383 reason: "managed_endpoint_mismatch",
284 });384 });
285 }385 }
286 return endpoint;386 return endpoint;
...@@ -295,9 +395,17 @@ const make = Effect.gen(function* () {...@@ -295,9 +395,17 @@ const make = Effect.gen(function* () {
295 });395 });
296 const link = yield* links.getForUser(input);396 const link = yield* links.getForUser(input);
297 if (!link) {397 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 });
299 }403 }
300 const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link });404 const endpoint = yield* resolveManagedEndpoint({
405 userId: input.userId,
406 operation: "status",
407 link,
408 });
301 const now = yield* DateTime.now;409 const now = yield* DateTime.now;
302 const expiresAt = DateTime.add(now, { minutes: 2 });410 const expiresAt = DateTime.add(now, { minutes: 2 });
303 const nonce = yield* crypto.randomUUIDv4.pipe(411 const nonce = yield* crypto.randomUUIDv4.pipe(
...@@ -404,13 +512,25 @@ const make = Effect.gen(function* () {...@@ -404,13 +512,25 @@ const make = Effect.gen(function* () {
404 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),512 ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}),
405 });513 });
406 if (input.clientProofKeyThumbprint.trim().length === 0) {514 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 });
408 }520 }
409 const link = yield* links.getForUser(input);521 const link = yield* links.getForUser(input);
410 if (!link) {522 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 });
412 }528 }
413 const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link });529 const endpoint = yield* resolveManagedEndpoint({
530 userId: input.userId,
531 operation: "connect",
532 link,
533 });
414 const now = yield* DateTime.now;534 const now = yield* DateTime.now;
415 const expiresAt = DateTime.add(now, { minutes: 2 });535 const expiresAt = DateTime.add(now, { minutes: 2 });
416 const nonce = yield* crypto.randomUUIDv4.pipe(536 const nonce = yield* crypto.randomUUIDv4.pipe(
infra/relay/src/http/Api.ts+4-4
...@@ -830,7 +830,7 @@ const currentTraceId = Effect.currentParentSpan.pipe(...@@ -830,7 +830,7 @@ const currentTraceId = Effect.currentParentSpan.pipe(
830 Effect.orElseSucceed(() => "unavailable"),830 Effect.orElseSucceed(() => "unavailable"),
831);831);
832832
833const COMMON_AUTH_INVALID_REASONS = [833const RelayCommonPersistenceError = Schema.Union([
834 Devices.DeviceRegistrationPersistenceError,834 Devices.DeviceRegistrationPersistenceError,
835 Devices.DeviceUnregistrationPersistenceError,835 Devices.DeviceUnregistrationPersistenceError,
836 Devices.DeviceListPersistenceError,836 Devices.DeviceListPersistenceError,
...@@ -850,9 +850,9 @@ const COMMON_AUTH_INVALID_REASONS = [...@@ -850,9 +850,9 @@ const COMMON_AUTH_INVALID_REASONS = [
850 AgentActivityRows.AgentActivityRowListPersistenceError,850 AgentActivityRows.AgentActivityRowListPersistenceError,
851 LiveActivities.LiveActivityDeliveryMarkPersistenceError,851 LiveActivities.LiveActivityDeliveryMarkPersistenceError,
852 DeliveryAttempts.DeliveryAttemptRecordPersistenceError,852 DeliveryAttempts.DeliveryAttemptRecordPersistenceError,
853] as const;853]);
854type RelayCommonPersistenceError = InstanceType<(typeof COMMON_AUTH_INVALID_REASONS)[number]>;854type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
855const isRelayCommonPersistenceError = Schema.is(Schema.Union(COMMON_AUTH_INVALID_REASONS));855const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
856856
857type MapRelayCommonApiError<E> =857type MapRelayCommonApiError<E> =
858 | Exclude<E, HttpApiError.Unauthorized | RelayCommonPersistenceError>858 | 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";...@@ -9,7 +9,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10import type { OtlpTracer } from "effect/unstable/observability";10import type { OtlpTracer } from "effect/unstable/observability";
1111
12import { EnvironmentMintRequestFailed } from "./environments/EnvironmentConnector.ts";12import { EnvironmentConnectNotAuthorized } from "./environments/EnvironmentConnector.ts";
13import { makeRelayTraceLayer } from "./observability.ts";13import { makeRelayTraceLayer } from "./observability.ts";
1414
15interface ExportedRequest {15interface ExportedRequest {
...@@ -43,10 +43,10 @@ it.effect("exports schema error fields as span attributes", () =>...@@ -43,10 +43,10 @@ it.effect("exports schema error fields as span attributes", () =>
43 );43 );
4444
45 yield* Effect.fail(45 yield* Effect.fail(
46 new EnvironmentMintRequestFailed({46 new EnvironmentConnectNotAuthorized({
47 environmentId: "environment-1",47 environmentId: "environment-1",
48 operation: "connect",48 operation: "connect",
49 cause: new Error("upstream unavailable"),49 reason: "managed_endpoint_allocation_not_ready",
50 }),50 }),
51 ).pipe(51 ).pipe(
52 Effect.withSpan("relay.test.schema_error"),52 Effect.withSpan("relay.test.schema_error"),
...@@ -76,11 +76,10 @@ it.effect("exports schema error fields as span attributes", () =>...@@ -76,11 +76,10 @@ it.effect("exports schema error fields as span attributes", () =>
76 expect(request.authorization).toBe("Bearer test-token");76 expect(request.authorization).toBe("Bearer test-token");
77 expect(request.dataset).toBe("relay-test-traces");77 expect(request.dataset).toBe("relay-test-traces");
78 expect(attributes).toMatchObject({78 expect(attributes).toMatchObject({
79 "error.type": "EnvironmentMintRequestFailed",79 "error.type": "EnvironmentConnectNotAuthorized",
80 "error.environmentId": "environment-1",80 "error.environmentId": "environment-1",
81 "error.operation": "connect",81 "error.operation": "connect",
82 "error.cause.name": "Error",82 "error.reason": "managed_endpoint_allocation_not_ready",
83 "error.cause.message": "upstream unavailable",
84 });83 });
85 }).pipe(Effect.provide(NodeHttpServer.layerTest), Effect.scoped),84 }).pipe(Effect.provide(NodeHttpServer.layerTest), Effect.scoped),
86);85);