From 00e69b37331dd3bbf9908d9a2e4d308a64b5f25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:43:12 +0200 Subject: [PATCH] feat(git): GitHub App manifest self-provisioning + app-level webhook (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(branding): white-label — public /branding, admin write, shell theming app_settings branding.* keys (product name, logo/favicon URLs, accent color, docs/support links). Public GET /branding so login and shell theme pre-auth; admin writes via PUT /settings. useBranding() applies document.title, accent CSS var and favicon; BrandWordmark replaces hardcoded wordmarks; admin Platform settings gain a Branding form. MCP containr_branding_get + settings_update. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(invites): team invite links — admin create/revoke, public accept Admins mint single-use invite links (sha256-hashed token at rest, optional email binding, configurable TTL). Public endpoints let the accept page validate the link and register — working even while public signup is disabled. - migration + sqlc queries/model for user_invites - POST /admin/invites (returns one-time token+url), GET list, DELETE revoke - GET /auth/invites/:token public validation, POST /auth/accept-invite - CLI containr invites list|create|revoke; MCP invites tools - /auth/accept-invite page (expired/used/invalid states, bound-email lock) - Admin page Team invites section: create, copy link, revoke - OpenAPI + generated types + CLI guide Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(admin): impersonate user — 15-min token, audit-logged POST /admin/users/:id/impersonate mints a short-lived bearer token (impersonated_by claim) for support/debugging. Admin accounts cannot be impersonated; every mint is audit-logged with actor + target. - CLI: containr admin impersonate (works via CONTAINR_TOKEN) - MCP: containr_admin_impersonate - Admin UI: per-user Impersonate action → token modal with usage hint - OpenAPI + generated types Verified live: token resolves to target, admin target 403, audit row. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(git): GitHub App manifest self-provisioning + app-level webhook Admins create the instance GitHub App in-browser instead of hand- setting GITHUB_APP_* env vars: - POST /admin/git/github-app/manifest builds the manifest + URL; the frontend auto-submits it to github.com - /git/github-app/callback page exchanges the code via POST /admin/git/github-app/convert; credentials persist in app_settings (precedence over env) - GET /admin/git/github-app reports configured/source - POST /api/git/github-app/webhook — single app-level push receiver, HMAC-verified, dispatches via extracted dispatchPushToServices - install-url + installation-token paths resolve settings→env - CLI: containr admin github-app status|manifest|convert; MCP tools - Git providers UI shows "Set up GitHub App" for admins when unconfigured Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 17 +- app/backend/internal/api/admin.go | 51 +++ app/backend/internal/api/auth.go | 13 + app/backend/internal/api/git.go | 8 +- app/backend/internal/api/git_receiver.go | 36 +- app/backend/internal/api/github_app.go | 310 ++++++++++++++++++ app/backend/internal/api/routes.go | 10 + app/backend/internal/cli/commands/admin.go | 86 ++++- app/backend/internal/mcpserver/server.go | 4 + app/frontend/src/app/App.tsx | 2 + .../src/features/admin/pages/AdminPage.tsx | 67 ++++ app/frontend/src/features/secondary/pages.tsx | 123 ++++++- app/frontend/src/generated/api-types.ts | 275 ++++++++++++++++ app/frontend/src/lib/api-client.ts | 42 +++ docs/api/openapi.yaml | 165 ++++++++++ docs/guides/CLI.md | 3 + 16 files changed, 1184 insertions(+), 28 deletions(-) create mode 100644 app/backend/internal/api/github_app.go diff --git a/ROADMAP2.md b/ROADMAP2.md index 00ffd1a..98bbe8d 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -310,10 +310,19 @@ and wired refs, one click or one CLI call. `POST /auth/accept-invite` registers — works while public signup is closed. `/auth/accept-invite` page, admin manage/revoke UI, `containr invites` CLI + MCP tools. -- [ ] **Impersonate user** — admin support tool, audit-logged. -- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest` - returns manifest + redirect; instance self-provisions its App - (dflow pattern — removes the env-setup barrier). +- [x] **Impersonate user** — `POST /admin/users/:id/impersonate` + mints a 15-minute bearer token (`impersonated_by` claim), + audit-logged; admin accounts are not impersonatable. CLI + `admin impersonate`, MCP tool, admin UI token modal. Remaining: + in-browser session swap needs a session bridge. +- [x] **GitHub App manifest flow** — `POST /admin/git/github-app/manifest` + returns manifest + submission URL; the callback page exchanges + `code` via `POST /admin/git/github-app/convert` and credentials + persist in `app_settings` (overriding `GITHUB_APP_*` env). New + public `POST /api/git/github-app/webhook` receives App-level push + events (HMAC-verified) and enqueues matching services. Settings→env + fallback wired into install-URL + installation-token paths. + `containr admin github-app` CLI + MCP + OpenAPI. - [ ] **fork-sync trigger** — accept repo-sync events on webhook receiver. - [ ] **Dynamic filter framework** — port dflow's `filter.utils` diff --git a/app/backend/internal/api/admin.go b/app/backend/internal/api/admin.go index 51cebf2..da9df98 100644 --- a/app/backend/internal/api/admin.go +++ b/app/backend/internal/api/admin.go @@ -246,6 +246,57 @@ func handleAdminSetUserAdmin(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "User updated"}) } +// POST /admin/users/:id/impersonate — mints a 15-minute bearer token acting +// as the target user. Admin-only; admin accounts cannot be impersonated +// (would blur audit attribution). Every use is audit-logged at mint time; +// token carries `impersonated_by` for downstream attribution. +func handleAdminImpersonateUser(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + targetID := strings.TrimSpace(c.Param("id")) + if _, err := uuid.Parse(targetID); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid user id") + return + } + + var email, name string + var isAdmin bool + err := db.QueryRowContext(c.Request.Context(), + `SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID). + Scan(&email, &name, &isAdmin) + if err != nil { + respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found") + return + } + if isAdmin { + respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated") + return + } + actorID := optionalUserID(c) + if actorID == targetID { + respondError(c, http.StatusBadRequest, "VALIDATION", "cannot impersonate yourself") + return + } + + expiresAt := time.Now().UTC().Add(15 * time.Minute) + jwtSecret := c.MustGet("jwt_secret").(string) + token, err := generateImpersonationJWT(targetID, email, actorID, jwtSecret, expiresAt) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token") + return + } + + LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{ + "actor_id": actorID, + "target_email": email, + "expires_at": expiresAt.Format(time.RFC3339), + }) + c.JSON(http.StatusOK, gin.H{ + "token": token, + "expires_at": expiresAt.Format(time.RFC3339), + "user": gin.H{"id": targetID, "email": email, "name": name}, + }) +} + func handleAdminSetProjectApproval(c *gin.Context) { db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index eba13b5..1e51f61 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -546,6 +546,19 @@ func generateJWT(userID, email, secret string) (string, error) { return token.SignedString([]byte(secret)) } +// generateImpersonationJWT mints a short-lived token acting as `userID`, +// tagged with the admin who minted it so downstream callers can attribute +// actions taken while impersonating. +func generateImpersonationJWT(userID, email, actorID, secret string, expiresAt time.Time) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": userID, + "email": email, + "impersonated_by": actorID, + "exp": expiresAt.Unix(), + }) + return token.SignedString([]byte(secret)) +} + // ValidateJWT validates a JWT token and returns the claims func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { diff --git a/app/backend/internal/api/git.go b/app/backend/internal/api/git.go index a8c6e30..0ac323a 100644 --- a/app/backend/internal/api/git.go +++ b/app/backend/internal/api/git.go @@ -133,7 +133,9 @@ func handleGetGitProviders(c *gin.Context) { } func handleGetGitHubAppInstallURL(c *gin.Context) { - slug := strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG")) + db, _ := c.Get("db") + d, _ := db.(*database.DB) + slug := strings.TrimSpace(settingValue(d, settingGitHubAppSlug, "GITHUB_APP_SLUG", "")) if slug == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"}) return @@ -1073,8 +1075,8 @@ func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGit } func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) { - appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) - privateKeyPEM := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) + appID := platformSetting(settingGitHubAppID, "GITHUB_APP_ID") + privateKeyPEM := platformSetting(settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY") if appID == "" || privateKeyPEM == "" { return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured") } diff --git a/app/backend/internal/api/git_receiver.go b/app/backend/internal/api/git_receiver.go index a019f28..b156d2c 100644 --- a/app/backend/internal/api/git_receiver.go +++ b/app/backend/internal/api/git_receiver.go @@ -113,6 +113,26 @@ func handleGitWebhookPush(c *gin.Context) { return } + engineValue, _ := c.Get("deployment_engine") + engine, _ := engineValue.(*deployment.DeploymentEngine) + + enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, repoUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) + return + } + + c.JSON(http.StatusAccepted, gin.H{ + "received": true, + "branch": branch, + "deployments": enqueued, + }) +} + +// dispatchPushToServices enqueues a deployment for every service tracking +// the pushed repo+branch. Shared by the per-repo webhook receiver and the +// GitHub App webhook endpoint. +func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) { rows, err := db.Query(` SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, @@ -121,8 +141,7 @@ func handleGitWebhookPush(c *gin.Context) { WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`, branch, cloneURL, fullName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) - return + return 0, err } defer rows.Close() @@ -134,15 +153,11 @@ func handleGitWebhookPush(c *gin.Context) { &s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory, &s.CreatedAt, &s.UpdatedAt, ); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) - return + return 0, err } services = append(services, s) } - engineValue, _ := c.Get("deployment_engine") - engine, _ := engineValue.(*deployment.DeploymentEngine) - enqueued := 0 for _, service := range services { now := time.Now() @@ -199,12 +214,7 @@ func handleGitWebhookPush(c *gin.Context) { } enqueued++ } - - c.JSON(http.StatusAccepted, gin.H{ - "received": true, - "branch": branch, - "deployments": enqueued, - }) + return enqueued, nil } // verifyGitWebhookSignature checks the push signature per provider: diff --git a/app/backend/internal/api/github_app.go b/app/backend/internal/api/github_app.go new file mode 100644 index 0000000..6cb2456 --- /dev/null +++ b/app/backend/internal/api/github_app.go @@ -0,0 +1,310 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "containr/internal/database" + "containr/internal/deployment" + + "github.com/gin-gonic/gin" +) + +// GitHub App self-provisioning (manifest flow): an admin POSTs the manifest +// to github.com/settings/apps/new, GitHub redirects back with `code`, and the +// code is exchanged at /app-manifests/{code}/conversions for real credentials +// stored in app_settings. Values stored there take precedence over the +// GITHUB_APP_* env vars, so the flow removes the manual env-setup barrier. + +const ( + settingGitHubAppID = "github_app.id" + settingGitHubAppSlug = "github_app.slug" + settingGitHubAppName = "github_app.name" + settingGitHubAppClientID = "github_app.client_id" + settingGitHubAppClientSecret = "github_app.client_secret" + settingGitHubAppPrivateKey = "github_app.private_key" + settingGitHubAppWebhookSecret = "github_app.webhook_secret" +) + +// platformSetting resolves a setting row first, then env, then empty. +func platformSetting(key, envKey string) string { + return strings.TrimSpace(settingValue(GetAuditDB(), key, envKey, "")) +} + +func githubAppConfigured(db *database.DB) bool { + return strings.TrimSpace(settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", "")) != "" && + strings.TrimSpace(settingValue(db, settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY", "")) != "" +} + +// POST /admin/git/github-app/manifest — builds the GitHub App manifest the +// browser POSTs to github.com. Response carries the manifest as a JSON string +// (the form submits it verbatim) plus the destination URL. +func handleAdminGitHubAppManifest(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url" binding:"required"` + Name string `json:"name"` + Organization string `json:"organization"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_BODY", "base_url is required") + return + } + base := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/") + if !strings.HasPrefix(base, "https://") && + !strings.HasPrefix(base, "http://localhost") && + !strings.HasPrefix(base, "http://127.0.0.1") { + respondError(c, http.StatusBadRequest, "VALIDATION", "base_url must be an https URL (localhost http allowed)") + return + } + + stateBuf := make([]byte, 16) + if _, err := rand.Read(stateBuf); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate state") + return + } + state := hex.EncodeToString(stateBuf) + + name := strings.TrimSpace(req.Name) + if name == "" { + db, _ := c.Get("db") + if d, ok := db.(*database.DB); ok { + name = strings.TrimSpace(settingValue(d, settingBrandProductName, "BRAND_NAME", "")) + } + if name == "" { + name = "Containr" + } + name = fmt.Sprintf("%s-%s", name, time.Now().UTC().Format("2006-01-02")) + } + + callbackURL := base + "/git/github-app/callback" + manifest, err := json.Marshal(map[string]interface{}{ + "name": name, + "url": base, + "hook_attributes": map[string]interface{}{ + "url": base + "/api/git/github-app/webhook", + "active": true, + }, + "redirect_url": callbackURL, + "callback_urls": []string{callbackURL}, + "public": false, + "default_permissions": map[string]string{ + "contents": "read", + "metadata": "read", + "pull_requests": "write", + }, + "default_events": []string{"push", "pull_request"}, + }) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build manifest") + return + } + + org := strings.TrimSpace(req.Organization) + createURL := fmt.Sprintf("https://github.com/settings/apps/new?state=%s", state) + if org != "" { + createURL = fmt.Sprintf("https://github.com/organizations/%s/settings/apps/new?state=%s", org, state) + } + + c.JSON(http.StatusOK, gin.H{ + "url": createURL, + "manifest": string(manifest), + "state": state, + }) +} + +// POST /admin/git/github-app/convert — exchanges the `code` GitHub appends to +// the manifest redirect for the real app credentials, then stores them. +func handleAdminGitHubAppConvert(c *gin.Context) { + var req struct { + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_BODY", "code is required") + return + } + + apiBase := strings.TrimSuffix(strings.TrimSpace(os.Getenv("GITHUB_APP_BASE_URL")), "/") + if apiBase == "" { + apiBase = "https://api.github.com" + } + request, err := http.NewRequest(http.MethodPost, + fmt.Sprintf("%s/app-manifests/%s/conversions", apiBase, strings.TrimSpace(req.Code)), nil) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build conversion request") + return + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(request) + if err != nil { + respondError(c, http.StatusBadGateway, "UPSTREAM", "failed to reach GitHub") + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + respondError(c, http.StatusBadGateway, "UPSTREAM", + fmt.Sprintf("GitHub conversion failed (%d)", resp.StatusCode)) + return + } + + var app struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + PEM string `json:"pem"` + WebhookSecret string `json:"webhook_secret"` + } + if err := json.Unmarshal(body, &app); err != nil || app.ID == 0 { + respondError(c, http.StatusBadGateway, "UPSTREAM", "invalid conversion response") + return + } + + db := c.MustGet("db").(*database.DB) + for _, kv := range []struct { + key string + value string + secret bool + }{ + {settingGitHubAppID, fmt.Sprintf("%d", app.ID), false}, + {settingGitHubAppSlug, app.Slug, false}, + {settingGitHubAppName, app.Name, false}, + {settingGitHubAppClientID, app.ClientID, false}, + {settingGitHubAppClientSecret, app.ClientSecret, true}, + {settingGitHubAppPrivateKey, app.PEM, true}, + {settingGitHubAppWebhookSecret, app.WebhookSecret, true}, + } { + if err := setSetting(db, kv.key, kv.value, kv.secret); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to store app credentials") + return + } + } + + LogAuditWithRequest(c, "github_app", fmt.Sprintf("%d", app.ID), "provision", + map[string]interface{}{"name": app.Name, "slug": app.Slug}) + c.JSON(http.StatusOK, gin.H{ + "app_id": app.ID, + "slug": app.Slug, + "name": app.Name, + }) +} + +// GET /admin/git/github-app — provisioning status for the settings UI. +func handleAdminGetGitHubApp(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + var stored string + source := "none" + if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" { + source = "settings" + } else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" { + source = "env" + } + c.JSON(http.StatusOK, gin.H{ + "configured": githubAppConfigured(db), + "source": source, + "app_id": settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", ""), + "slug": settingValue(db, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""), + "name": settingValue(db, settingGitHubAppName, "", ""), + }) +} + +// POST /api/git/github-app/webhook — the single App-level webhook all +// installed repos deliver to. Verified against the provisioned webhook +// secret, then dispatched to matching services. +func handleGitHubAppWebhook(c *gin.Context) { + dbValue, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + db := dbValue.(*database.DB) + + secret := strings.TrimSpace(settingValue(db, settingGitHubAppWebhookSecret, "GITHUB_APP_WEBHOOK_SECRET", "")) + if secret == "" { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "GitHub App webhook not configured"}) + return + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"}) + return + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := mac.Sum(nil) + sig := strings.TrimPrefix(c.GetHeader("X-Hub-Signature-256"), "sha256=") + decoded, err := hex.DecodeString(sig) + if err != nil || len(decoded) == 0 || subtle.ConstantTimeCompare(decoded, expected) != 1 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"}) + return + } + + event := c.GetHeader("X-GitHub-Event") + if event != "push" { + c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "event " + event}) + return + } + + var payload struct { + Ref string `json:"ref"` + After string `json:"after"` + Repository struct { + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + } `json:"repository"` + HeadCommit struct { + ID string `json:"id"` + } `json:"head_commit"` + } + if err := json.Unmarshal(body, &payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + if !strings.HasPrefix(payload.Ref, "refs/heads/") { + c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"}) + return + } + branch := strings.TrimPrefix(payload.Ref, "refs/heads/") + commit := payload.HeadCommit.ID + if commit == "" { + commit = payload.After + } + + cloneURL := payload.Repository.CloneURL + fullName := payload.Repository.FullName + if cloneURL == "" { + cloneURL = fmt.Sprintf("https://github.com/%s.git", fullName) + } + + // Services tracking an App-connected repo store either the clone URL or + // owner/name — match both like the per-repo receiver does. + engineValue, _ := c.Get("deployment_engine") + engine, _ := engineValue.(*deployment.DeploymentEngine) + + enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) + return + } + c.JSON(http.StatusAccepted, gin.H{ + "received": true, + "branch": branch, + "deployments": enqueued, + }) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 1931641..3d6f8fc 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -249,6 +249,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // replaces session auth. Providers POST to /api/git/webhooks/:id. router.POST("/api/git/webhooks/:id", handleGitWebhookPush) + // GitHub App-level webhook: installed repos deliver to this single + // endpoint, verified against the provisioned webhook secret. + router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook) + v1 := router.Group("/api/v1") { // Public routes (no authentication required) @@ -477,6 +481,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg admin.GET("/admin/overview", handleAdminOverview) admin.GET("/admin/users", handleAdminListUsers) admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin) + admin.POST("/admin/users/:id/impersonate", handleAdminImpersonateUser) admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval) admin.POST("/users", handleCreateUser) @@ -501,6 +506,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner) admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner) + // GitHub App self-provisioning (manifest flow) + admin.GET("/admin/git/github-app", handleAdminGetGitHubApp) + admin.POST("/admin/git/github-app/manifest", handleAdminGitHubAppManifest) + admin.POST("/admin/git/github-app/convert", handleAdminGitHubAppConvert) + // Team invites — single-use registration links admin.GET("/admin/invites", handleAdminListInvites) admin.POST("/admin/invites", handleAdminCreateInvite) diff --git a/app/backend/internal/cli/commands/admin.go b/app/backend/internal/cli/commands/admin.go index 1971455..9111726 100644 --- a/app/backend/internal/cli/commands/admin.go +++ b/app/backend/internal/cli/commands/admin.go @@ -56,6 +56,84 @@ var adminUsersCmd = &cobra.Command{ }, } +var adminImpersonateCmd = &cobra.Command{ + Use: "impersonate ", + Short: "Mint a 15-minute token acting as a user (audit-logged)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/admin/users/"+args[0]+"/impersonate", map[string]interface{}{}) + if err != nil { + return err + } + PrintRaw(data) + fmt.Fprintln(cmd.ErrOrStderr(), "\nUse it with: CONTAINR_TOKEN= containr ") + return nil + }, +} + +var adminGitHubAppCmd = &cobra.Command{ + Use: "github-app", + Short: "GitHub App self-provisioning", +} + +var adminGitHubAppStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show GitHub App provisioning status", + RunE: simpleGet("/admin/git/github-app"), +} + +var adminGitHubAppManifestCmd = &cobra.Command{ + Use: "manifest", + Short: "Build the manifest to create the instance GitHub App", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + req := map[string]interface{}{"base_url": ghAppBaseURL} + if ghAppOrg != "" { + req["organization"] = ghAppOrg + } + if ghAppName != "" { + req["name"] = ghAppName + } + data, err := c.Do("POST", "/admin/git/github-app/manifest", req) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var adminGitHubAppConvertCmd = &cobra.Command{ + Use: "convert ", + Short: "Exchange the manifest callback code for app credentials", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/admin/git/github-app/convert", map[string]interface{}{"code": args[0]}) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var ( + ghAppBaseURL string + ghAppOrg string + ghAppName string +) + var adminSettingsCmd = &cobra.Command{ Use: "settings", Short: "Show platform settings", @@ -353,7 +431,13 @@ func init() { securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive") securityScanCmd.Flags().String("service", "", "limit scan to one service") - AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd) + adminGitHubAppManifestCmd.Flags().StringVar(&ghAppBaseURL, "base-url", "", "public base URL of this instance (required)") + adminGitHubAppManifestCmd.Flags().StringVar(&ghAppOrg, "org", "", "create the app under this organization") + adminGitHubAppManifestCmd.Flags().StringVar(&ghAppName, "name", "", "app name (defaults to product name + date)") + adminGitHubAppManifestCmd.MarkFlagRequired("base-url") + + adminGitHubAppCmd.AddCommand(adminGitHubAppStatusCmd, adminGitHubAppManifestCmd, adminGitHubAppConvertCmd) + AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd, adminImpersonateCmd, adminGitHubAppCmd) GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd) HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd) ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd) diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 637f84d..696cf2e 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -161,6 +161,10 @@ func init() { register(spec{"containr_invites_list", "List team invite links (admin).", "GET", "/admin/invites", nil, nil, false}) register(spec{"containr_invites_create", "Create a team invite link (admin). Args: email (optional bind), expires_in_hours (default 168). Returns the one-time token + accept URL.", "POST", "/admin/invites", nil, []string{"email", "expires_in_hours"}, false}) register(spec{"containr_invites_revoke", "Revoke a team invite (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/invites/{id}", []string{"id"}, nil, true}) + register(spec{"containr_admin_impersonate", "Mint a 15-minute bearer token acting as a user (admin, audit-logged). Args: id (user id).", "POST", "/admin/users/{id}/impersonate", []string{"id"}, nil, false}) + register(spec{"containr_github_app_status", "Show GitHub App provisioning status (admin).", "GET", "/admin/git/github-app", nil, nil, false}) + register(spec{"containr_github_app_manifest", "Build a GitHub App manifest for self-provisioning (admin). Args: base_url (required, https public URL), name (optional), organization (optional).", "POST", "/admin/git/github-app/manifest", nil, []string{"base_url", "name", "organization"}, false}) + register(spec{"containr_github_app_convert", "Exchange the GitHub manifest callback code for app credentials (admin). Args: code.", "POST", "/admin/git/github-app/convert", nil, []string{"code"}, false}) // Nodes / agent tokens (admin) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index e605971..24b143f 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -23,6 +23,7 @@ import { HighAvailabilityPage, SecurityPage, ComponentShowcase, + GitHubAppCallbackPage, } from '@/features/secondary/pages'; import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages'; import { useAuthSession } from '@/lib/use-auth-session'; @@ -156,6 +157,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/frontend/src/features/admin/pages/AdminPage.tsx b/app/frontend/src/features/admin/pages/AdminPage.tsx index d6e1c4e..85e47c5 100644 --- a/app/frontend/src/features/admin/pages/AdminPage.tsx +++ b/app/frontend/src/features/admin/pages/AdminPage.tsx @@ -20,6 +20,8 @@ import { deleteBanner, deleteProject, getAdminOverview, + impersonateUser, + type ImpersonationResult, listAdminBanners, listAdminUsers, listInvites, @@ -77,6 +79,7 @@ export function AdminPage() { const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] }); const [inviteEmail, setInviteEmail] = useState(''); const [inviteLink, setInviteLink] = useState(null); + const [impersonation, setImpersonation] = useState(null); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['admin-overview'] }); @@ -107,6 +110,12 @@ export function AdminPage() { onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined), }); + const impersonateMutation = useMutation({ + mutationFn: (id: string) => impersonateUser(id), + onSuccess: (result) => setImpersonation(result), + onError: (error) => showToast('error', 'Impersonation failed', error instanceof Error ? error.message : undefined), + }); + const bannerCreateMutation = useMutation({ mutationFn: () => createBanner({ @@ -520,6 +529,7 @@ export function AdminPage() { isSelf={user.id === myId} pending={adminMutation.isPending} onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })} + onImpersonate={user.is_admin || user.id === myId ? undefined : () => impersonateMutation.mutate(user.id)} /> ))} {users.length === 0 ? ( @@ -528,6 +538,52 @@ export function AdminPage() { + {impersonation ? ( +
+
+
+

