Skip to content

Commit 6aaa139

Browse files
thsigedvaldeysi09
authored
improvement(cloud): default orgs (#7852)
* improvement(cloud): default orgs For enterprise users who have migrated to app.garden.io but haven't yet updated their project configuration to add an `organizationId`, we now default to looking up the organization that their old project ID is associated with. This makes use of a new API endpoint that was written just for this purpose. * chore: minor improvements and tests * chore: log tweaks around API init * chore: fix formatting issue --------- Co-authored-by: Jon Edvald <edvald@gmail.com> Co-authored-by: Eyþór Magnússon <eysi09@gmail.com>
1 parent 32f03d3 commit 6aaa139

File tree

7 files changed

+474
-221
lines changed

7 files changed

+474
-221
lines changed

core/src/cloud/api/api.ts

Lines changed: 120 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
} from "./trpc.js"
2222
import { describeTRPCClientError, getAuthenticatedApiClient } from "./trpc.js"
2323
import type { GardenErrorParams } from "../../exceptions.js"
24-
import { CloudApiError, GardenError } from "../../exceptions.js"
24+
import { CloudApiError, GardenError, ParameterError } from "../../exceptions.js"
2525
import { gardenEnv } from "../../constants.js"
2626
import { LogLevel } from "../../logger/logger.js"
2727
import { getCloudDistributionName, getCloudLogSectionName } from "../util.js"
@@ -74,7 +74,8 @@ interface GardenCloudApiFactoryParams {
7474
log: Log
7575
cloudDomain: string
7676
globalConfigStore: GlobalConfigStore
77-
organizationId: string
77+
organizationId: string | undefined
78+
legacyProjectId: string | undefined
7879
skipLogging?: boolean
7980
__trpcClientOverrideForTesting?: ApiTrpcClient
8081
}
@@ -135,7 +136,8 @@ export class GardenCloudApi {
135136
static async factory({
136137
log,
137138
cloudDomain,
138-
organizationId,
139+
organizationId: paramOrganizationId,
140+
legacyProjectId,
139141
globalConfigStore,
140142
__trpcClientOverrideForTesting,
141143
skipLogging = false,
@@ -149,9 +151,19 @@ export class GardenCloudApi {
149151

150152
cloudFactoryLog.info("Authorizing...")
151153

154+
let authToken: string | undefined
155+
let organizationId: string | undefined = paramOrganizationId
156+
152157
if (gardenEnv.GARDEN_AUTH_TOKEN) {
153-
log.silly(() => "Using auth token from GARDEN_AUTH_TOKEN env var")
154-
if (!(await isTokenValid({ authToken: gardenEnv.GARDEN_AUTH_TOKEN, cloudDomain, log: cloudLog }))) {
158+
log.debug(() => "Using auth token from GARDEN_AUTH_TOKEN env var")
159+
if (
160+
!(await isTokenValid({
161+
authToken: gardenEnv.GARDEN_AUTH_TOKEN,
162+
cloudDomain,
163+
log: cloudLog,
164+
__trpcClientOverrideForTesting,
165+
}))
166+
) {
155167
throw new CloudApiError({
156168
message: deline`
157169
The provided access token is expired or has been revoked for ${cloudDomain}, please create a new one from the ${distroName} UI.
@@ -160,40 +172,77 @@ export class GardenCloudApi {
160172
})
161173
}
162174

163-
cloudFactoryLog.info(styles.success(successMsg))
164-
return new GardenCloudApi({
165-
log: cloudLog,
166-
domain: cloudDomain,
167-
organizationId,
168-
globalConfigStore,
169-
authToken: gardenEnv.GARDEN_AUTH_TOKEN,
170-
__trpcClientOverrideForTesting,
171-
})
172-
}
175+
authToken = gardenEnv.GARDEN_AUTH_TOKEN
176+
} else {
177+
const tokenData = await getStoredAuthToken(log, globalConfigStore, cloudDomain)
178+
authToken = tokenData?.token
179+
180+
if (!tokenData || !authToken) {
181+
log.debug(
182+
`No auth token found, proceeding without access to ${distroName}. Command results for this command run will not be available in ${distroName}.`
183+
)
184+
return undefined
185+
}
173186

174-
const tokenData = await getStoredAuthToken(log, globalConfigStore, cloudDomain)
175-
let authToken = tokenData?.token
187+
// Refresh the token if it has expired.
188+
if (isTokenExpired(tokenData)) {
189+
cloudFactoryLog.debug({ msg: `Current auth token is expired, attempting to refresh` })
190+
authToken = (
191+
await refreshAuthTokenAndWriteToConfigStore({
192+
log,
193+
globalConfigStore,
194+
cloudDomain,
195+
refreshToken: tokenData.refreshToken,
196+
__trpcClientOverrideForTesting,
197+
})
198+
).accessToken
199+
}
176200

177-
if (!tokenData || !authToken) {
178-
log.debug(
179-
`No auth token found, proceeding without access to ${distroName}. Command results for this command run will not be available in ${distroName}.`
180-
)
181-
return undefined
182-
}
201+
const tokenValid = await isTokenValid({ cloudDomain, authToken, log, __trpcClientOverrideForTesting })
183202

184-
// Refresh the token if it has expired.
185-
if (isTokenExpired(tokenData)) {
186-
cloudFactoryLog.debug({ msg: `Current auth token is expired, attempting to refresh` })
187-
authToken = (
188-
await refreshAuthTokenAndWriteToConfigStore(log, globalConfigStore, cloudDomain, tokenData.refreshToken)
189-
).accessToken
203+
if (!tokenValid) {
204+
log.debug({ msg: `The stored token was not valid.` })
205+
return undefined
206+
}
190207
}
191208

192-
const tokenValid = await isTokenValid({ cloudDomain, authToken, log })
209+
// Resolve organization ID if not provided but legacy project ID is available
210+
if (!organizationId && legacyProjectId) {
211+
cloudFactoryLog.debug({ msg: `No organization ID provided, attempting to resolve from project ID` })
212+
try {
213+
organizationId = await GardenCloudApi.getDefaultOrganizationIdForLegacyProject(
214+
cloudDomain,
215+
authToken,
216+
legacyProjectId,
217+
__trpcClientOverrideForTesting
218+
)
219+
if (organizationId) {
220+
cloudFactoryLog.debug({ msg: `Resolved organization ID: ${organizationId}` })
221+
cloudFactoryLog.warn({
222+
msg:
223+
dedent`
224+
Organization ID resolved from project ID. Please update your project configuration to specify the organization ID.
225+
226+
Add the following to your project configuration to avoid this message in the future:
227+
228+
${styles.command(`organizationId: ${organizationId}`)}
229+
` + "\n",
230+
})
231+
} else {
232+
cloudFactoryLog.debug({ msg: `Could not resolve organization ID from project ID` })
233+
}
234+
} catch (error) {
235+
cloudFactoryLog.warn({ msg: `Failed to resolve organization ID from project ID: ${error}` })
236+
}
237+
}
193238

194-
if (!tokenValid) {
195-
log.debug({ msg: `The stored token was not valid.` })
196-
return undefined
239+
if (!organizationId) {
240+
throw new ParameterError({
241+
message: deline`
242+
Could not determine organization ID. Please provide an organizationId in your project configuration
243+
or ensure your project is properly configured in ${distroName}.
244+
`,
245+
})
197246
}
198247

199248
// Start refresh interval if using JWT
@@ -203,9 +252,13 @@ export class GardenCloudApi {
203252
organizationId,
204253
globalConfigStore,
205254
authToken,
255+
__trpcClientOverrideForTesting,
206256
})
207-
cloudFactoryLog.debug({ msg: `Starting refresh interval.` })
208-
api.startInterval()
257+
258+
if (!gardenEnv.GARDEN_AUTH_TOKEN) {
259+
cloudFactoryLog.debug({ msg: `Starting refresh interval.` })
260+
api.startInterval()
261+
}
209262

210263
cloudFactoryLog.success(successMsg)
211264
return api
@@ -244,12 +297,12 @@ export class GardenCloudApi {
244297
const { sub, isAfter } = await import("date-fns")
245298

246299
if (isAfter(new Date(), sub(token.validity, { seconds: refreshThreshold }))) {
247-
const tokenResponse = await refreshAuthTokenAndWriteToConfigStore(
248-
this.log,
249-
this.globalConfigStore,
250-
this.domain,
251-
token.refreshToken
252-
)
300+
const tokenResponse = await refreshAuthTokenAndWriteToConfigStore({
301+
log: this.log,
302+
globalConfigStore: this.globalConfigStore,
303+
cloudDomain: this.domain,
304+
refreshToken: token.refreshToken,
305+
})
253306
this.authToken = tokenResponse.accessToken
254307
}
255308
}
@@ -354,7 +407,7 @@ export class GardenCloudApi {
354407
importVariables: ImportVariablesConfig
355408
environmentName: string
356409
log: Log
357-
legacyProjectId?: string | undefined
410+
legacyProjectId: string | undefined
358411
}) {
359412
log.info(`Fetching remote variables`)
360413
const variableListIds = await this.getVariableListIds(importVariables, legacyProjectId, log)
@@ -395,6 +448,28 @@ export class GardenCloudApi {
395448
return variables
396449
}
397450

451+
static async getDefaultOrganizationIdForLegacyProject(
452+
domain: string,
453+
authToken: string,
454+
legacyProjectId: string,
455+
__trpcClientOverrideForTesting?: ApiTrpcClient
456+
): Promise<string | undefined> {
457+
const tokenGetter = () => authToken
458+
const client = __trpcClientOverrideForTesting || getAuthenticatedApiClient({ hostUrl: domain, tokenGetter })
459+
460+
try {
461+
const response = await client.organization.legacyGetDefaultOrganization.query({
462+
legacyProjectId,
463+
})
464+
return response.id ?? undefined
465+
} catch (error) {
466+
if (error instanceof TRPCClientError) {
467+
throw GardenCloudTRPCError.wrapTRPCClientError(error)
468+
}
469+
throw error
470+
}
471+
}
472+
398473
async getVariableListIds(
399474
importVariables: ImportVariablesConfig,
400475
legacyProjectId: string | undefined,
@@ -417,13 +492,13 @@ export class GardenCloudApi {
417492
})
418493
log.warn(`No variable lists configured, falling back to default variable list: ${response.id}`)
419494
// Write a YAML snippet to help the user configure the variable list
420-
log.info(dedent`
495+
log.warn(dedent`
421496
To avoid using the default variable list (and suppress this message), you can configure remote variables in your project configuration:
422-
${styles.highlight(
497+
${styles.command(
423498
`
424499
importVariables:
425500
- from: "garden-cloud"
426-
list: ${styles.success('"' + response.id + '"')}
501+
list: ${'"' + response.id + '"'}
427502
description: "${response.description}"
428503
`
429504
)}

core/src/cloud/api/auth.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server"
1212
import { getHTTPStatusCodeFromError } from "@trpc/server/http"
1313
import type { ClientAuthToken, GlobalConfigStore } from "../../config-store/global.js"
1414
import type { Log } from "../../logger/log-entry.js"
15+
import type { ApiTrpcClient } from "./trpc.js"
1516
import { describeTRPCClientError, getNonAuthenticatedApiClient } from "./trpc.js"
1617
import { CloudApiTokenRefreshError } from "../api-legacy/api.js"
1718
import { CloudApiError, InternalError } from "../../exceptions.js"
@@ -73,14 +74,17 @@ export async function isTokenValid({
7374
authToken,
7475
cloudDomain,
7576
log,
77+
__trpcClientOverrideForTesting,
7678
}: {
7779
authToken: string
7880
cloudDomain: string
7981
log: Log
82+
__trpcClientOverrideForTesting?: ApiTrpcClient
8083
}): Promise<boolean> {
8184
try {
8285
log.debug(`Checking client auth token with ${getCloudDistributionName(cloudDomain)}`)
83-
const verificationResult = await getNonAuthenticatedApiClient({ hostUrl: cloudDomain }).token.verifyToken.query({
86+
const client = __trpcClientOverrideForTesting || getNonAuthenticatedApiClient({ hostUrl: cloudDomain })
87+
const verificationResult = await client.token.verifyToken.query({
8488
token: authToken,
8589
})
8690

@@ -116,14 +120,22 @@ export async function isTokenValid({
116120
}
117121
}
118122

119-
export async function refreshAuthTokenAndWriteToConfigStore(
120-
log: Log,
121-
globalConfigStore: GlobalConfigStore,
122-
cloudDomain: string,
123+
export async function refreshAuthTokenAndWriteToConfigStore({
124+
log,
125+
globalConfigStore,
126+
cloudDomain,
127+
refreshToken,
128+
__trpcClientOverrideForTesting,
129+
}: {
130+
log: Log
131+
globalConfigStore: GlobalConfigStore
132+
cloudDomain: string
123133
refreshToken: string
124-
) {
134+
__trpcClientOverrideForTesting?: ApiTrpcClient
135+
}) {
125136
try {
126-
const result = await getNonAuthenticatedApiClient({ hostUrl: cloudDomain }).token.refreshToken.mutate({
137+
const client = __trpcClientOverrideForTesting || getNonAuthenticatedApiClient({ hostUrl: cloudDomain })
138+
const result = await client.token.refreshToken.mutate({
127139
refreshToken,
128140
})
129141

0 commit comments

Comments
 (0)