Skip to content

Commit c3b0a24

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
chore(webapp): observe session creation permission compatibility
Add measure-only session creation permission telemetry without changing authorization behavior. Mono-RevId: 555e88513a2ce6fec570c8800555ec6f20cdd0c3
1 parent f049c34 commit c3b0a24

4 files changed

Lines changed: 164 additions & 1 deletion

File tree

‎apps/webapp/app/routes/api.v1.sessions.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
createLoaderApiRoute,
3333
everyResource,
3434
} from "~/services/routeBuilders/apiBuilder.server";
35+
import { recordSessionCreateAuthorization } from "~/services/sessionAuthorizationTelemetry.server";
3536
import { ServiceValidationError } from "~/v3/services/common.server";
3637
import { runStore } from "~/v3/runStore.server";
3738

@@ -170,7 +171,7 @@ const { action } = createActionApiRoute(
170171
},
171172
corsStrategy: "all",
172173
},
173-
async ({ authentication, body }) => {
174+
async ({ authentication, body, ability, request }) => {
174175
try {
175176
if (body.externalId && !isSafeSessionExternalId(body.externalId)) {
176177
return json(
@@ -218,6 +219,8 @@ const { action } = createActionApiRoute(
218219
);
219220
}
220221

222+
recordSessionCreateAuthorization(ability, session, request, authentication.environment);
223+
221224
// Session is task-bound — every session has a live run by
222225
// construction. `ensureRunForSession` is idempotent: on the
223226
// cached path it sees `currentRunId` is alive and returns it
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { trace } from "@opentelemetry/api";
2+
import {
3+
BasicTracerProvider,
4+
InMemorySpanExporter,
5+
SimpleSpanProcessor,
6+
} from "@opentelemetry/sdk-trace-base";
7+
import { buildJwtAbility, withActionAliases } from "@trigger.dev/rbac";
8+
import { describe, expect, it } from "vitest";
9+
import {
10+
recordSessionCreateAuthorization,
11+
sessionCreateAuthorizationOutcome,
12+
} from "~/services/sessionAuthorizationTelemetry.server";
13+
14+
describe("session creation authorization observation", () => {
15+
it.each([
16+
[["write:sessions"], "missing_task_trigger"],
17+
[["write:sessions:chat-1"], "missing_both"],
18+
[["trigger:tasks:chat"], "missing_session_write"],
19+
[["write:tasks:chat"], "missing_session_write"],
20+
[["write:sessions", "trigger:tasks:chat"], "both_allowed"],
21+
[["write:sessions", "trigger:tasks:other"], "missing_task_trigger"],
22+
[["write:sessions", "write:tasks:chat"], "both_allowed"],
23+
[["write:sessions", "trigger:tasks"], "both_allowed"],
24+
[["admin"], "both_allowed"],
25+
[["write:all"], "both_allowed"],
26+
] as const)("classifies %j as %s", (scopes, expected) => {
27+
expect(
28+
sessionCreateAuthorizationOutcome(withActionAliases(buildJwtAbility([...scopes])), "chat")
29+
).toBe(expected);
30+
});
31+
32+
it("attributes only would-deny events without exporting the credential or task", async () => {
33+
const exporter = new InMemorySpanExporter();
34+
const provider = new BasicTracerProvider({
35+
spanProcessors: [new SimpleSpanProcessor(exporter)],
36+
});
37+
trace.setGlobalTracerProvider(provider);
38+
const credential = `tr_prod_sk_${"a".repeat(24)}`;
39+
const request = new Request("https://example.com/api/v1/sessions", {
40+
method: "POST",
41+
headers: { Authorization: `Bearer ${credential}` },
42+
body: JSON.stringify({ taskIdentifier: "requested-task" }),
43+
});
44+
const environment = {
45+
id: "env-1",
46+
organizationId: "org-1",
47+
projectId: "project-1",
48+
type: "PRODUCTION" as const,
49+
};
50+
51+
try {
52+
recordSessionCreateAuthorization(
53+
withActionAliases(buildJwtAbility(["write:tasks:chat"])),
54+
{ taskIdentifier: "chat" },
55+
request,
56+
environment
57+
);
58+
recordSessionCreateAuthorization(
59+
buildJwtAbility(["admin"]),
60+
{ taskIdentifier: "chat" },
61+
request,
62+
environment
63+
);
64+
recordSessionCreateAuthorization(
65+
withActionAliases(buildJwtAbility(["write:sessions", "trigger:tasks:requested-task"])),
66+
{ taskIdentifier: "stored-task" },
67+
request,
68+
environment
69+
);
70+
const spans = exporter.getFinishedSpans();
71+
expect(spans).toHaveLength(2);
72+
expect(spans[1].attributes["session.authorization.outcome"]).toBe("missing_task_trigger");
73+
expect(spans[0].name).toBe("session.create.authorization_would_deny");
74+
expect(spans[0].attributes).toMatchObject({
75+
forceRecording: true,
76+
"$trigger.org.id": "org-1",
77+
"$trigger.project.id": "project-1",
78+
"$trigger.env.id": "env-1",
79+
"session.authorization.credential_kind": "additional_api_key",
80+
"session.authorization.outcome": "missing_session_write",
81+
});
82+
expect(JSON.stringify(spans[0].attributes)).not.toContain(credential);
83+
expect(Object.values(spans[0].attributes)).not.toContain("chat");
84+
} finally {
85+
trace.disable();
86+
await provider.shutdown();
87+
}
88+
});
89+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { getMeter, getTracer, ROOT_CONTEXT } from "@internal/tracing";
2+
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
3+
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
4+
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
5+
import type { RbacAbility } from "@trigger.dev/rbac";
6+
import { singleton } from "~/utils/singleton";
7+
import { logger } from "~/services/logger.server";
8+
9+
const checks = singleton("sessionCreateAuthorizationChecks", () =>
10+
getMeter("session-authorization").createCounter("session.create.authorization_checks", {
11+
description:
12+
"Session creation requests admitted by current authorization, by proposed permission outcome",
13+
})
14+
);
15+
16+
export function sessionCreateAuthorizationOutcome(ability: RbacAbility, taskIdentifier: string) {
17+
const sessionWrite = ability.can("write", { type: "sessions" });
18+
const taskTrigger = ability.can("trigger", { type: "tasks", id: taskIdentifier });
19+
20+
if (sessionWrite && taskTrigger) return "both_allowed";
21+
if (!sessionWrite && !taskTrigger) return "missing_both";
22+
return sessionWrite ? "missing_task_trigger" : "missing_session_write";
23+
}
24+
25+
export function recordSessionCreateAuthorization(
26+
ability: RbacAbility,
27+
session: { taskIdentifier: string },
28+
request: Request,
29+
environment: Pick<AuthenticatedEnvironment, "id" | "organizationId" | "projectId" | "type">
30+
) {
31+
const token =
32+
request.headers
33+
.get("Authorization")
34+
?.replace(/^Bearer /, "")
35+
.trim() ?? "";
36+
const credentialKind = isPublicJWT(token)
37+
? "public_jwt"
38+
: isAdditionalApiKey(token)
39+
? "additional_api_key"
40+
: token.startsWith("tr_")
41+
? "root_api_key"
42+
: "unknown";
43+
44+
try {
45+
const outcome = sessionCreateAuthorizationOutcome(ability, session.taskIdentifier);
46+
checks.add(1, { credential_kind: credentialKind, outcome });
47+
if (outcome === "both_allowed") return;
48+
49+
// Force-record an independent root span so request sampling cannot hide affected tenants.
50+
getTracer("session-authorization")
51+
.startSpan(
52+
"session.create.authorization_would_deny",
53+
{
54+
attributes: {
55+
forceRecording: true,
56+
"$trigger.org.id": environment.organizationId,
57+
"$trigger.project.id": environment.projectId,
58+
"$trigger.env.id": environment.id,
59+
"$trigger.env.type": environment.type,
60+
"session.authorization.credential_kind": credentialKind,
61+
"session.authorization.outcome": outcome,
62+
},
63+
},
64+
ROOT_CONTEXT
65+
)
66+
.end();
67+
} catch {
68+
logger.debug("Session authorization observation failed");
69+
}
70+
}

‎apps/webapp/vitest.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default defineConfig({
1919
"app/runEngine/concerns/**/*.test.ts",
2020
"app/runEngine/services/**/*.test.ts",
2121
"app/services/realtime/**/*.test.ts",
22+
"app/services/sessionAuthorizationTelemetry.server.test.ts",
2223
"app/services/previewAutoArchive.server.test.ts",
2324
"app/services/provisionBillingCustomer.server.test.ts",
2425
"app/services/clickhouse/**/*.test.ts",

0 commit comments

Comments
 (0)