From 9ad23166c59ef42381b59b00837c3ce790ce3f1c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 19 Jun 2026 15:15:20 +0100 Subject: [PATCH] feat(cli,webapp): mint short-lived delegated tokens that act as a user --- .changeset/mint-token-command.md | 9 ++ .../routes/api.v1.auth.user-actor-token.ts | 83 ++++++++++++ .../api.v1.projects.$projectRef.$env.jwt.ts | 57 ++++++++- .../environmentVariableApiAccess.server.ts | 29 +++-- .../services/personalAccessToken.server.ts | 8 ++ apps/webapp/app/services/rbac.server.ts | 4 +- .../routeBuilders/apiBuilder.server.ts | 54 ++++---- internal-packages/rbac/src/fallback.ts | 47 ++++++- internal-packages/rbac/src/index.ts | 29 ++++- packages/cli-v3/src/apiClient.ts | 21 ++++ packages/cli-v3/src/cli/index.ts | 2 + packages/cli-v3/src/commands/mint-token.ts | 87 +++++++++++++ packages/plugins/src/index.ts | 9 ++ packages/plugins/src/rbac.ts | 118 +++++++++++++++++- 14 files changed, 511 insertions(+), 46 deletions(-) create mode 100644 .changeset/mint-token-command.md create mode 100644 apps/webapp/app/routes/api.v1.auth.user-actor-token.ts create mode 100644 packages/cli-v3/src/commands/mint-token.ts diff --git a/.changeset/mint-token-command.md b/.changeset/mint-token-command.md new file mode 100644 index 00000000000..7e405355f65 --- /dev/null +++ b/.changeset/mint-token-command.md @@ -0,0 +1,9 @@ +--- +"trigger.dev": patch +--- + +Adds `trigger.dev mint-token`, which mints a short-lived delegated token from your stored personal access token. The token authenticates against the API as you, can be narrowed with `--cap` and given a lifetime with `--ttl`, and prints to stdout so it can be captured. + +```bash +UAT=$(trigger.dev mint-token --ttl 3600 --cap read:runs) +``` diff --git a/apps/webapp/app/routes/api.v1.auth.user-actor-token.ts b/apps/webapp/app/routes/api.v1.auth.user-actor-token.ts new file mode 100644 index 00000000000..50318172163 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.auth.user-actor-token.ts @@ -0,0 +1,83 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { signUserActorToken } from "@trigger.dev/rbac"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { rbac } from "~/services/rbac.server"; + +// Callers pick the TTL (default 1h) up to a hard ceiling; renewal = mint again +// with the PAT. The default is short, but the ceiling allows long-lived tokens +// for callers that need them (e.g. a long-running integration). +const DEFAULT_UAT_TTL_SECONDS = 60 * 60; // 1 hour +const MAX_UAT_TTL_SECONDS = 365 * 24 * 60 * 60; // 365 days + +// Mint a short-lived delegated user-actor token (`tr_uat_`) from a personal +// access token. A UAT is a strict downgrade of the PAT: same user identity, +// short-lived, optionally narrowed by `cap`. It lets a holder (an agent, the +// MCP server, an IDE) act as the user without carrying a long-lived PAT. +const RequestBodySchema = z + .object({ + // Optional scope cap (e.g. ["read:runs"]) — ceilings the UAT below the + // user's role. Absent → identity-only, floored by the user's role at + // use-time. + cap: z.array(z.string()).optional(), + // Attribution label recorded in the token's `act.client` (e.g. the agent + // or tool that requested it). + client: z.string().min(1).max(255).optional(), + // Lifetime in seconds. Omitted → 1h. Over the ceiling → 400 (we don't + // silently clamp, so a caller never thinks it got longer than it did). + ttlSeconds: z.number().int().positive().max(MAX_UAT_TTL_SECONDS).optional(), + }) + .optional(); + +export async function action({ request }: ActionFunctionArgs) { + try { + // Mint only from a real PAT. authenticatePat requires the `tr_pat_` + // prefix, so a UAT can't mint another UAT (no indefinite renewal) and an + // env API key / OAT can't mint one either. + const patAuth = await rbac.authenticatePat(request, {}); + if (!patAuth.ok) { + return json({ error: patAuth.error }, { status: patAuth.status }); + } + + // A role-restricted PAT (one with a TokenRole cap) can't mint a UAT: the + // UAT is floored by the user's role at use-time and wouldn't carry the + // PAT's narrower ceiling, so minting would widen the grant. Reject rather + // than silently escalate. (The OSS fallback has no TokenRoles, so this + // only takes effect with the cloud RBAC plugin installed.) + const tokenRole = await rbac.getTokenRole(patAuth.tokenId); + if (tokenRole) { + return json( + { + error: + "Cannot mint a user-actor token from a role-restricted personal access token", + }, + { status: 403 } + ); + } + + const parsedBody = RequestBodySchema.safeParse(await request.json().catch(() => ({}))); + if (!parsedBody.success) { + return json( + { error: "Invalid request body", issues: parsedBody.error.issues }, + { status: 400 } + ); + } + const body = parsedBody.data ?? {}; + const ttlSeconds = body.ttlSeconds ?? DEFAULT_UAT_TTL_SECONDS; + + const token = await signUserActorToken(env.SESSION_SECRET, { + userId: patAuth.userId, + client: body.client ?? "personal-access-token", + cap: body.cap, + // Absolute exp (seconds since epoch). jose treats a number as absolute. + expirationTime: Math.floor(Date.now() / 1000) + ttlSeconds, + }); + + return json({ token, expiresInSeconds: ttlSeconds }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to mint user-actor token", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts index 1f455a6b51c..1214c80f24a 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts @@ -1,10 +1,13 @@ import { type ActionFunctionArgs, json } from "@remix-run/node"; import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; +import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac"; import { z } from "zod"; import { authenticatedEnvironmentForAuthentication, authenticateRequest, + type AuthenticationResult, } from "~/services/apiAuth.server"; +import { env as appEnv } from "~/env.server"; import { logger } from "~/services/logger.server"; import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; @@ -24,11 +27,36 @@ const RequestBodySchema = z.object({ export async function action({ request, params }: ActionFunctionArgs) { try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: false, - }); + const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim(); + const isUat = !!bearer && isUserActorToken(bearer); + + // A delegated user-actor token authenticates as its user, like a PAT. We + // resolve it here (not through authenticateRequest) so the exchange stays + // scoped to this route — UATs deliberately aren't accepted on every + // PAT route. `uatCap` (the token's optional scope cap) ceilings the + // minted env JWT below. + let uatCap: string[] | undefined; + let userActorId: string | undefined; + let authenticationResult: AuthenticationResult | undefined; + if (isUat) { + const claims = await verifyUserActorToken(appEnv.SESSION_SECRET, bearer!); + if (!claims) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + uatCap = claims.cap; + userActorId = claims.userId; + // The env lookup keys purely on the user, identical to a PAT. + authenticationResult = { + type: "personalAccessToken", + result: { userId: claims.userId }, + }; + } else { + authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + } if (!authenticationResult) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); @@ -73,10 +101,27 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } + // The env JWT carries scopes only — downstream auth builds its ability + // from them with no role context. So for a user-actor token we ceiling + // the scopes by the token's own cap here (a read-only agent token can't + // widen its grant through the exchange) and stamp the user via `act` so + // the minted env JWT stays attributable. The cap is a ceiling, not a + // replacement: intersect what the caller asked for with the cap (or use + // the full cap if they asked for nothing). No cap → the request passes + // through, same as a PAT. + const requestedScopes = parsedBody.data.claims?.scopes; + const scopes = + isUat && uatCap + ? requestedScopes && requestedScopes.length > 0 + ? requestedScopes.filter((scope) => uatCap.includes(scope)) + : uatCap + : requestedScopes; + const claims = { sub: runtimeEnv.id, pub: true, - ...parsedBody.data.claims, + ...(scopes ? { scopes } : {}), + ...(userActorId ? { act: { sub: userActorId } } : {}), }; const jwt = await internal_generateJWT({ diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts index 2ba7b2b91f2..49d745ddad0 100644 --- a/apps/webapp/app/services/environmentVariableApiAccess.server.ts +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -1,5 +1,6 @@ import { json } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { isUserActorToken } from "@trigger.dev/rbac"; import { rbac } from "~/services/rbac.server"; type EnvironmentScopedResource = "envvars" | "apiKeys"; @@ -15,11 +16,12 @@ const RESOURCE_LABELS: Record = { * * Machine credentials (an environment's secret/public API key) are already * scoped to a single environment, so they pass through unchanged. A personal - * access token carries a user, so enforce that user's role for the targeted - * environment tier — e.g. a Developer can't read deployed env vars or API keys - * via the API, matching the dashboard restriction. Blocking the credential read - * for deployed tiers is also what stops a restricted role deploying via the CLI - * (deploy needs the environment's secret key). + * access token (or a delegated user-actor token) carries a user, so enforce + * that user's role for the targeted environment tier — e.g. a Developer can't + * read deployed env vars or API keys via the API, matching the dashboard + * restriction. Blocking the credential read for deployed tiers is also what + * stops a restricted role deploying via the CLI (deploy needs the + * environment's secret key). * * Returns a `Response` to short-circuit with when access is denied, or * `undefined` when the request may proceed. @@ -41,16 +43,23 @@ export async function authorizePatEnvironmentAccess({ resource: EnvironmentScopedResource; action: "read" | "write"; }): Promise { - if (authType !== "personalAccessToken") { + const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim(); + const isUat = !!bearer && isUserActorToken(bearer); + + // Machine creds (apiKey) and org tokens carry no user role to enforce. A + // user-actor token carries a user just like a PAT, so it's gated too. + if (authType !== "personalAccessToken" && !isUat) { return undefined; } - const patAuth = await rbac.authenticatePat(request, { organizationId, projectId }); - if (!patAuth.ok) { - return json({ error: patAuth.error }, { status: patAuth.status }); + const userAuth = isUat + ? await rbac.authenticateUserActor(request, { organizationId, projectId }) + : await rbac.authenticatePat(request, { organizationId, projectId }); + if (!userAuth.ok) { + return json({ error: userAuth.error }, { status: userAuth.status }); } - if (!patAuth.ability.can(action, { type: resource, envType })) { + if (!userAuth.ability.can(action, { type: resource, envType })) { return json( { error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`, diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index ad3e7be8f16..acd3959911c 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -6,6 +6,7 @@ import { logger } from "./logger.server"; import { rbac } from "./rbac.server"; import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server"; import { env } from "~/env.server"; +import { isUserActorToken } from "@trigger.dev/rbac"; const tokenValueLength = 40; //lowercase only, removed 0 and l to avoid confusion @@ -165,6 +166,13 @@ export async function authenticateApiRequestWithPersonalAccessToken( return; } + // A user-actor token authenticates as the user wherever a PAT does. + // The plugin verifies it (identity path → no org context to floor against). + if (isUserActorToken(token)) { + const result = await rbac.authenticateUserActor(request, {}); + return result.ok ? { userId: result.userId } : undefined; + } + return authenticatePersonalAccessToken(token); } diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts index 49b6f88ecf4..0e2c0fc7e16 100644 --- a/apps/webapp/app/services/rbac.server.ts +++ b/apps/webapp/app/services/rbac.server.ts @@ -25,5 +25,7 @@ export const rbac = plugin.create( // $replica is structurally a PrismaClient minus `$transaction` — the // RBAC fallback only uses `findFirst` on it, so the cast is safe. { primary: prisma, replica: $replica as PrismaClient }, - { forceFallback: env.RBAC_FORCE_FALLBACK } + // SESSION_SECRET signs delegated user-actor tokens; the plugin verifies + // them with it in authenticateUserActor. + { forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET } ); diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 0c661a7e684..bee8fdc5a71 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -6,6 +6,7 @@ import { apiCors } from "~/utils/apiCors"; import { logger } from "../logger.server"; import { rbac } from "../rbac.server"; import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; +import { isUserActorToken } from "@trigger.dev/rbac"; import { PersonalAccessTokenAuthenticationResult, updateLastAccessedAtIfStale, @@ -552,28 +553,39 @@ export function createLoaderPATApiRoute< // `updateLastAccessedAtIfStale` — no DB roundtrip when the // cached timestamp is fresher than the throttle window). const ctx = contextFn ? await contextFn(parsedParams, request) : {}; - const patAuth = await rbac.authenticatePat(request, ctx); - if (!patAuth.ok) { - return await wrapResponse( - request, - json({ error: patAuth.error }, { status: patAuth.status }), - corsStrategy !== "none" - ); - } - const authenticationResult: PersonalAccessTokenAuthenticationResult = { - userId: patAuth.userId, - }; - const ability: RbacAbility = patAuth.ability; - - // Fire the `lastAccessedAt` write conditionally. Two-layer throttle: - // JS skips the SQL when the value is fresh (most requests); the - // SQL `WHERE` clause inside the helper is race-safe for concurrent - // auths that both decide to fire. Don't `await` it from the - // critical path? — it's a one-row update on a small hot table and - // we want to surface failures, so it's awaited (same shape as the - // legacy `authenticatePersonalAccessToken`). - await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt); + let authenticationResult: PersonalAccessTokenAuthenticationResult; + let ability: RbacAbility; + + const bearer = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim(); + if (bearer && isUserActorToken(bearer)) { + // A user-actor token validates + computes the cap-and-floor ability + // in one call, same shape as a PAT. + const uatAuth = await rbac.authenticateUserActor(request, ctx); + if (!uatAuth.ok) { + return await wrapResponse( + request, + json({ error: uatAuth.error }, { status: uatAuth.status }), + corsStrategy !== "none" + ); + } + authenticationResult = { userId: uatAuth.userId }; + ability = uatAuth.ability; + } else { + // PAT: validate + compute the cap-and-floor ability in one query. + const patAuth = await rbac.authenticatePat(request, ctx); + if (!patAuth.ok) { + return await wrapResponse( + request, + json({ error: patAuth.error }, { status: patAuth.status }), + corsStrategy !== "none" + ); + } + authenticationResult = { userId: patAuth.userId }; + ability = patAuth.ability; + // Throttled in the helper (no DB write when the cached value is fresh). + await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt); + } if (authorization) { const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders); diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index d28c4817705..a8b0e719d71 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -11,7 +11,9 @@ import type { RoleAssignmentResult, RoleBaseAccessController, RoleMutationResult, + UserActorAuthResult, } from "@trigger.dev/plugins"; +import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/plugins"; import { createHash } from "node:crypto"; import type { PrismaClient } from "@trigger.dev/database"; import { validateJWT } from "@trigger.dev/core/v3/jwt"; @@ -39,25 +41,34 @@ function resolvePrismaClients(input: PrismaInput): FallbackPrismaClients { return "primary" in input ? input : { primary: input, replica: input }; } +export type FallbackOptions = { + // Platform secret for verifying delegated user-actor tokens (tr_uat_). + userActorSecret?: string; +}; + export class RoleBaseAccessFallback { private readonly clients: FallbackPrismaClients; + private readonly options: FallbackOptions; - constructor(prisma: PrismaInput) { + constructor(prisma: PrismaInput, options?: FallbackOptions) { this.clients = resolvePrismaClients(prisma); + this.options = options ?? {}; } create(): RoleBaseAccessFallbackController { - return new RoleBaseAccessFallbackController(this.clients); + return new RoleBaseAccessFallbackController(this.clients, this.options); } } class RoleBaseAccessFallbackController implements RoleBaseAccessController { private readonly prisma: PrismaClient; // alias for primary — used by writes private readonly replica: PrismaClient; + private readonly userActorSecret?: string; - constructor(clients: FallbackPrismaClients) { + constructor(clients: FallbackPrismaClients, options?: FallbackOptions) { this.prisma = clients.primary; this.replica = clients.replica; + this.userActorSecret = options?.userActorSecret; } async isUsingPlugin(): Promise { @@ -316,6 +327,36 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { }; } + async authenticateUserActor( + request: Request, + context: { organizationId?: string; projectId?: string } + ): Promise { + const rawToken = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim(); + if (!rawToken || !isUserActorToken(rawToken)) { + return { ok: false, status: 401, error: "Invalid or Missing user-actor token" }; + } + if (!this.userActorSecret) { + return { ok: false, status: 401, error: "User-actor tokens are not configured" }; + } + const claims = await verifyUserActorToken(this.userActorSecret, rawToken); + if (!claims) { + return { ok: false, status: 401, error: "Invalid user-actor token" }; + } + return { + ok: true, + userId: claims.userId, + subject: { + type: "userActor", + userId: claims.userId, + client: claims.client, + organizationId: context.organizationId ?? "", + projectId: context.projectId, + }, + // No plugin → permissive, matching the fallback's PAT behaviour. + ability: permissiveAbility, + }; + } + async systemRoles(_organizationId: string) { // No plugin installed → no seeded roles. Callers handle null by // hiding role-picker UI / skipping role assignment writes. diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 3f74dd83f51..7dbd9f83d1f 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -11,6 +11,15 @@ import type { import type { PrismaClient } from "@trigger.dev/database"; import { RoleBaseAccessFallback } from "./fallback.js"; export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; +export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; +// Re-export the user-actor token grammar so the webapp mints/checks tokens +// through @trigger.dev/rbac (it doesn't import @trigger.dev/plugins directly). +export { + isUserActorToken, + signUserActorToken, + verifyUserActorToken, + USER_ACTOR_TOKEN_PREFIX, +} from "@trigger.dev/plugins"; // Either a single PrismaClient (used for both writes and reads — fine // for callers that don't have a separate replica), or `{primary, replica}` @@ -21,6 +30,9 @@ export type RbacPrismaInput = PrismaClient | { primary: PrismaClient; replica: P export type RbacCreateOptions = { // When true, skip loading the plugin, useful for tests forceFallback?: boolean; + // Platform secret used to verify delegated user-actor tokens (tr_uat_). + // Threaded through to the plugin / fallback's authenticateUserActor. + userActorSecret?: string; }; // Route actions that historically authorised via the legacy checkAuthorization's @@ -67,14 +79,16 @@ class LazyController implements RoleBaseAccessController { options?: RbacCreateOptions ): Promise { if (options?.forceFallback) { - return new RoleBaseAccessFallback(prisma).create(); + return new RoleBaseAccessFallback(prisma, { + userActorSecret: options?.userActorSecret, + }).create(); } const moduleName = "@triggerdotdev/plugins/rbac"; try { const module = await import(moduleName); const plugin: RoleBasedAccessControlPlugin = module.default; console.log("RBAC: using plugin implementation"); - return plugin.create(); + return plugin.create({ userActorSecret: options?.userActorSecret }); } catch (err) { // The dynamic import either succeeded or failed for one of two // distinct reasons. Distinguishing them is critical for debugging @@ -126,7 +140,9 @@ class LazyController implements RoleBaseAccessController { ); } - return new RoleBaseAccessFallback(prisma).create(); + return new RoleBaseAccessFallback(prisma, { + userActorSecret: options?.userActorSecret, + }).create(); } } @@ -182,6 +198,13 @@ class LazyController implements RoleBaseAccessController { return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; } + async authenticateUserActor( + ...args: Parameters + ) { + const result = await (await this.c()).authenticateUserActor(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + async systemRoles(...args: Parameters) { return (await this.c()).systemRoles(...args); } diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index c70d9b11419..04aa1982335 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -58,6 +58,11 @@ import { z } from "zod"; import { logger } from "./utilities/logger.js"; import { VERSION } from "./version.js"; +const MintUserActorTokenResponseSchema = z.object({ + token: z.string(), + expiresInSeconds: z.number(), +}); + const CliPlatformNotificationResponseSchema = z.object({ notification: z .object({ @@ -217,6 +222,22 @@ export class CliApiClient { ); } + async mintUserActorToken(body?: { cap?: string[]; client?: string; ttlSeconds?: number }) { + if (!this.accessToken) { + throw new Error("mintUserActorToken: No access token"); + } + + return wrapZodFetch( + MintUserActorTokenResponseSchema, + `${this.apiURL}/api/v1/auth/user-actor-token`, + { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify(body ?? {}), + } + ); + } + async getJWT(projectRef: string, envName: string, body: GetJWTRequestBody) { if (!this.accessToken) { throw new Error("getJWT: No access token"); diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index 1278de73430..e9012296553 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -12,6 +12,7 @@ import { configurePromoteCommand } from "../commands/promote.js"; import { configureSwitchProfilesCommand } from "../commands/switch.js"; import { configureUpdateCommand } from "../commands/update.js"; import { configureWhoamiCommand } from "../commands/whoami.js"; +import { configureMintTokenCommand } from "../commands/mint-token.js"; import { configureMcpCommand } from "../commands/mcp.js"; import { COMMAND_NAME } from "../consts.js"; import { VERSION } from "../version.js"; @@ -33,6 +34,7 @@ configureEnvCommand(program); configureDeployCommand(program); configurePromoteCommand(program); configureWhoamiCommand(program); +configureMintTokenCommand(program); configureLogoutCommand(program); configureListProfilesCommand(program); configureSwitchProfilesCommand(program); diff --git a/packages/cli-v3/src/commands/mint-token.ts b/packages/cli-v3/src/commands/mint-token.ts new file mode 100644 index 00000000000..5fe50ae8fbc --- /dev/null +++ b/packages/cli-v3/src/commands/mint-token.ts @@ -0,0 +1,87 @@ +import { Command } from "commander"; +import { z } from "zod"; +import { CliApiClient } from "../apiClient.js"; +import { + CommonCommandOptions, + commonOptions, + handleTelemetry, + wrapCommandAction, +} from "../cli/common.js"; +import { isLoggedIn } from "../utilities/session.js"; + +const MintTokenCommandOptions = CommonCommandOptions.extend({ + ttl: z.coerce.number().int().positive().optional(), + cap: z.string().optional(), + client: z.string().optional(), +}); + +type MintTokenCommandOptions = z.infer; + +export function configureMintTokenCommand(program: Command) { + return commonOptions( + program + .command("mint-token") + .description( + "Mint a short-lived token (tr_uat_) that authenticates as you, from your stored personal access token" + ) + .option("--ttl ", "Token lifetime in seconds (default 3600, max 31536000)") + .option( + "--cap ", + "Comma-separated scope cap, e.g. read:runs,read:tasks (defaults to your full role)" + ) + .option("--client