Pārlūkot izejas kodu

poll for cloud provider permission status (#4390)

Feroze Mohideen 2 gadi atpakaļ
vecāks
revīzija
9f00e94d58

+ 176 - 0
api/server/handlers/project_integration/get_cloud_provider_permissions_status.go

@@ -0,0 +1,176 @@
+package project_integration
+
+import (
+	"context"
+	"net/http"
+	"strings"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CloudProviderPermissionsStatusHandler is the handler for checking the status of cloud provider permissions
+type CloudProviderPermissionsStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCloudProviderPermissionsStatusHandler returns a handler for checking the status of cloud provider permissions
+func NewCloudProviderPermissionsStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CloudProviderPermissionsStatusHandler {
+	return &CloudProviderPermissionsStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// CloudProviderType is a type for the cloud provider
+type CloudProviderType string
+
+const (
+	// CloudProviderAWS is the AWS cloud provider
+	CloudProviderAWS CloudProviderType = "AWS"
+	// CloudProviderGCP is the GCP cloud provider
+	CloudProviderGCP CloudProviderType = "GCP"
+	// CloudProviderAzure is the Azure cloud provider
+	CloudProviderAzure CloudProviderType = "Azure"
+)
+
+// CloudProviderPermissionsStatusRequest is the request to check the status of cloud provider permissions
+type CloudProviderPermissionsStatusRequest struct {
+	CloudProvider                     CloudProviderType `schema:"cloud_provider"`
+	CloudProviderCredentialIdentifier string            `schema:"cloud_provider_credential_identifier"`
+}
+
+// CloudProviderPermissionsStatusResponse is the response to check the status of cloud provider permissions
+type CloudProviderPermissionsStatusResponse struct {
+	PercentCompleted float32 `json:"percent_completed"`
+}
+
+// ServeHTTP checks the status of cloud provider permissions
+func (p *CloudProviderPermissionsStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-cloud-provider-permissions-status")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &CloudProviderPermissionsStatusRequest{}
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cloud-provider", Value: string(request.CloudProvider)},
+		telemetry.AttributeKV{Key: "cloud-provider-credential-identifier", Value: request.CloudProviderCredentialIdentifier},
+	)
+
+	if request.CloudProviderCredentialIdentifier == "" {
+		err := telemetry.Error(ctx, span, nil, "missing cloud provider credential identifier")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.CloudProvider == "" {
+		err := telemetry.Error(ctx, span, nil, "missing cloud provider")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var cloudProvider porterv1.EnumCloudProvider
+	switch request.CloudProvider {
+	case CloudProviderAWS:
+		accessErrorExists, err := p.checkSameAccountInDifferentProjects(ctx, request.CloudProviderCredentialIdentifier, user)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error checking if same account exists in different projects")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if accessErrorExists {
+			err = telemetry.Error(ctx, span, err, "user does not have access to all projects")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+			return
+		}
+		cloudProvider = porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
+	}
+
+	credReq := porterv1.CloudProviderPermissionsStatusRequest{
+		ProjectId:                         int64(project.ID),
+		CloudProvider:                     cloudProvider,
+		CloudProviderCredentialIdentifier: request.CloudProviderCredentialIdentifier,
+	}
+	credResp, err := p.Config().ClusterControlPlaneClient.CloudProviderPermissionsStatus(ctx, connect.NewRequest(&credReq))
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error checking cloud provider permissions status")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if credResp == nil {
+		err = telemetry.Error(ctx, span, err, "error reading cloud provider permissions response")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if credResp.Msg == nil {
+		err = telemetry.Error(ctx, span, err, "error reading cloud provider permissions message")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := CloudProviderPermissionsStatusResponse{
+		PercentCompleted: credResp.Msg.PercentCompleted,
+	}
+
+	p.WriteResult(w, r, res)
+}
+
+func (p *CloudProviderPermissionsStatusHandler) checkSameAccountInDifferentProjects(ctx context.Context, targetArn string, user *models.User) (bool, error) {
+	ctx, span := telemetry.NewSpan(ctx, "check-same-account-in-different-projects")
+	defer span.End()
+
+	// if a user is changing the external ID, then we need to update the external ID for all projects that use that AWS account.
+	// This is required since the same AWS account can be used across multiple projects. In order to change the external ID for a project,
+	// the user must then have access to all projects that use that AWS account.
+	// If we ever do a higher abstraction about porter projects, then we can tie the ability to access a cloud provider account to that higher abstraction.
+	awsAccountIdPrefix := strings.TrimPrefix(targetArn, "arn:aws:iam::")
+	awsAccountId := strings.TrimSuffix(awsAccountIdPrefix, ":role/porter-manager")
+	assumeRoles, err := p.Repo().AWSAssumeRoleChainer().ListByAwsAccountId(ctx, awsAccountId)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error listing assume role chains")
+	}
+
+	requiredProjects := make(map[int]bool)
+	for _, role := range assumeRoles {
+		requiredProjects[role.ProjectID] = false
+	}
+
+	usersProject, err := p.Repo().Project().ListProjectsByUserID(user.ID)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error listing projects by user id")
+	}
+
+	for _, project := range usersProject {
+		if _, ok := requiredProjects[int(project.ID)]; ok {
+			requiredProjects[int(project.ID)] = true
+		}
+	}
+
+	for proj, required := range requiredProjects {
+		if !required {
+			err = telemetry.Error(ctx, span, err, "user does not have access to all projects that use this AWS account")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "missing-project", Value: proj})
+			return true, err
+		}
+	}
+
+	return false, nil
+}