+ Impersonating {impersonation.user.email} +

+

+ Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged. +

+
+
+
+ + {impersonation.token} + + +
+

+ Use it as a bearer token — e.g. CONTAINR_TOKEN=<token> + containr <command> or Authorization: Bearer + <token>. Requests run with that user's permissions for 15 minutes. +

+
+
+ +
+
+
+ ) : null} + {editingProject ? (
@@ -583,11 +639,13 @@ function UserRow({ isSelf, pending, onToggle, + onImpersonate, }: { user: AdminUser; isSelf: boolean; pending: boolean; onToggle: (isAdmin: boolean) => void; + onImpersonate?: () => void; }) { return (
@@ -606,6 +664,15 @@ function UserRow({ admin ) : null} + {onImpersonate ? ( + + ) : null}
- + {showProvision ? ( + + ) : ( + + )}
)} + {provisionMutation.isError && ( +
+ {provisionMutation.error instanceof Error ? provisionMutation.error.message : 'Failed to start GitHub App setup'} +
+ )} {connectAppMutation.isPending && (
Completing GitHub App installation… @@ -1151,6 +1200,66 @@ function GitProvidersSection() { ); } +// GitHub redirects here after the admin completes the manifest form — +// /git/github-app/callback?code=…&state=… +export function GitHubAppCallbackPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const code = searchParams.get('code'); + + const convertMutation = useMutation({ + mutationFn: (appCode: string) => convertGitHubAppCode(appCode), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['github-app-status'] }); + navigate('/settings', { replace: true }); + }, + }); + const firedRef = useRef(false); + useEffect(() => { + if (code && !firedRef.current) { + firedRef.current = true; + convertMutation.mutate(code); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per code + }, [code]); + + return ( +
+ {!code ? ( + <> + +

Missing code

+

+ GitHub did not return an app code. Start the setup again from Git providers. +

+ + ) : convertMutation.isError ? ( + <> + +

Setup failed

+

+ {convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'} +

+ + + ) : ( + <> + +

Setting up GitHub App

+

Exchanging the app credentials…

+ + )} +
+ ); +} + function PlatformSettingsSection() { const queryClient = useQueryClient(); const settingsQuery = useQuery({ diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 1e8bc0d..8289a58 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -2717,6 +2717,63 @@ export interface paths { }; trace?: never; }; + "/admin/users/{id}/impersonate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Impersonate a user + * @description Mint a 15-minute bearer token acting as the target user. Admin-only; admin accounts cannot be impersonated and every mint is audit-logged. The token carries an `impersonated_by` claim. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Impersonation token */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + token?: string; + /** Format: date-time */ + expires_at?: string; + user?: { + id?: string; + email?: string; + name?: string; + }; + }; + }; + }; + /** @description Target is an admin account */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/projects/{id}": { parameters: { query?: never; @@ -5661,6 +5718,162 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/git/github-app": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * GitHub App provisioning status + * @description Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Provisioning status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + configured?: boolean; + /** @enum {string} */ + source?: "settings" | "env" | "none"; + app_id?: string; + slug?: string; + name?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/git/github-app/manifest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Build GitHub App manifest + * @description Returns a GitHub App manifest for self-provisioning. The client POSTs `manifest` verbatim as a form field to `url`; GitHub then redirects to the instance's /git/github-app/callback with a code. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Public https base URL of this instance */ + base_url: string; + name?: string; + /** @description Create the app under this GitHub organization */ + organization?: string; + }; + }; + }; + responses: { + /** @description Manifest + submission URL */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + url?: string; + /** @description JSON string — POST as form field `manifest` */ + manifest?: string; + state?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/git/github-app/convert": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange manifest callback code + * @description Exchanges the `code` from the manifest redirect for the app credentials and stores them in app_settings (overriding env vars). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + code: string; + }; + }; + }; + responses: { + /** @description App provisioned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + app_id?: number; + slug?: string; + name?: string; + }; + }; + }; + /** @description GitHub conversion failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/git/providers/{providerId}": { parameters: { query?: never; @@ -8386,6 +8599,68 @@ export interface paths { patch?: never; trace?: never; }; + "/api/git/github-app/webhook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * GitHub App webhook receiver + * @description Single app-level webhook all installed repositories deliver to (configured via the manifest's hook_attributes). Verified with X-Hub-Signature-256 against the provisioned webhook secret. Push events enqueue deployments for services tracking the repo+branch. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": Record; + }; + }; + responses: { + /** @description Event accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + received?: boolean; + branch?: string; + deployments?: number; + ignored?: string; + }; + }; + }; + /** @description Invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description GitHub App webhook secret not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/agents/{id}/commands/{commandId}/result": { parameters: { query?: never; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 55da426..a9458a3 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -2258,6 +2258,36 @@ export async function getGitHubAppInstallUrl(): Promise { return payload.install_url; } +export type GitHubAppStatus = { + configured: boolean; + source: 'env' | 'settings' | 'none'; + app_id?: string; + slug?: string; + name?: string; +}; + +export async function getGitHubAppStatus(): Promise { + return requestJson('/admin/git/github-app'); +} + +export async function createGitHubAppManifest(input: { + base_url: string; + name?: string; + organization?: string; +}): Promise<{ url: string; manifest: string; state: string }> { + return requestJson('/admin/git/github-app/manifest', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function convertGitHubAppCode(code: string): Promise<{ app_id: number; slug: string; name: string }> { + return requestJson('/admin/git/github-app/convert', { + method: 'POST', + body: JSON.stringify({ code }), + }); +} + export async function connectGitHubApp(installationId: number, displayName?: string): Promise { const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', { method: 'POST', @@ -2622,6 +2652,18 @@ export async function listAdminUsers(): Promise { return payload.users ?? []; } +export type ImpersonationResult = { + token: string; + expires_at: string; + user: { id: string; email: string; name: string }; +}; + +export async function impersonateUser(userId: string): Promise { + return requestJson(`/admin/users/${encodeURIComponent(userId)}/impersonate`, { + method: 'POST', + }); +} + export async function setUserAdmin(userId: string, isAdmin: boolean): Promise { await requestJson(`/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3e153e3..27fac19 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1791,6 +1791,45 @@ paths: message: type: string + /admin/users/{id}/impersonate: + post: + tags: + - Admin + summary: Impersonate a user + description: >- + Mint a 15-minute bearer token acting as the target user. Admin-only; + admin accounts cannot be impersonated and every mint is audit-logged. + The token carries an `impersonated_by` claim. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Impersonation token + content: + application/json: + schema: + type: object + properties: + token: + type: string + expires_at: + type: string + format: date-time + user: + type: object + properties: + id: {type: string} + email: {type: string} + name: {type: string} + '403': + description: Target is an admin account + /admin/projects/{id}: patch: tags: @@ -3730,6 +3769,93 @@ paths: schema: $ref: '#/components/schemas/Error' + /admin/git/github-app: + get: + tags: + - Admin + summary: GitHub App provisioning status + description: Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables. + security: + - bearerAuth: [] + responses: + '200': + description: Provisioning status + content: + application/json: + schema: + type: object + properties: + configured: {type: boolean} + source: {type: string, enum: [settings, env, none]} + app_id: {type: string} + slug: {type: string} + name: {type: string} + + /admin/git/github-app/manifest: + post: + tags: + - Admin + summary: Build GitHub App manifest + description: >- + Returns a GitHub App manifest for self-provisioning. The client POSTs + `manifest` verbatim as a form field to `url`; GitHub then redirects to + the instance's /git/github-app/callback with a code. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [base_url] + properties: + base_url: {type: string, description: Public https base URL of this instance} + name: {type: string} + organization: {type: string, description: Create the app under this GitHub organization} + responses: + '200': + description: Manifest + submission URL + content: + application/json: + schema: + type: object + properties: + url: {type: string} + manifest: {type: string, description: JSON string — POST as form field `manifest`} + state: {type: string} + + /admin/git/github-app/convert: + post: + tags: + - Admin + summary: Exchange manifest callback code + description: Exchanges the `code` from the manifest redirect for the app credentials and stores them in app_settings (overriding env vars). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: {type: string} + responses: + '200': + description: App provisioned + content: + application/json: + schema: + type: object + properties: + app_id: {type: integer} + slug: {type: string} + name: {type: string} + '502': + description: GitHub conversion failed + /git/providers/{providerId}: delete: tags: @@ -5325,6 +5451,45 @@ paths: schema: $ref: '#/components/schemas/Error' + /api/git/github-app/webhook: + servers: + - url: http://localhost:8080 + post: + tags: + - Git + summary: GitHub App webhook receiver + description: >- + Single app-level webhook all installed repositories deliver to + (configured via the manifest's hook_attributes). Verified with + X-Hub-Signature-256 against the provisioned webhook secret. Push + events enqueue deployments for services tracking the repo+branch. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '202': + description: Event accepted + content: + application/json: + schema: + type: object + properties: + received: + type: boolean + branch: + type: string + deployments: + type: integer + ignored: + type: string + '401': + description: Invalid signature + '503': + description: GitHub App webhook secret not configured + /api/agents/{id}/commands/{commandId}/result: servers: - url: http://localhost:8080 diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index ab4d1ee..6d8557a 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -88,6 +88,9 @@ banners list|create|update|delete announcement banners (admin); active banners print on `auth status` invites list|create|revoke team invite links (admin); create prints the one-time acceptance URL +admin impersonate mint a 15-min token acting as a user +admin github-app status|manifest|convert + self-provision the instance GitHub App up deploy the current directory ```