Browse Source

Merge branch 'master' of github.com:porter-dev/porter into 0.6.0-implement-rbac

jnfrati 4 years ago
parent
commit
d5b5ed57c9
44 changed files with 1144 additions and 133 deletions
  1. 2 0
      cmd/app/main.go
  2. 8 6
      cmd/migrate/keyrotate/helpers_test.go
  3. 2 0
      cmd/migrate/main.go
  4. 10 4
      dashboard/src/main/home/Home.tsx
  5. 3 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  6. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  7. 1 1
      dashboard/src/main/home/integrations/IntegrationList.tsx
  8. 1 5
      dashboard/src/main/home/integrations/Integrations.tsx
  9. 266 0
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  10. 1 1
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  11. 1 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  12. 1 1
      dashboard/src/main/home/modals/EnvEditorModal.tsx
  13. 1 1
      dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx
  14. 1 1
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  15. 1 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  16. 1 1
      dashboard/src/main/home/modals/NamespaceModal.tsx
  17. 1 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  18. 30 5
      dashboard/src/main/home/navbar/Navbar.tsx
  19. 1 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  20. 3 1
      dashboard/src/shared/Context.tsx
  21. 5 0
      dashboard/src/shared/api.tsx
  22. 9 0
      dashboard/src/shared/hardcodedNameDict.tsx
  23. 1 0
      dashboard/webpack.config.js
  24. 23 0
      docs/guides/linking-github-account.md
  25. 5 0
      internal/config/config.go
  26. 8 6
      internal/forms/helper_test.go
  27. 1 0
      internal/helm/agent.go
  28. 33 0
      internal/models/integrations/github_app.go
  29. 21 7
      internal/models/integrations/oauth.go
  30. 3 0
      internal/models/user.go
  31. 24 0
      internal/oauth/config.go
  32. 92 0
      internal/repository/gorm/auth.go
  33. 16 12
      internal/repository/gorm/auth_test.go
  34. 9 6
      internal/repository/gorm/helpers_test.go
  35. 22 20
      internal/repository/gorm/repository.go
  36. 16 0
      internal/repository/integrations.go
  37. 121 2
      internal/repository/memory/auth.go
  38. 19 17
      internal/repository/memory/repository.go
  39. 22 20
      internal/repository/repository.go
  40. 10 0
      server/api/api.go
  41. 210 0
      server/api/integration_handler.go
  42. 7 5
      server/api/oauth_do_handler.go
  43. 98 5
      server/api/oauth_github_handler.go
  44. 32 0
      server/router/router.go

+ 2 - 0
cmd/app/main.go

@@ -72,6 +72,8 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

+ 8 - 6
cmd/migrate/keyrotate/helpers_test.go

@@ -253,12 +253,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 2 - 0
cmd/migrate/main.go

@@ -57,6 +57,8 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

+ 10 - 4
dashboard/src/main/home/Home.tsx

@@ -29,7 +29,7 @@ import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
-
+import AccountSettingsModal from "./modals/AccountSettingsModal";
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -105,9 +105,6 @@ class Home extends Component<PropsType, StateType> {
   };
 
   getCapabilities = () => {
-    let { currentProject } = this.props;
-    if (!currentProject) return;
-
     api
       .getCapabilities("<token>", {}, {})
       .then((res) => {
@@ -550,6 +547,15 @@ class Home extends Component<PropsType, StateType> {
             <EditInviteOrCollaboratorModal />
           </Modal>
         )}
+        {currentModal === "AccountSettingsModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="760px"
+            height="440px"
+          >
+            <AccountSettingsModal />
+          </Modal>
+        )}
 
         {this.renderSidebar()}
 

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -112,7 +112,7 @@ export const NamespaceList: React.FunctionComponent = () => {
             (namespace) => namespace.metadata.name === data.Object.metadata.name
           );
           oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
-          return oldNamespaces;
+          return [...oldNamespaces];
         });
       }
     };