+ 28 - 0
api/server/router/project_integration.go

@@ -679,5 +679,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/integrations/cloud-permissions -> project_integration.NewCloudProviderPermissionsStatusHandler
+	cloudPermissionsStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/cloud-permissions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	cloudPermissionsStatusHandler := project_integration.NewCloudProviderPermissionsStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: cloudPermissionsStatusEndpoint,
+		Handler:  cloudPermissionsStatusHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 3 - 11
dashboard/src/lib/hooks/useCloudProvider.ts

@@ -2,8 +2,7 @@ import { z } from "zod";
 
 import api from "shared/api";
 
-// TODO: refactor this to match "connectTo.." syntax
-export const isAWSArnAccessible = async ({
+export const connectToAwsAccount = async ({
   targetArn,
   externalId,
   projectId,
@@ -11,8 +10,8 @@ export const isAWSArnAccessible = async ({
   targetArn: string;
   externalId: string;
   projectId: number;
-}): Promise<number> => {
-  const res = await api.createAWSIntegration(
+}): Promise<void> => {
+  await api.createAWSIntegration(
     "<token>",
     {
       aws_target_arn: targetArn,
@@ -20,13 +19,6 @@ export const isAWSArnAccessible = async ({
     },
     { id: projectId }
   );
-  const parsed = await z
-    .object({
-      percent_completed: z.number(),
-    })
-    .parseAsync(res.data);
-
-  return parsed.percent_completed;
 };
 
 export const connectToAzureAccount = async ({

+ 8 - 2
dashboard/src/main/home/infrastructure-dashboard/forms/CloudProviderSelect.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useContext, useState } from "react";
 import styled from "styled-components";
 
 import Button from "components/porter/Button";
@@ -14,6 +14,7 @@ import {
 } from "lib/clusters/constants";
 import { type ClientCloudProvider } from "lib/clusters/types";
 
+import { Context } from "shared/Context";
 import bolt from "assets/bolt.svg";
 
 import CostConsentModal from "../modals/cost-consent/CostConsentModal";
@@ -25,6 +26,7 @@ const CloudProviderSelect: React.FC<Props> = ({ onComplete }) => {
   const [cloudProvider, setCloudProvider] = useState<
     ClientCloudProvider | undefined
   >(undefined);
+  const { user } = useContext(Context);
 
   return (
     <div>
@@ -43,7 +45,11 @@ const CloudProviderSelect: React.FC<Props> = ({ onComplete }) => {
                 <Block
                   key={i}
                   onClick={() => {
-                    setCloudProvider(provider);
+                    if (user?.isPorterUser) {
+                      onComplete(provider);
+                    } else {
+                      setCloudProvider(provider);
+                    }
                   }}
                 >
                   <Icon src={provider.icon} />

+ 88 - 37
dashboard/src/main/home/infrastructure-dashboard/forms/aws/GrantAWSPermissions.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useEffect, useMemo, useState } from "react";
 import { useQuery } from "@tanstack/react-query";
 import axios from "axios";
+import AnimateHeight from "react-animate-height";
 import styled from "styled-components";
 import { v4 as uuidv4 } from "uuid";
 import { z } from "zod";
@@ -18,11 +19,14 @@ import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
 import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
 import { CloudProviderAWS } from "lib/clusters/constants";
-import { isAWSArnAccessible } from "lib/hooks/useCloudProvider";
+import { connectToAwsAccount } from "lib/hooks/useCloudProvider";
 import { useClusterAnalytics } from "lib/hooks/useClusterAnalytics";
 import { useIntercom } from "lib/hooks/useIntercom";
 
+import api from "shared/api";
+
 import GrantAWSPermissionsHelpModal from "../../modals/help/permissions/GrantAWSPermissionsHelpModal";
+import { CheckItem } from "../../modals/PreflightChecksModal";
 
 type Props = {
   goBack: () => void;
@@ -95,12 +99,22 @@ const GrantAWSPermissions: React.FC<Props> = ({
     ],
     async () => {
       try {
-        const res = await isAWSArnAccessible({
-          targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
-          externalId,
-          projectId,
-        });
-        return res;
+        const res = await api.getCloudProviderPermissionsStatus(
+          "<token>",
+          {
+            cloud_provider: "AWS",
+            cloud_provider_credential_identifier: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+          },
+          {
+            project_id: projectId,
+          }
+        );
+        const parsed = z
+          .object({
+            percent_completed: z.number(),
+          })
+          .parse(res.data);
+        return parsed.percent_completed;
       } catch (err) {
         return 0;
       }
@@ -126,11 +140,22 @@ const GrantAWSPermissions: React.FC<Props> = ({
   const checkIfAlreadyAccessible = async (): Promise<void> => {
     setAccountIdContinueButtonStatus("loading");
     try {
-      const awsIntegrationPercentCompleted = await isAWSArnAccessible({
-        targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
-        externalId,
-        projectId,
-      });
+      const res = await api.getCloudProviderPermissionsStatus(
+        "<token>",
+        {
+          cloud_provider: "AWS",
+          cloud_provider_credential_identifier: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+        },
+        {
+          project_id: projectId,
+        }
+      );
+      const parsed = z
+        .object({
+          percent_completed: z.number(),
+        })
+        .parse(res.data);
+      const awsIntegrationPercentCompleted = parsed.percent_completed;
       if (awsIntegrationPercentCompleted > 0) {
         // this indicates the permission check is already in place; no need to re-create cloudformation stack
         setCurrentStep(3);
@@ -187,6 +212,17 @@ const GrantAWSPermissions: React.FC<Props> = ({
   };
 
   const directToCloudFormation = useCallback(async () => {
+    try {
+      // this sends an async connection request on the backend
+      await connectToAwsAccount({
+        targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+        externalId,
+        projectId,
+      });
+    } catch (err) {
+      // todo: handle error here
+    }
+
     const trustArn = process.env.TRUST_ARN
       ? process.env.TRUST_ARN
       : "arn:aws:iam::108458755588:role/CAPIManagement";
@@ -362,32 +398,47 @@ const GrantAWSPermissions: React.FC<Props> = ({
           <>
             <Text size={16}>Check permissions</Text>
             <Spacer y={1} />
-            <StatusBar
-              icon={CloudProviderAWS.icon}
-              title={"AWS permissions setup"}
-              titleDescriptor={awsPermissionsLoadingMessage}
-              subtitle={
-                permissionsGrantCompletionPercentage === 100
-                  ? "Porter can access your account! You may now continue."
-                  : "Porter is creating roles and policies to access your account. This can take up to 15 minutes. Please stay on this page."
-              }
-              percentCompleted={Math.max(
-                permissionsGrantCompletionPercentage,
-                5
-              )}
-            />
-            <Spacer y={0.5} />
-            <Link
-              hasunderline
-              onClick={() => {
-                showIntercomWithMessage({
-                  message: "I need help with AWS permissions setup.",
-                  delaySeconds: 0,
-                });
-              }}
+            <AnimateHeight
+              height={permissionsGrantCompletionPercentage === 100 ? 0 : "auto"}
             >
-              Need help?
-            </Link>
+              <StatusBar
+                icon={CloudProviderAWS.icon}
+                title={"AWS permissions setup"}
+                titleDescriptor={awsPermissionsLoadingMessage}
+                subtitle={
+                  permissionsGrantCompletionPercentage === 100
+                    ? "Porter can access your account! You may now continue."
+                    : "Porter is creating roles and policies to access your account. This can take up to 15 minutes. Please stay on this page."
+                }
+                percentCompleted={Math.max(
+                  permissionsGrantCompletionPercentage,
+                  5
+                )}
+              />
+              <Spacer y={0.5} />
+              <Link
+                hasunderline
+                onClick={() => {
+                  showIntercomWithMessage({
+                    message: "I need help with AWS permissions setup.",
+                    delaySeconds: 0,
+                  });
+                }}
+              >
+                Need help?
+              </Link>
+            </AnimateHeight>
+            <AnimateHeight
+              height={permissionsGrantCompletionPercentage === 100 ? "auto" : 0}
+            >
+              <CheckItem
+                preflightCheck={{
+                  title:
+                    "AWS account is accessible by Porter! You may continue.",
+                  status: "success",
+                }}
+              />
+            </AnimateHeight>
             <Spacer y={1} />
             <Container row>
               <Button

+ 1 - 1
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/GrantGCPPermissions.tsx

@@ -227,7 +227,7 @@ const GrantGCPPermissions: React.FC<Props> = ({
             <Container row>
               <Button
                 onClick={() => {
-                  setCurrentStep(2);
+                  setCurrentStep(1);
                 }}
                 color="#222222"
               >

+ 13 - 0
dashboard/src/shared/api.tsx

@@ -3463,6 +3463,18 @@ const createSecretAndOpenGitHubPullRequest = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/pr`
 );
 
+const getCloudProviderPermissionsStatus = baseApi<
+  {
+    cloud_provider: string;
+    cloud_provider_credential_identifier: string;
+  },
+  { project_id: number }
+>(
+  "GET",
+  ({ project_id }) =>
+    `/api/projects/${project_id}/integrations/cloud-permissions`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -3754,4 +3766,5 @@ export default {
   // STATUS
   getGithubStatus,
   getDatabaseStatus,
+  getCloudProviderPermissionsStatus,
 };

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.114
+	github.com/porter-dev/api-contracts v0.2.116
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 0
go.sum

@@ -1527,6 +1527,8 @@ github.com/porter-dev/api-contracts v0.2.113 h1:sv1huO9MpykJaWhV2D5zTD2LouMbRSBV
 github.com/porter-dev/api-contracts v0.2.113/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.114 h1:qfEq70BJ8xXTkiZU7ygzOSGnMCqJHOa5Lbkfu4OzQBI=
 github.com/porter-dev/api-contracts v0.2.114/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.116 h1:Oe7pO69yyv1lEnafGWnKoHqNYJAe8KpQCE9cnTC1m1w=
+github.com/porter-dev/api-contracts v0.2.116/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=