Files
Containr/app/backend/internal/api/github_app.go
T
Tomáš DvořákandDevin 00e69b3733 feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* 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 <user-id> (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>
2026-10-09 10:43:12 +02:00

311 lines
10 KiB
Go

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,
})
}