@@ -171,7 +171,8 @@ export const NamespaceList: React.FunctionComponent = () => {
                 </Status>
               </ContentContainer>
               {isAuthorized("namespace", "", ["get", "delete"]) &&
-                isAvailableForDeletion(namespace?.metadata?.name) && (
+                isAvailableForDeletion(namespace?.metadata?.name) &&
+                namespace?.status?.phase === "Active" && (
                   <OptionsDropdown>
                     <DropdownOption onClick={() => onDelete(namespace)}>
                       <i className="material-icons-outlined">delete</i>

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -139,6 +139,8 @@ export default class Logs extends Component<PropsType, StateType> {
     if (prevState.currentTab !== this.state.currentTab) {
       let { selectedPod } = this.props;
 
+      this.ws?.close();
+
       this.setState({ logs: [] });
 
       if (this.state.currentTab == "Application") {

+ 1 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -134,7 +134,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
             label={label}
             toggleCollapse={(e: MouseEvent) => this.toggleDisplay(e, i)}
             triggerDelete={(e: MouseEvent) => this.triggerDelete(e, i, item_id)}
-          ></IntegrationRow>
+          />
         );
       });
     } else if (integrations && integrations.length > 0) {

+ 1 - 5
dashboard/src/main/home/integrations/Integrations.tsx

@@ -72,11 +72,7 @@ class Integrations extends Component<PropsType, StateType> {
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
               pushFiltered(this.props, "/integrations", ["project_id"]);
             }
-            return (
-              <IntegrationCategories
-                category={currentCategory}
-              ></IntegrationCategories>
-            );
+            return <IntegrationCategories category={currentCategory} />;
           }}
         />
         <Route>

+ 266 - 0
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -0,0 +1,266 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import close from "assets/close.png";
+import github from "assets/github.png";
+
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import Loading from "../../../components/Loading";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+import TabSelector from "components/TabSelector";
+
+interface GithubAppAccessData {
+  has_access: boolean;
+  username?: string;
+  accounts?: string[];
+}
+
+const tabOptions = [{ label: "Integrations", value: "integrations" }];
+
+const AccountSettingsModal = () => {
+  const { setCurrentModal } = useContext(Context);
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({
+    has_access: false,
+  });
+  const [currentTab, setCurrentTab] = useState("integrations");
+
+  useEffect(() => {
+    api
+      .getGithubAccess("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+  }, []);
+
+  return (
+    <>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+      <ModalTitle>
+        Account Settings
+      </ModalTitle>
+
+      <TabSelector
+        options={tabOptions}
+        currentTab={currentTab}
+        setCurrentTab={(value: string) =>
+          setCurrentTab(value)
+        }
+      />
+
+      <Heading>
+        <GitIcon src={github} /> Github
+      </Heading>
+      {accessError ? (
+        <Placeholder>An error has occured.</Placeholder>
+      ) : accessLoading ? (
+        <LoadingWrapper>
+          {" "}
+          <Loading />
+        </LoadingWrapper>
+      ) : (
+        <>
+          {/* Will be styled (and show what account is connected) later */}
+          {accessData.has_access ? (
+            <Placeholder>
+              <User>
+                You are currently authorized as <B>{accessData.username}</B> and have access to:
+              </User>
+              {!accessData.accounts || accessData.accounts?.length == 0 ? (
+                <ListWrapper>
+                  <Helper>
+                    No connected repositories found.
+                    <A href={"/api/integrations/github-app/install"}>
+                      Install Porter in your repositories
+                    </A>
+                  </Helper>
+                </ListWrapper>
+              ) : (
+                <>
+                  <List>
+                    {accessData.accounts.map((name, i) => {
+                      return (
+                        <React.Fragment key={i}>
+                          <Row isLastItem={i === accessData.accounts.length - 1}>
+                            <i className="material-icons">bookmark</i>
+                            {name}
+                          </Row>
+                        </React.Fragment>
+                      );
+                    })}
+                  </List>
+                  <br />
+                  Don't see the right repos?{" "}
+                  <A href={"/api/integrations/github-app/install"}>
+                    Install Porter in more repositories
+                  </A>
+                </>
+              )}
+            </Placeholder>
+          ) : (
+            <ListWrapper>
+              <Helper>
+                No github integration detected. You can
+                <A href={"/api/integrations/github-app/authorize"}>
+                  connect your GitHub account
+                </A>
+              </Helper>
+            </ListWrapper>
+          )}
+        </>
+      )}
+    </>
+  );
+};
+
+export default AccountSettingsModal;
+
+const B = styled.b`
+  color: #ffffff;
+`;
+
+const User = styled.div`
+  margin-top: 14px;
+  font-size: 13px;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  height: 200px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 40px;
+`;
+
+const List = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  border-radius: 5px;
+  margin-top: 20px;
+  border: 1px solid #ffffff44;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLastItem?: boolean }>`
+  width: 100%;
+  height: 35px;
+  color: #ffffff55;
+  display: flex;
+  align-items: center;
+  border-bottom: ${props => props.isLastItem ? "" : "1px solid #ffffff44"};
+  > i {
+    font-size: 17px;
+    margin-left: 10px;
+    margin-right: 12px;
+    color: #ffffff44;
+  }
+`;
+
+const GitIcon = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 10px;
+  filter: brightness(120%);
+  margin-left: 1px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 18px;
+    margin-top: 1px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 0;
+  }
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 50px;
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  line-height: 1.6em;
+  user-select: none;
+`;

+ 1 - 1
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -224,7 +224,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -136,7 +136,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/EnvEditorModal.tsx

@@ -123,7 +123,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx

@@ -152,7 +152,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -140,7 +140,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -189,7 +189,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -162,7 +162,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -267,7 +267,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 30 - 5
dashboard/src/main/home/navbar/Navbar.tsx

@@ -36,9 +36,19 @@ class Navbar extends Component<PropsType, StateType> {
             <DropdownLabel>
               {this.context.user && this.context.user.email}
             </DropdownLabel>
-            <LogOutButton onClick={this.props.logOut}>
+            <UserDropdownButton
+              onClick={() =>
+                this.context.setCurrentModal("AccountSettingsModal", {})
+              }
+            >
+              <SettingsIcon>
+                <i className="material-icons">settings</i>
+              </SettingsIcon>
+              Account Settings
+            </UserDropdownButton>
+            <UserDropdownButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
-            </LogOutButton>
+            </UserDropdownButton>
           </Dropdown>
         </>
       );
@@ -77,8 +87,7 @@ class Navbar extends Component<PropsType, StateType> {
             this.setState({ showDropdown: !this.state.showDropdown })
           }
         >
-          <I className="material-icons-outlined">account_circle</I>
-          {this.context.user?.email}
+          <I className="material-icons">account_circle</I>
           {this.renderSettingsDropdown()}
         </NavButton>
       </StyledNavbar>
@@ -90,6 +99,22 @@ Navbar.contextType = Context;
 
 export default withAuth(Navbar);
 
+const SettingsIcon = styled.div`
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 15px;
+    top: 11px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 0;
+  }
+`;
+
 const I = styled.i`
   margin-right: 7px;
 `;
@@ -111,7 +136,7 @@ const CloseOverlay = styled.div`
   cursor: default;
 `;
 
-const LogOutButton = styled.button`
+const UserDropdownButton = styled.button`
   padding: 13px;
   height: 40px;
   font-size: 13px;

+ 1 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -579,7 +579,7 @@ const Tooltip = styled.div`
   flex: 1;
   color: white;
   font-size: 12px;
-  font-family: "Assistant", sans-serif;
+  font-family: Work Sans, sans-serif;
   outline: 1px solid #ffffff55;
   opacity: 0;
   animation: faded-in 0.2s 0.15s;

+ 3 - 1
dashboard/src/shared/Context.tsx

@@ -86,9 +86,11 @@ class ContextProvider extends Component<PropsType, StateType> {
     },
     currentProject: null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
-      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
       if (currentProject) {
         localStorage.setItem("currentProject", currentProject.id.toString());
+        pushQueryParams(this.props, {
+          project_id: currentProject.id.toString(),
+        });
       } else {
         localStorage.removeItem("currentProject");
       }

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

@@ -733,6 +733,10 @@ const linkGithubProject = baseApi<
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
+const getGithubAccess = baseApi<{}, {}>("GET", () => {
+  return `/api/integrations/github-app/access`;
+});
+
 const logInUser = baseApi<{
   email: string;
   password: string;
@@ -1029,6 +1033,7 @@ export default {
   getTemplates,
   getUser,
   linkGithubProject,
+  getGithubAccess,
   listConfigMaps,
   logInUser,
   logOutUser,

+ 9 - 0
dashboard/src/shared/hardcodedNameDict.tsx

@@ -1,8 +1,11 @@
 const hardcodedNames: { [key: string]: string } = {
+  agones: "Agones System",
   docker: "Docker",
   "https-issuer": "HTTPS Issuer",
   metabase: "Metabase",
   mongodb: "MongoDB",
+  datadog: "Datadog",
+  "wallarm-ingress": "Wallarm Ingress",
   mysql: "MySQL",
   postgresql: "PostgreSQL",
   redis: "Redis",
@@ -25,6 +28,12 @@ const hardcodedIcons: { [key: string]: string } = {
     "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
   mongodb:
     "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
+  datadog:
+    "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
+  wallarm:
+    "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
+  agones:
+    "https://avatars.githubusercontent.com/u/36940055?v=4",
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   postgresql:
     "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",

+ 1 - 0
dashboard/webpack.config.js

@@ -55,6 +55,7 @@ module.exports = () => {
     },
     devServer: {
       historyApiFallback: true,
+      disableHostCheck: true,
     },
     plugins: [
       new HtmlWebpackPlugin({

+ 23 - 0
docs/guides/linking-github-account.md

@@ -0,0 +1,23 @@
+# Configuring Github Access
+
+> 🚧
+>
+> **Note:** Porter currently uses a Github OAuth App to authenticate and gain access to repositories. This mechanism will be phased out over the next few weeks to transition to the authentication method below. After this, all old applications will still work as intended but new applications will need to be authenticated through a GitHub App.
+
+Porter uses a GitHub App to authorize and gain access to your GitHub repositories. In order to be able to deploy applications through GitHub repositories, you must first authorize the Porter GitHub App to have access to them.
+
+## Step 1: Authorize the Porter Application
+
+On your home page, click select "Account Settings" through the dropdown on the top right and click "connect your GitHub account" in the popup that opens:
+
+![image](https://user-images.githubusercontent.com/25856165/125105942-0acb6d00-e0ad-11eb-8254-6660d390daea.png)
+
+Then, follow the GitHub steps to authorize the application.
+
+## Step 2: Install App in your repositories
+
+Once the Porter Github App is authorized, you can see a list of accounts and organization the Porter has access to through the same popup:
+
+![image](https://user-images.githubusercontent.com/25856165/125106692-ee7c0000-e0ad-11eb-9c79-44714f898aa5.png)
+
+You can install the app into more repositories by clicking on "Install Porter in more repositories". Note that if you are part of an organization, Porter will show you access to every repository that the app is installed into regardless of who it was installed by. So, if your organization does not grant you access to install applications, having an admin install the application into the appropriate repositories is sufficient.

+ 5 - 0
internal/config/config.go

@@ -41,6 +41,11 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
+	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
+	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
+	GithubAppName          string `env:"GITHUB_APP_NAME"`
+	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
+
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
 	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`

+ 8 - 6
internal/forms/helper_test.go

@@ -200,12 +200,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 1 - 0
internal/helm/agent.go

@@ -168,6 +168,7 @@ func (a *Agent) InstallChart(
 
 	cmd.ReleaseName = conf.Name
 	cmd.Namespace = conf.Namespace
+	cmd.Timeout = 300
 
 	if err := checkIfInstallable(conf.Chart); err != nil {
 		return nil, err

+ 33 - 0
internal/models/integrations/github_app.go

@@ -0,0 +1,33 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// GithubAppInstallation is an instance of the porter github app
+// we need to store account/installation id pairs in order to authenticate as the installation
+type GithubAppInstallation struct {
+	gorm.Model
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id" gorm:"unique"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+type GithubAppInstallationExternal struct {
+	ID uint `json:"id"`
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+func (r *GithubAppInstallation) Externalize() *GithubAppInstallationExternal {
+	return &GithubAppInstallationExternal{
+		ID:             r.ID,
+		AccountID:      r.AccountID,
+		InstallationID: r.InstallationID,
+	}
+}

+ 21 - 7
internal/models/integrations/oauth.go

@@ -14,10 +14,23 @@ const (
 	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 
+// SharedOAuthModel stores general fields needed for OAuth Integration
+type SharedOAuthModel struct {
+	// The ID issued to the client
+	ClientID []byte `json:"client-id"`
+
+	// The end-users's access token
+	AccessToken []byte `json:"access-token"`
+
+	// The end-user's refresh token
+	RefreshToken []byte `json:"refresh-token"`
+}
+
 // OAuthIntegration is an auth mechanism that uses oauth
 // https://tools.ietf.org/html/rfc6749
 type OAuthIntegration struct {
 	gorm.Model
+	SharedOAuthModel
 
 	// The name of the auth mechanism
 	Client OAuthIntegrationClient `json:"client"`
@@ -31,15 +44,16 @@ type OAuthIntegration struct {
 	// ------------------------------------------------------------------
 	// All fields encrypted before storage.
 	// ------------------------------------------------------------------
+}
 
-	// The ID issued to the client
-	ClientID []byte `json:"client-id"`
-
-	// The end-users's access token
-	AccessToken []byte `json:"access-token"`
+// GithubAppOAuthIntegration is the model used for storing github app oauth data
+// Unlike the above, this model is tied to a specific user, not a project
+type GithubAppOAuthIntegration struct {
+	gorm.Model
+	SharedOAuthModel
 
-	// The end-user's refresh token
-	RefreshToken []byte `json:"refresh-token"`
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
 }
 
 // OAuthIntegrationExternal is an OAuthIntegration to be shared over REST

+ 3 - 0
internal/models/user.go

@@ -12,6 +12,9 @@ type User struct {
 	Password      string `json:"password"`
 	EmailVerified bool   `json:"email_verified"`
 
+	// ID of oauth integration for github connection (optional)
+	GithubAppIntegrationID uint
+
 	// The github user id used for login (optional)
 	GithubUserID int64
 	GoogleUserID string

+ 24 - 0
internal/oauth/config.go

@@ -18,6 +18,13 @@ type Config struct {
 	BaseURL      string
 }
 
+// GithubAppConf is standard oauth2 config but it need to keeps track of the app name and webhook secret
+type GithubAppConf struct {
+	AppName       string
+	WebhookSecret string
+	oauth2.Config
+}
+
 func NewGithubClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,
@@ -31,6 +38,23 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewGithubAppClient(cfg *Config, name string, secret string) *GithubAppConf {
+	return &GithubAppConf{
+		AppName:       name,
+		WebhookSecret: secret,
+		Config: oauth2.Config{
+			ClientID:     cfg.ClientID,
+			ClientSecret: cfg.ClientSecret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  "https://github.com/login/oauth/authorize",
+				TokenURL: "https://github.com/login/oauth/access_token",
+			},
+			RedirectURL: cfg.BaseURL + "/api/oauth/github-app/callback",
+			Scopes:      cfg.Scopes,
+		},
+	}
+}
+
 func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,

+ 92 - 0
internal/repository/gorm/auth.go

@@ -1087,3 +1087,95 @@ func (repo *AWSIntegrationRepository) DecryptAWSIntegrationData(
 
 	return nil
 }
+
+// GithubAppInstallationRepository implements repository.GithubAppInstallationRepository
+type GithubAppInstallationRepository struct {
+	db *gorm.DB
+}
+
+// NewGithubAppInstallationRepository creates a new GithubAppInstallationRepository
+func NewGithubAppInstallationRepository(db *gorm.DB) repository.GithubAppInstallationRepository {
+	return &GithubAppInstallationRepository{db}
+}
+
+// CreateGithubAppInstallation creates a new GithubAppInstallation instance
+func (repo *GithubAppInstallationRepository) CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+// ReadGithubAppInstallation finds a GithubAppInstallation by id
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error) {
+	ret := &ints.GithubAppInstallation{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// ReadGithubAppInstallationByAccountID finds a GithubAppInstallation by an account ID
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error) {
+
+	ret := &ints.GithubAppInstallation{}
+
+	if err := repo.db.Where("account_id = ?", accountID).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// ReadGithubAppInstallationByAccountIDs finds all instances of GithubInstallations given a list of account IDs
+// note that if there is not Installation for a given ID, no error will be generated
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error) {
+	ret := make([]*ints.GithubAppInstallation, 0)
+
+	if err := repo.db.Where("account_id IN ?", accountIDs).Find(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// DeleteGithubAppInstallationByAccountID deletes a GithubAppInstallation given an account ID
+// note that this deletion is done with db.Unscoped(), so the record is actually deleted
+func (repo *GithubAppInstallationRepository) DeleteGithubAppInstallationByAccountID(accountID int64) error {
+	if err := repo.db.Unscoped().Where("account_id = ?", accountID).Delete(&ints.GithubAppInstallation{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// GithubAppOAuthIntegrationRepository implements repository.GithubAppOAuthIntegrationRepository
+type GithubAppOAuthIntegrationRepository struct {
+	db *gorm.DB
+}
+
+// NewGithubAppOAuthIntegrationRepository creates a GithubAppOAuthIntegrationRepository
+func NewGithubAppOAuthIntegrationRepository(db *gorm.DB) repository.GithubAppOAuthIntegrationRepository {
+	return &GithubAppOAuthIntegrationRepository{db}
+}
+
+// CreateGithubAppOAuthIntegration creates a new GithubAppOAuthIntegration
+func (repo *GithubAppOAuthIntegrationRepository) CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+// ReadGithubAppOauthIntegration finds a GithubAppOauthIntegration by id
+func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error) {
+	ret := &ints.GithubAppOAuthIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}

+ 16 - 12
internal/repository/gorm/auth_test.go

@@ -285,12 +285,14 @@ func TestCreateOAuthIntegration(t *testing.T) {
 	defer cleanup(tester, t)
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	expOAuth := *oauth
@@ -345,12 +347,14 @@ func TestListOAuthIntegrationsByProjectID(t *testing.T) {
 
 	// make sure data is correct
 	expOAuth := ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth := oauths[0]

+ 9 - 6
internal/repository/gorm/helpers_test.go

@@ -72,6 +72,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
 	)
 
 	if err != nil {
@@ -272,12 +273,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 22 - 20
internal/repository/gorm/repository.go

@@ -9,25 +9,27 @@ import (
 // gorm.DB for querying the database
 func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 	return &repository.Repository{
-		User:             NewUserRepository(db),
-		Session:          NewSessionRepository(db),
-		Project:          NewProjectRepository(db),
-		Release:          NewReleaseRepository(db),
-		GitRepo:          NewGitRepoRepository(db, key),
-		Cluster:          NewClusterRepository(db, key),
-		HelmRepo:         NewHelmRepoRepository(db, key),
-		Registry:         NewRegistryRepository(db, key),
-		Infra:            NewInfraRepository(db, key),
-		GitActionConfig:  NewGitActionConfigRepository(db),
-		Invite:           NewInviteRepository(db),
-		AuthCode:         NewAuthCodeRepository(db),
-		DNSRecord:        NewDNSRecordRepository(db),
-		PWResetToken:     NewPWResetTokenRepository(db),
-		KubeIntegration:  NewKubeIntegrationRepository(db, key),
-		BasicIntegration: NewBasicIntegrationRepository(db, key),
-		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
-		OAuthIntegration: NewOAuthIntegrationRepository(db, key),
-		GCPIntegration:   NewGCPIntegrationRepository(db, key),
-		AWSIntegration:   NewAWSIntegrationRepository(db, key),
+		User:                      NewUserRepository(db),
+		Session:                   NewSessionRepository(db),
+		Project:                   NewProjectRepository(db),
+		Release:                   NewReleaseRepository(db),
+		GitRepo:                   NewGitRepoRepository(db, key),
+		Cluster:                   NewClusterRepository(db, key),
+		HelmRepo:                  NewHelmRepoRepository(db, key),
+		Registry:                  NewRegistryRepository(db, key),
+		Infra:                     NewInfraRepository(db, key),
+		GitActionConfig:           NewGitActionConfigRepository(db),
+		Invite:                    NewInviteRepository(db),
+		AuthCode:                  NewAuthCodeRepository(db),
+		DNSRecord:                 NewDNSRecordRepository(db),
+		PWResetToken:              NewPWResetTokenRepository(db),
+		KubeIntegration:           NewKubeIntegrationRepository(db, key),
+		BasicIntegration:          NewBasicIntegrationRepository(db, key),
+		OIDCIntegration:           NewOIDCIntegrationRepository(db, key),
+		OAuthIntegration:          NewOAuthIntegrationRepository(db, key),
+		GCPIntegration:            NewGCPIntegrationRepository(db, key),
+		AWSIntegration:            NewAWSIntegrationRepository(db, key),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 	}
 }

+ 16 - 0
internal/repository/integrations.go

@@ -37,6 +37,13 @@ type OAuthIntegrationRepository interface {
 	UpdateOAuthIntegration(am *ints.OAuthIntegration) (*ints.OAuthIntegration, error)
 }
 
+// GithubAppOAuthIntegrationRepository represents the set of queries on the oauth
+// mechanism
+type GithubAppOAuthIntegrationRepository interface {
+	CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
+	ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error)
+}
+
 // AWSIntegrationRepository represents the set of queries on the AWS auth
 // mechanism
 type AWSIntegrationRepository interface {
@@ -53,3 +60,12 @@ type GCPIntegrationRepository interface {
 	ReadGCPIntegration(id uint) (*ints.GCPIntegration, error)
 	ListGCPIntegrationsByProjectID(projectID uint) ([]*ints.GCPIntegration, error)
 }
+
+// GithubAppInstallationRepository represents the set of queries for github app installations
+type GithubAppInstallationRepository interface {
+	CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error)
+	DeleteGithubAppInstallationByAccountID(accountID int64) error
+}

+ 121 - 2
internal/repository/memory/auth.go

@@ -2,7 +2,6 @@ package test
 
 import (
 	"errors"
-
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 
@@ -220,7 +219,7 @@ func (repo *OAuthIntegrationRepository) CreateOAuthIntegration(
 	am *ints.OAuthIntegration,
 ) (*ints.OAuthIntegration, error) {
 	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
+		return nil, errors.New("cannot write database")
 	}
 
 	repo.oIntegrations = append(repo.oIntegrations, am)
@@ -427,3 +426,123 @@ func (repo *GCPIntegrationRepository) ListGCPIntegrationsByProjectID(
 
 	return res, nil
 }
+
+// GithubAppInstallationRepository implements repository.GithubAppInstallationRepository
+type GithubAppInstallationRepository struct {
+	canQuery               bool
+	githubAppInstallations []*ints.GithubAppInstallation
+}
+
+func NewGithubAppInstallationRepository(canQuery bool) repository.GithubAppInstallationRepository {
+	return &GithubAppInstallationRepository{
+		canQuery,
+		[]*ints.GithubAppInstallation{},
+	}
+}
+
+func (repo *GithubAppInstallationRepository) CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.githubAppInstallations = append(repo.githubAppInstallations, am)
+	am.ID = uint(len(repo.githubAppInstallations))
+
+	return am, nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	if int(id-1) >= len(repo.githubAppInstallations) || repo.githubAppInstallations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return repo.githubAppInstallations[int(id-1)], nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error) {
+
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	for _, installation := range repo.githubAppInstallations {
+		if installation != nil && installation.AccountID == accountID {
+			return installation, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error) {
+
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	ret := make([]*ints.GithubAppInstallation, 0)
+
+	for _, installation := range repo.githubAppInstallations {
+		// O(n^2) can be made into O(n) if this is too slow
+		for _, id := range accountIDs {
+			if installation.AccountID == id {
+				ret = append(ret, installation)
+			}
+		}
+	}
+
+	return ret, nil
+}
+
+func (repo *GithubAppInstallationRepository) DeleteGithubAppInstallationByAccountID(accountID int64) error {
+	if !repo.canQuery {
+		return errors.New("cannot write database")
+	}
+
+	for i, installation := range repo.githubAppInstallations {
+		if installation != nil && installation.AccountID == accountID {
+			repo.githubAppInstallations[i] = nil
+		}
+	}
+
+	return nil
+}
+
+type GithubAppOAuthIntegrationRepository struct {
+	canQuery                   bool
+	githubAppOauthIntegrations []*ints.GithubAppOAuthIntegration
+}
+
+func NewGithubAppOAuthIntegrationRepository(canQuery bool) repository.GithubAppOAuthIntegrationRepository {
+	return &GithubAppOAuthIntegrationRepository{
+		canQuery,
+		[]*ints.GithubAppOAuthIntegration{},
+	}
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.githubAppOauthIntegrations = append(repo.githubAppOauthIntegrations, am)
+	am.ID = uint(len(repo.githubAppOauthIntegrations))
+
+	return am, nil
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	if int(id-1) >= len(repo.githubAppOauthIntegrations) || repo.githubAppOauthIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return repo.githubAppOauthIntegrations[int(id-1)], nil
+}

+ 19 - 17
internal/repository/memory/repository.go

@@ -8,22 +8,24 @@ import (
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool) *repository.Repository {
 	return &repository.Repository{
-		User:             NewUserRepository(canQuery),
-		Session:          NewSessionRepository(canQuery),
-		Project:          NewProjectRepository(canQuery),
-		Cluster:          NewClusterRepository(canQuery),
-		HelmRepo:         NewHelmRepoRepository(canQuery),
-		Registry:         NewRegistryRepository(canQuery),
-		GitRepo:          NewGitRepoRepository(canQuery),
-		Invite:           NewInviteRepository(canQuery),
-		AuthCode:         NewAuthCodeRepository(canQuery),
-		DNSRecord:        NewDNSRecordRepository(canQuery),
-		PWResetToken:     NewPWResetTokenRepository(canQuery),
-		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
-		BasicIntegration: NewBasicIntegrationRepository(canQuery),
-		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),
-		OAuthIntegration: NewOAuthIntegrationRepository(canQuery),
-		GCPIntegration:   NewGCPIntegrationRepository(canQuery),
-		AWSIntegration:   NewAWSIntegrationRepository(canQuery),
+		User:                      NewUserRepository(canQuery),
+		Session:                   NewSessionRepository(canQuery),
+		Project:                   NewProjectRepository(canQuery),
+		Cluster:                   NewClusterRepository(canQuery),
+		HelmRepo:                  NewHelmRepoRepository(canQuery),
+		Registry:                  NewRegistryRepository(canQuery),
+		GitRepo:                   NewGitRepoRepository(canQuery),
+		Invite:                    NewInviteRepository(canQuery),
+		AuthCode:                  NewAuthCodeRepository(canQuery),
+		DNSRecord:                 NewDNSRecordRepository(canQuery),
+		PWResetToken:              NewPWResetTokenRepository(canQuery),
+		KubeIntegration:           NewKubeIntegrationRepository(canQuery),
+		BasicIntegration:          NewBasicIntegrationRepository(canQuery),
+		OIDCIntegration:           NewOIDCIntegrationRepository(canQuery),
+		OAuthIntegration:          NewOAuthIntegrationRepository(canQuery),
+		GCPIntegration:            NewGCPIntegrationRepository(canQuery),
+		AWSIntegration:            NewAWSIntegrationRepository(canQuery),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(canQuery),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 	}
 }

+ 22 - 20
internal/repository/repository.go

@@ -2,24 +2,26 @@ package repository
 
 // Repository collects the repositories for each model
 type Repository struct {
-	User             UserRepository
-	Project          ProjectRepository
-	Release          ReleaseRepository
-	Session          SessionRepository
-	GitRepo          GitRepoRepository
-	Cluster          ClusterRepository
-	HelmRepo         HelmRepoRepository
-	Registry         RegistryRepository
-	Infra            InfraRepository
-	GitActionConfig  GitActionConfigRepository
-	Invite           InviteRepository
-	AuthCode         AuthCodeRepository
-	DNSRecord        DNSRecordRepository
-	PWResetToken     PWResetTokenRepository
-	KubeIntegration  KubeIntegrationRepository
-	BasicIntegration BasicIntegrationRepository
-	OIDCIntegration  OIDCIntegrationRepository
-	OAuthIntegration OAuthIntegrationRepository
-	GCPIntegration   GCPIntegrationRepository
-	AWSIntegration   AWSIntegrationRepository
+	User                      UserRepository
+	Project                   ProjectRepository
+	Release                   ReleaseRepository
+	Session                   SessionRepository
+	GitRepo                   GitRepoRepository
+	Cluster                   ClusterRepository
+	HelmRepo                  HelmRepoRepository
+	Registry                  RegistryRepository
+	Infra                     InfraRepository
+	GitActionConfig           GitActionConfigRepository
+	Invite                    InviteRepository
+	AuthCode                  AuthCodeRepository
+	DNSRecord                 DNSRecordRepository
+	PWResetToken              PWResetTokenRepository
+	KubeIntegration           KubeIntegrationRepository
+	BasicIntegration          BasicIntegrationRepository
+	OIDCIntegration           OIDCIntegrationRepository
+	OAuthIntegration          OAuthIntegrationRepository
+	GCPIntegration            GCPIntegrationRepository
+	AWSIntegration            AWSIntegrationRepository
+	GithubAppInstallation     GithubAppInstallationRepository
+	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 }

+ 10 - 0
server/api/api.go

@@ -83,6 +83,7 @@ type App struct {
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
+	GithubAppConf     *oauth.GithubAppConf
 	DOConf            *oauth2.Config
 	GoogleUserConf    *oauth2.Config
 
@@ -169,6 +170,15 @@ func New(conf *AppConfig) (*App, error) {
 		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
 	}
 
+	if sc.GithubAppClientID != "" && sc.GithubAppClientSecret != "" && sc.GithubAppName != "" && sc.GithubAppWebhookSecret != "" {
+		app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
+			ClientID:     sc.GithubAppClientID,
+			ClientSecret: sc.GithubAppClientSecret,
+			Scopes:       []string{"read:user"},
+			BaseURL:      sc.ServerURL,
+		}, sc.GithubAppName, sc.GithubAppWebhookSecret)
+	}
+
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
 		app.Capabilities.GoogleLogin = true
 

+ 210 - 0
server/api/integration_handler.go

@@ -1,10 +1,22 @@
 package api
 
 import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
+	"fmt"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+	"io/ioutil"
 	"net/http"
 	"net/url"
+	"sort"
 	"strconv"
+	"strings"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
@@ -377,3 +389,201 @@ func (app *App) HandleListProjectOAuthIntegrations(w http.ResponseWriter, r *htt
 		return
 	}
 }
+
+// verifySignature verifies a signature based on hmac protocal
+// https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
+func verifySignature(secret []byte, signature string, body []byte) bool {
+	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
+		return false
+	}
+
+	actual := make([]byte, 32)
+	hex.Decode(actual, []byte(signature[7:]))
+
+	computed := hmac.New(sha256.New, secret)
+	computed.Write(body)
+
+	return hmac.Equal(computed.Sum(nil), actual)
+}
+
+func (app *App) HandleGithubAppEvent(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// verify webhook secret
+	signature := r.Header.Get("X-Hub-Signature-256")
+
+	if !verifySignature([]byte(app.GithubAppConf.WebhookSecret), signature, payload) {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	switch e := event.(type) {
+	case *github.InstallationEvent:
+		if *e.Action == "created" {
+			_, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil && err == gorm.ErrRecordNotFound {
+				// insert account/installation pair into database
+				_, err := app.Repo.GithubAppInstallation.CreateGithubAppInstallation(&ints.GithubAppInstallation{
+					AccountID:      *e.Installation.Account.ID,
+					InstallationID: *e.Installation.ID,
+				})
+
+				if err != nil {
+					app.handleErrorInternal(err, w)
+				}
+
+				return
+			} else if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+		}
+		if *e.Action == "deleted" {
+			err := app.Repo.GithubAppInstallation.DeleteGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+		}
+	}
+
+}
+
+// HandleGithubAppAuthorize starts the oauth2 flow for a project repo request.
+func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubAppConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubAppInstall redirects the user to the Porter github app installation page
+func (app *App) HandleGithubAppInstall(w http.ResponseWriter, r *http.Request) {
+	http.Redirect(w, r, fmt.Sprintf("https://github.com/apps/%s/installations/new", app.GithubAppConf.AppName), 302)
+}
+
+// HandleListGithubAppAccessResp is the response returned by HandleListGithubAppAccess
+type HandleListGithubAppAccessResp struct {
+	HasAccess bool     `json:"has_access"`
+	LoginName string   `json:"username,omitempty"`
+	Accounts  []string `json:"accounts,omitempty"`
+}
+
+// HandleListGithubAppAccess provides basic info on if the current user is authenticated through the GitHub app
+// and what accounts/organizations their authentication has access to
+func (app *App) HandleListGithubAppAccess(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.getGithubUserTokenFromRequest(r)
+
+	if err != nil {
+		res := HandleListGithubAppAccessResp{
+			HasAccess: false,
+		}
+		json.NewEncoder(w).Encode(res)
+		return
+	}
+
+	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	res := HandleListGithubAppAccessResp{
+		HasAccess: true,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+		if err != nil {
+			res := HandleListGithubAppAccessResp{
+				HasAccess: false,
+			}
+			json.NewEncoder(w).Encode(res)
+			return
+		}
+
+		for _, org := range orgs {
+			res.Accounts = append(res.Accounts, *org.Login)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	AuthUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	res.LoginName = *AuthUser.Login
+
+	// check if user has app installed in their account
+	Installation, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountID(*AuthUser.ID)
+
+	if err != nil && err != gorm.ErrRecordNotFound {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if Installation != nil {
+		res.Accounts = append(res.Accounts, *AuthUser.Login)
+	}
+
+	sort.Strings(res.Accounts)
+
+	json.NewEncoder(w).Encode(res)
+}
+
+// getGithubUserTokenFromRequest
+func (app *App) getGithubUserTokenFromRequest(r *http.Request) (*oauth2.Token, error) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		return nil, err
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	oauthInt, err := app.Repo.GithubAppOAuthIntegration.ReadGithubAppOauthIntegration(user.GithubAppIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		TokenType:    "Bearer",
+	}, nil
+}

+ 7 - 5
server/api/oauth_do_handler.go

@@ -76,11 +76,13 @@ func (app *App) HandleDOOAuthCallback(w http.ResponseWriter, r *http.Request) {
 	projID, _ := session.Values["project_id"].(uint)
 
 	oauthInt := &integrations.OAuthIntegration{
-		Client:       integrations.OAuthDigitalOcean,
-		UserID:       userID,
-		ProjectID:    projID,
-		AccessToken:  []byte(token.AccessToken),
-		RefreshToken: []byte(token.RefreshToken),
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		Client:    integrations.OAuthDigitalOcean,
+		UserID:    userID,
+		ProjectID: projID,
 	}
 
 	// create the oauth integration first

+ 98 - 5
server/api/oauth_github_handler.go

@@ -269,11 +269,13 @@ func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.
 	}
 
 	oauthInt := &integrations.OAuthIntegration{
-		Client:       integrations.OAuthGithub,
-		UserID:       userID,
-		ProjectID:    projectID,
-		AccessToken:  []byte(tok.AccessToken),
-		RefreshToken: []byte(tok.RefreshToken),
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(tok.AccessToken),
+			RefreshToken: []byte(tok.RefreshToken),
+		},
+		Client:    integrations.OAuthGithub,
+		UserID:    userID,
+		ProjectID: projectID,
 	}
 
 	// create the oauth integration first
@@ -294,3 +296,94 @@ func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.
 
 	return err
 }
+
+// HandleGithubAppOAuthCallback handles the oauth callback from the GitHub app oauth flow
+// this basically just involves generating an access token and then linking it to the current user
+func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+		return
+	}
+
+	token, err := app.GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil || !token.Valid() {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+		return
+	}
+
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	oauthInt := &integrations.GithubAppOAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		UserID: user.ID,
+	}
+
+	oauthInt, err = app.Repo.GithubAppOAuthIntegration.CreateGithubAppOAuthIntegration(oauthInt)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user.GithubAppIntegrationID = oauthInt.ID
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

+ 32 - 0
server/router/router.go

@@ -176,6 +176,32 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/integrations/github-app/webhook",
+				requestlog.NewHandler(a.HandleGithubAppEvent, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/authorize",
+				requestlog.NewHandler(a.HandleGithubAppAuthorize, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/install",
+				requestlog.NewHandler(a.HandleGithubAppInstall, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/access",
+				auth.BasicAuthenticate(
+					requestlog.NewHandler(a.HandleListGithubAppAccess, l),
+				),
+			)
+
 			// /api/templates routes
 			r.Method(
 				"GET",
@@ -215,6 +241,12 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/github-app/callback",
+				requestlog.NewHandler(a.HandleGithubAppOAuthCallback, l),
+			)
+
 			r.Method(
 				"GET",
 				"/oauth/login/google",