Skip to content

Commit 3ea6adf

Browse files
[codex] Enrich relay authorization diagnostics (#2977)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent a56496c commit 3ea6adf

4 files changed

Lines changed: 188 additions & 25 deletions

File tree

infra/relay/src/environments/EnvironmentConnector.test.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as Redacted from "effect/Redacted";
2222
import * as Result from "effect/Result";
2323
import * as Schema from "effect/Schema";
2424
import * as TestClock from "effect/testing/TestClock";
25+
import * as Tracer from "effect/Tracer";
2526
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
2627

2728
import * as EnvironmentLinks from "./EnvironmentLinks.ts";
@@ -50,6 +51,9 @@ const decodeHealthRequestBody = Schema.decodeUnknownSync(
5051
const decodeMintRequestBody = Schema.decodeUnknownSync(
5152
Schema.fromJsonString(RelayCloudMintCredentialRequest),
5253
);
54+
const isEnvironmentConnectNotAuthorized = Schema.is(
55+
EnvironmentConnector.EnvironmentConnectNotAuthorized,
56+
);
5357

5458
function 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

Lines changed: 132 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,54 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http";
4040
import * as EnvironmentLinks from "./EnvironmentLinks.ts";
4141
import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
4242
import * as RelayConfiguration from "../Config.ts";
43+
import { isManagedEndpointHostname } from "../deploymentConfig.ts";
44+
45+
export 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+
]);
55+
export type EnvironmentConnectNotAuthorizedReason =
56+
typeof EnvironmentConnectNotAuthorizedReason.Type;
57+
58+
function 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

4481
export 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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,7 @@ const currentTraceId = Effect.currentParentSpan.pipe(
830830
Effect.orElseSucceed(() => "unavailable"),
831831
);
832832

833-
const COMMON_AUTH_INVALID_REASONS = [
833+
const 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;
854-
type RelayCommonPersistenceError = InstanceType<(typeof COMMON_AUTH_INVALID_REASONS)[number]>;
855-
const isRelayCommonPersistenceError = Schema.is(Schema.Union(COMMON_AUTH_INVALID_REASONS));
853+
]);
854+
type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
855+
const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
856856

857857
type MapRelayCommonApiError<E> =
858858
| Exclude<E, HttpApiError.Unauthorized | RelayCommonPersistenceError>

infra/relay/src/observability.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
99
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
1010
import type { OtlpTracer } from "effect/unstable/observability";
1111

12-
import { EnvironmentMintRequestFailed } from "./environments/EnvironmentConnector.ts";
12+
import { EnvironmentConnectNotAuthorized } from "./environments/EnvironmentConnector.ts";
1313
import { makeRelayTraceLayer } from "./observability.ts";
1414

1515
interface 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
);

0 commit comments

Comments
 (0)