diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index 7a27daae3e6..11ba80992de 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -3073,7 +3073,7 @@ "lang": "bash", "meta": null, "sha256": "866eaf5e25ac2d6471b52118f082a507d5300b7c80cd82ecbe3cfbce598c5f6a", - "bytes": 44, + "bytes": 54, "line": 351 }, { @@ -11198,7 +11198,7 @@ "lang": "bash", "meta": null, "sha256": "866eaf5e25ac2d6471b52118f082a507d5300b7c80cd82ecbe3cfbce598c5f6a", - "bytes": 44, + "bytes": 54, "line": 800 }, { diff --git a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts index 9448a0fae9b..7a52e5d3a3c 100644 --- a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts +++ b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts @@ -48,6 +48,11 @@ export const POST = defineV2JsonRoute({ if (!result.available) { throw new WorkloadIdentityNotConfiguredError() } + // This route only issues or renews subjects Sim holds, so the hold is always reported. + const { subjectIssuedAt, subjectHeldUntil } = result + if (!subjectIssuedAt || !subjectHeldUntil) { + throw new Error('A held workload identity subject must report its hold') + } const { authType, requiredFields, optionalFields } = WORKLOAD_IDENTITY_KEYLESS_CONFIGS[workloadIdentityProviderFor(body.providerId)] return { @@ -57,8 +62,8 @@ export const POST = defineV2JsonRoute({ subject: result.subject, subjectProof: result.subjectProof, expiresAt: result.expiresAt, - subjectIssuedAt: result.subjectIssuedAt, - subjectHeldUntil: result.subjectHeldUntil, + subjectIssuedAt, + subjectHeldUntil, keylessConfig: { authType, subject: result.subject, subjectProof: result.subjectProof }, requiredConfigFields: [...requiredFields], optionalConfigFields: [...optionalFields], diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection.tsx index a63bd114725..f9d71f6fefb 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection.tsx @@ -234,7 +234,11 @@ export function KeylessTrustFields({ title='Subject' value={setup.trust.subject} copyLabel='Copy subject' - hint={`Your trust configuration must allow exactly this subject. Sim holds it for you until ${formatDate(new Date(setup.trust.subjectHeldUntil))}.`} + hint={ + setup.trust.subjectHeldUntil + ? `Your trust configuration must allow exactly this subject. Sim holds it for you until ${formatDate(new Date(setup.trust.subjectHeldUntil))}.` + : 'Your trust configuration must allow exactly this subject.' + } /> )} diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 3b12bab24dd..06bfa9f1e0a 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -646,8 +646,11 @@ export const prepareWorkloadIdentityContract = defineRouteContract({ z.object({ available: z.literal(true), ...workloadIdentitySubjectGrantShape, + /** Absent when Sim does not hold the subject: a reconnect that keeps its own. */ + subjectIssuedAt: workloadIdentitySubjectGrantShape.subjectIssuedAt.optional(), + subjectHeldUntil: workloadIdentitySubjectGrantShape.subjectHeldUntil.optional(), /** Whether `subject` was issued earlier, so a trust configuration may already allow it. */ - subjectReused: z.boolean(), + subjectReused: z.boolean().optional(), reconnectAuthType: reconnectAuthTypeSchema, reconnectDelegation: reconnectDelegationSchema, reconnectCloud: reconnectCloudSchema, diff --git a/apps/sim/lib/credentials/__integration__/workload-identity-pending-subject.integration.ts b/apps/sim/lib/credentials/__integration__/workload-identity-pending-subject.integration.ts index 250bc6d17fd..a110b91cb23 100644 --- a/apps/sim/lib/credentials/__integration__/workload-identity-pending-subject.integration.ts +++ b/apps/sim/lib/credentials/__integration__/workload-identity-pending-subject.integration.ts @@ -198,7 +198,7 @@ describe('holding an unsaved keyless subject', () => { expect(renewed.subject).toBe(held.subject) expect(renewed.subjectIssuedAt).toBe(tenDaysAgo.toISOString()) - expect(Date.parse(renewed.subjectHeldUntil)).toBeGreaterThan( + expect(Date.parse(renewed.subjectHeldUntil ?? '')).toBeGreaterThan( Date.now() + PENDING_SUBJECT_HOLD_MS - DAY_MS ) expect(saved.success).toBe(true) diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts index 8370ed0362f..ec9344be03a 100644 --- a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts +++ b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts @@ -361,6 +361,25 @@ describe('preparing a reconnect', () => { expect(await prepare()).not.toHaveProperty('reconnectCloud') }) + it('reports no hold for a reconnect subject Sim does not hold', async () => { + reconnecting(keylessSecret) + + const result = await prepareWorkloadIdentity.execute({ + principal, + input: { + workspaceId: 'workspace-1', + provider: 'snowflake', + credentialId: 'cred-1', + subjectRequest: { kind: 'unheld' }, + }, + }) + + expect(result).toMatchObject({ available: true, subject: expect.any(String) }) + expect(result).not.toHaveProperty('subjectHeldUntil') + expect(result).not.toHaveProperty('subjectIssuedAt') + expect(result).not.toHaveProperty('subjectReused') + }) + it('gives Google no fixed audience', async () => { reconnecting(STORED_MODES[11].secret, { providerId: 'google-service-account' }) expect(await prepare('google')).not.toHaveProperty('audience') diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.ts index 40e91bb63a7..22d23626f81 100644 --- a/apps/sim/lib/credentials/application/prepare-workload-identity.ts +++ b/apps/sim/lib/credentials/application/prepare-workload-identity.ts @@ -71,12 +71,12 @@ export type PrepareWorkloadIdentityResult = subject: string subjectProof: string expiresAt: string - /** When Sim first issued `subject`. */ - subjectIssuedAt: string + /** When Sim first issued `subject`; absent when Sim does not hold it (an `unheld` request). */ + subjectIssuedAt?: string /** Until when Sim keeps holding `subject` for this person if no credential saves it. */ - subjectHeldUntil: string + subjectHeldUntil?: string /** Whether `subject` was issued earlier, so a trust configuration may already allow it. */ - subjectReused: boolean + subjectReused?: boolean } & ReconnectFields) /** What a provider's stored-secret reader returns: the non-secret fields a reconnect keeps. */ @@ -147,27 +147,14 @@ async function storedReconnectFields( return STORED_RECONNECT[provider](decrypted) } -/** - * Starts a keyless cloud connection (an AWS role, a Databricks service - * principal, a Snowflake service user, a Microsoft app, or a Google service account): the subject the customer's trust - * configuration will pin, signed for this person and workspace so only they can attach it to the credential - * they are about to create. Sim holds the subject for them until a credential saves it, so a trust - * configuration applied days later still matches. Reports `available: false` when this deployment - * cannot sign tokens, so the connect dialog offers only key-based modes. On a - * reconnect it also reports the credential's stored mode, signing or not. - */ +/** The signed subject to hand out, and Sim's hold on it; no hold for an `unheld` request. */ async function subjectGrant( input: PrepareWorkloadIdentityInput, owner: { workspaceId: string }, userId: string ) { if (input.subjectRequest.kind === 'unheld') { - const grant = issueWorkloadIdentitySubject(input.provider, owner, userId) - const now = new Date() - return { - grant, - pending: { issuedAt: now, heldUntil: new Date(grant.expiresAt), reused: false }, - } + return { grant: issueWorkloadIdentitySubject(input.provider, owner, userId), pending: null } } const pending = await resolvePendingWorkloadIdentitySubject( { provider: input.provider, workspaceId: input.workspaceId, userId }, @@ -179,6 +166,15 @@ async function subjectGrant( } } +/** + * Starts a keyless cloud connection (an AWS role, a Databricks service + * principal, a Snowflake service user, a Microsoft app, or a Google service account): the subject the customer's trust + * configuration will pin, signed for this person and workspace so only they can attach it to the credential + * they are about to create. Sim holds the subject for them until a credential saves it, so a trust + * configuration applied days later still matches. Reports `available: false` when this deployment + * cannot sign tokens, so the connect dialog offers only key-based modes. On a + * reconnect it also reports the credential's stored mode, signing or not. + */ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.prepareWorkloadIdentity, resolveContext: async ({ input }: { input: PrepareWorkloadIdentityInput }) => { @@ -207,9 +203,13 @@ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({ issuer: getWorkloadIdentityIssuer(), ...(audience ? { audience } : {}), ...grant, - subjectIssuedAt: pending.issuedAt.toISOString(), - subjectHeldUntil: pending.heldUntil.toISOString(), - subjectReused: pending.reused, + ...(pending + ? { + subjectIssuedAt: pending.issuedAt.toISOString(), + subjectHeldUntil: pending.heldUntil.toISOString(), + subjectReused: pending.reused, + } + : {}), ...reconnect, } }, diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 8d33eb0b44b..be27b8670d2 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -324,7 +324,9 @@ async function verifyWorkspaceIdentity( { profile: 'configuredEndpoint', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + // `timeout` resets on activity; the signal caps the whole call, DNS included. timeout: REQUEST_TIMEOUT_MS, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), maxRedirects: 0, // An oversized body rejects here and fails verification rather than // saving the host as the identity.