diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/ProviderActionsDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/ProviderActionsDropdown.tsx index 5d5ab18e..a87e1ae3 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/ProviderActionsDropdown.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/ProviderActionsDropdown.tsx @@ -26,6 +26,7 @@ import { import { api } from "~/trpc/react"; import { UpdateAwsProviderDialog } from "./integrations/aws/UpdateAwsProviderDialog"; +import { UpdateAzureProviderDialog } from "./integrations/azure/UpdateAzureProviderDialog"; import { UpdateGoogleProviderDialog } from "./integrations/google/UpdateGoogleProviderDialog"; type Provider = RouterOutputs["resource"]["provider"]["byWorkspaceId"][number]; @@ -87,6 +88,18 @@ export const ProviderActionsDropdown: React.FC<{ )} + {provider.azureConfig != null && ( + setOpen(false)} + > + e.preventDefault()}> + Edit + + + )} {isManagedProvider && ( { diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/AzureDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/CreateAzureProviderDialog.tsx similarity index 88% rename from apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/AzureDialog.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/CreateAzureProviderDialog.tsx index 8fd4e993..71472347 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/AzureDialog.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/CreateAzureProviderDialog.tsx @@ -1,8 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { z } from "zod"; +import { createResourceProviderAzure } from "@ctrlplane/db/schema"; import { Button } from "@ctrlplane/ui/button"; import { Dialog, @@ -22,16 +22,12 @@ import { } from "@ctrlplane/ui/form"; import { Input } from "@ctrlplane/ui/input"; -type AzureDialogProps = { workspaceId: string }; +type CreateAzureProviderDialogProps = { workspaceId: string }; -const schema = z.object({ - tenantId: z.string(), - subscriptionId: z.string(), - name: z.string(), -}); - -export const AzureDialog: React.FC = ({ workspaceId }) => { - const form = useForm({ schema }); +export const CreateAzureProviderDialog: React.FC< + CreateAzureProviderDialogProps +> = ({ workspaceId }) => { + const form = useForm({ schema: createResourceProviderAzure }); const router = useRouter(); const onSubmit = form.handleSubmit((data) => diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/UpdateAzureProviderDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/UpdateAzureProviderDialog.tsx new file mode 100644 index 00000000..c7b0fd74 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/UpdateAzureProviderDialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import type { UpdateResourceProviderAzure } from "@ctrlplane/db/schema"; +import type * as SCHEMA from "@ctrlplane/db/schema"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +import { updateResourceProviderAzure } from "@ctrlplane/db/schema"; +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; + +type UpdateAzureProviderDialogProps = { + workspaceId: string; + resourceProvider: SCHEMA.ResourceProvider; + azureConfig: SCHEMA.ResourceProviderAzure; + children: React.ReactNode; + onClose?: () => void; +}; + +export const UpdateAzureProviderDialog: React.FC< + UpdateAzureProviderDialogProps +> = ({ workspaceId, resourceProvider, azureConfig, children, onClose }) => { + const [open, setOpen] = useState(false); + const form = useForm({ + schema: updateResourceProviderAzure, + defaultValues: { ...azureConfig, ...resourceProvider }, + }); + const router = useRouter(); + + const onSubmit = form.handleSubmit((data: UpdateResourceProviderAzure) => { + setOpen(false); + router.push( + `/api/azure/${workspaceId}/${data.tenantId}/${data.subscriptionId}/${data.name}?resourceProviderId=${resourceProvider.id}`, + ); + }); + + return ( + { + setOpen(o); + if (!o) onClose?.(); + }} + > + {children} + + + Configure Azure + + + + ( + + Name + + + + + + )} + /> + + ( + + Tenant ID + + + + + + )} + /> + + ( + + Subscription ID + + + + + )} + /> + + + Save + + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/[resourceProviderId]/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/[resourceProviderId]/page.tsx new file mode 100644 index 00000000..e6e1223c --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/azure/[resourceProviderId]/page.tsx @@ -0,0 +1,65 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { IconExternalLink } from "@tabler/icons-react"; + +import { cn } from "@ctrlplane/ui"; +import { buttonVariants } from "@ctrlplane/ui/button"; + +import { api } from "~/trpc/server"; + +type Params = { resourceProviderId: string }; + +export default async function AzureProviderPage({ + params, +}: { + params: Params; +}) { + const { resourceProviderId } = params; + + const provider = + await api.resource.provider.managed.azure.byProviderId(resourceProviderId); + + if (provider == null) return notFound(); + + const portalUrl = `https://portal.azure.com/#@${provider.azure_tenant.tenantId}/resource/subscriptions/${provider.resource_provider_azure.subscriptionId}/users`; + + return ( + + + Next steps + + To allow Ctrlplane to scan your Azure resources, you need to grant the + Azure service principal the necessary permissions. + + + + + + + Step 1: Go to Access Control (IAM) in the Azure portal + + + Go to IAM + + + + Step 2: Click "Add role assignment" + + + + Step 3: Configure the role assignment + + + + + + ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/page.tsx index 6439a67a..2e098fbf 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/integrations/page.tsx @@ -16,7 +16,7 @@ import { Card } from "@ctrlplane/ui/card"; import { env } from "~/env"; import { api } from "~/trpc/server"; import { AwsActionButton } from "./AwsActionButton"; -import { AzureDialog } from "./azure/AzureDialog"; +import { CreateAzureProviderDialog } from "./azure/CreateAzureProviderDialog"; import { GoogleActionButton } from "./GoogleActionButton"; export const metadata: Metadata = { @@ -137,7 +137,7 @@ const ResourceProviders: React.FC<{ workspaceSlug: string }> = async ({ - + )} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/page.tsx index eebe2c04..51d00f20 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(resources)/resource-providers/page.tsx @@ -123,7 +123,7 @@ export default async function ResourceProvidersPage({ }); return ( - + diff --git a/apps/webservice/src/app/api/azure/[workspaceId]/[tenantId]/[subscriptionId]/[name]/route.ts b/apps/webservice/src/app/api/azure/[workspaceId]/[tenantId]/[subscriptionId]/[name]/route.ts index 65a99e5a..fd46950b 100644 --- a/apps/webservice/src/app/api/azure/[workspaceId]/[tenantId]/[subscriptionId]/[name]/route.ts +++ b/apps/webservice/src/app/api/azure/[workspaceId]/[tenantId]/[subscriptionId]/[name]/route.ts @@ -1,5 +1,7 @@ import { randomUUID } from "crypto"; +import type { Tx } from "@ctrlplane/db"; import type { ResourceScanEvent } from "@ctrlplane/validators/events"; +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { Queue } from "bullmq"; import { FORBIDDEN, INTERNAL_SERVER_ERROR, NOT_FOUND } from "http-status"; @@ -29,8 +31,43 @@ const resourceScanQueue = new Queue(Channel.ResourceScan, { connection: redis, }); -export const GET = async (_: never, { params }: { params: Params }) => { +const createResourceProvider = async ( + db: Tx, + workspaceId: string, + tenantId: string, + subscriptionId: string, + name: string, +) => { + const resourceProvider = await db + .insert(SCHEMA.resourceProvider) + .values({ workspaceId, name }) + .returning() + .then(takeFirstOrNull); + + if (resourceProvider == null) + throw new Error("Failed to create resource provider"); + + await db.insert(SCHEMA.resourceProviderAzure).values({ + resourceProviderId: resourceProvider.id, + tenantId, + subscriptionId, + }); + + await resourceScanQueue.add( + resourceProvider.id, + { resourceProviderId: resourceProvider.id }, + { repeat: { every: ms("10m"), immediately: true } }, + ); +}; + +export const GET = async ( + request: NextRequest, + { params }: { params: Params }, +) => { const { workspaceId, tenantId, subscriptionId, name } = params; + const { searchParams } = new URL(request.url); + const resourceProviderId = searchParams.get("resourceProviderId"); + return db.transaction(async (db) => { const workspace = await db .select() @@ -56,7 +93,11 @@ export const GET = async (_: never, { params }: { params: Params }) => { const configJSON = JSON.stringify(config); await redis.set(`azure_consent_state:${state}`, configJSON, "EX", 900); const redirectUrl = `${baseUrl}/api/azure/consent?state=${state}`; - const consentUrl = `https://login.microsoftonline.com/${tenantId}/adminconsent?client_id=${clientId}&redirect_uri=${redirectUrl}`; + const consentUrlExtension = + resourceProviderId == null + ? "" + : `&resourceProviderId=${resourceProviderId}`; + const consentUrl = `https://login.microsoftonline.com/${tenantId}/adminconsent?client_id=${clientId}&redirect_uri=${redirectUrl}${consentUrlExtension}`; return NextResponse.redirect(consentUrl); } @@ -66,43 +107,44 @@ export const GET = async (_: never, { params }: { params: Params }) => { { status: FORBIDDEN }, ); - const resourceProvider = await db - .insert(SCHEMA.resourceProvider) - .values({ - workspaceId, - name, - }) - .returning() - .then(takeFirstOrNull); + const nextStepsUrl = `${baseUrl}/${workspace.slug}/resource-providers/integrations/azure/${resourceProviderId}`; - if (resourceProvider == null) - return NextResponse.json( - { error: "Failed to create resource provider" }, - { status: INTERNAL_SERVER_ERROR }, - ); + if (resourceProviderId != null) + return db + .update(SCHEMA.resourceProviderAzure) + .set({ tenantId: tenant.id, subscriptionId }) + .where( + eq( + SCHEMA.resourceProviderAzure.resourceProviderId, + resourceProviderId, + ), + ) + .then(() => + resourceScanQueue.add(resourceProviderId, { resourceProviderId }), + ) + .then(() => NextResponse.redirect(nextStepsUrl)) + .catch((error) => { + logger.error(error); + return NextResponse.json( + { error: "Failed to update resource provider" }, + { status: INTERNAL_SERVER_ERROR }, + ); + }); - try { - await db.insert(SCHEMA.resourceProviderAzure).values({ - resourceProviderId: resourceProvider.id, - tenantId: tenant.id, - subscriptionId, + return createResourceProvider( + db, + workspaceId, + tenant.id, + subscriptionId, + name, + ) + .then(() => NextResponse.redirect(nextStepsUrl)) + .catch((error) => { + logger.error(error); + return NextResponse.json( + { error: "Failed to create resource provider" }, + { status: INTERNAL_SERVER_ERROR }, + ); }); - - await resourceScanQueue.add( - resourceProvider.id, - { resourceProviderId: resourceProvider.id }, - { repeat: { every: ms("10m"), immediately: true } }, - ); - } catch (error) { - logger.error(error); - return NextResponse.json( - { error: "Failed to create resource provider Azure" }, - { status: INTERNAL_SERVER_ERROR }, - ); - } - - return NextResponse.redirect( - `${baseUrl}/${workspace.slug}/resource-providers`, - ); }); }; diff --git a/apps/webservice/src/app/api/azure/consent/route.ts b/apps/webservice/src/app/api/azure/consent/route.ts index af9173b7..a9b259b8 100644 --- a/apps/webservice/src/app/api/azure/consent/route.ts +++ b/apps/webservice/src/app/api/azure/consent/route.ts @@ -30,7 +30,7 @@ const configSchema = z.object({ export const GET = async (req: NextRequest) => { const { searchParams } = new URL(req.url); const state = searchParams.get("state"); - + const resourceProviderId = searchParams.get("resourceProviderId"); if (!state) return NextResponse.json({ error: "Bad request" }, { status: BAD_REQUEST }); @@ -70,6 +70,26 @@ export const GET = async (req: NextRequest) => { { status: INTERNAL_SERVER_ERROR }, ); + const nextStepsUrl = `${env.BASE_URL}/${workspace.slug}/resource-providers/integrations/azure/${resourceProviderId}`; + + if (resourceProviderId != null) + return db + .update(SCHEMA.resourceProviderAzure) + .set({ tenantId, subscriptionId }) + .where( + eq( + SCHEMA.resourceProviderAzure.resourceProviderId, + resourceProviderId, + ), + ) + .then(() => NextResponse.redirect(nextStepsUrl)) + .catch(() => + NextResponse.json( + { error: "Failed to update resource provider" }, + { status: INTERNAL_SERVER_ERROR }, + ), + ); + const resourceProvider = await db .insert(SCHEMA.resourceProvider) .values({ workspaceId, name }) @@ -82,14 +102,17 @@ export const GET = async (req: NextRequest) => { { status: INTERNAL_SERVER_ERROR }, ); - const resourceProviderId = resourceProvider.id; return db .insert(SCHEMA.resourceProviderAzure) - .values({ resourceProviderId, tenantId: tenant.id, subscriptionId }) + .values({ + resourceProviderId: resourceProvider.id, + tenantId: tenant.id, + subscriptionId, + }) .then(() => resourceScanQueue.add( - resourceProviderId, - { resourceProviderId }, + resourceProvider.id, + { resourceProviderId: resourceProvider.id }, { repeat: { every: ms("10m"), immediately: true } }, ), ) diff --git a/apps/webservice/src/env.ts b/apps/webservice/src/env.ts index 4bce9934..acd51023 100644 --- a/apps/webservice/src/env.ts +++ b/apps/webservice/src/env.ts @@ -30,6 +30,8 @@ export const env = createEnv({ OPENREPLAY_PROJECT_KEY: z.string().optional(), OPENREPLAY_INGEST_POINT: z.string().optional(), AZURE_APP_CLIENT_ID: z.string().optional(), + AZURE_APP_CLIENT_SECRET: z.string().optional(), + AZURE_TENANT_ID: z.string().optional(), REDIS_URL: z.string(), }, diff --git a/packages/api/src/router/resource-provider.ts b/packages/api/src/router/resource-provider.ts index 15ec4240..9f64ed66 100644 --- a/packages/api/src/router/resource-provider.ts +++ b/packages/api/src/router/resource-provider.ts @@ -11,6 +11,7 @@ import { takeFirstOrNull, } from "@ctrlplane/db"; import { + azureTenant, createResourceProvider, createResourceProviderAws, createResourceProviderGoogle, @@ -37,6 +38,20 @@ export const resourceProviderRouter = createTRPCRouter({ }) .input(z.string().uuid()) .query(async ({ ctx, input }) => { + const azureConfigSubquery = ctx.db + .select({ + id: resourceProviderAzure.id, + tenantId: azureTenant.tenantId, + subscriptionId: resourceProviderAzure.subscriptionId, + resourceProviderId: resourceProviderAzure.resourceProviderId, + }) + .from(resourceProviderAzure) + .innerJoin( + azureTenant, + eq(resourceProviderAzure.tenantId, azureTenant.id), + ) + .as("azureConfig"); + const providers = await ctx.db .select() .from(resourceProvider) @@ -49,8 +64,8 @@ export const resourceProviderRouter = createTRPCRouter({ eq(resourceProviderAws.resourceProviderId, resourceProvider.id), ) .leftJoin( - resourceProviderAzure, - eq(resourceProviderAzure.resourceProviderId, resourceProvider.id), + azureConfigSubquery, + eq(azureConfigSubquery.resourceProviderId, resourceProvider.id), ) .where(eq(resourceProvider.workspaceId, input)); @@ -97,7 +112,7 @@ export const resourceProviderRouter = createTRPCRouter({ ...provider.resource_provider, googleConfig: provider.resource_provider_google, awsConfig: provider.resource_provider_aws, - azureConfig: provider.resource_provider_azure, + azureConfig: provider.azureConfig, resourceCount: providerCounts.find( (pc) => pc.providerId === provider.resource_provider.id, @@ -337,6 +352,27 @@ export const resourceProviderRouter = createTRPCRouter({ }); }), }), + azure: createTRPCRouter({ + byProviderId: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.ResourceProviderGet) + .on({ type: "resourceProvider", id: input }), + }) + .query(({ ctx, input }) => + ctx.db + .select() + .from(resourceProviderAzure) + .innerJoin( + azureTenant, + eq(resourceProviderAzure.tenantId, azureTenant.id), + ) + .where(eq(resourceProviderAzure.resourceProviderId, input)) + .then(takeFirstOrNull), + ), + }), }), delete: protectedProcedure .meta({ diff --git a/packages/db/src/schema/resource-provider.ts b/packages/db/src/schema/resource-provider.ts index eb015443..40562fd0 100644 --- a/packages/db/src/schema/resource-provider.ts +++ b/packages/db/src/schema/resource-provider.ts @@ -141,6 +141,23 @@ export const resourceProviderAzure = pgTable("resource_provider_azure", { subscriptionId: text("subscription_id").notNull(), }); +export const createResourceProviderAzure = createInsertSchema( + resourceProviderAzure, +) + .omit({ id: true, resourceProviderId: true }) + .extend({ name: z.string().min(1) }); + +export type CreateResourceProviderAzure = z.infer< + typeof createResourceProviderAzure +>; + +export const updateResourceProviderAzure = + createResourceProviderAzure.partial(); + +export type UpdateResourceProviderAzure = z.infer< + typeof updateResourceProviderAzure +>; + export type AzureTenant = InferSelectModel; export type ResourceProviderAzure = InferSelectModel<
+ To allow Ctrlplane to scan your Azure resources, you need to grant the + Azure service principal the necessary permissions. +