fix(git): use cloud install path for ghe apps (#10576)
This commit is contained in:
commit
8ce054c10c
3 changed files with 235 additions and 53 deletions
|
|
@ -8,11 +8,7 @@
|
||||||
use App\Rules\SafeExternalUrl;
|
use App\Rules\SafeExternalUrl;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Lcobucci\JWT\Configuration;
|
|
||||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
|
||||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Change extends Component
|
class Change extends Component
|
||||||
|
|
@ -208,6 +204,8 @@ public function checkPermissions()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncGithubAppName($this->github_app);
|
||||||
|
|
||||||
GithubAppPermissionJob::dispatchSync($this->github_app);
|
GithubAppPermissionJob::dispatchSync($this->github_app);
|
||||||
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||||
$this->syncData(false);
|
$this->syncData(false);
|
||||||
|
|
@ -303,64 +301,34 @@ public function getGithubAppNameUpdatePath()
|
||||||
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}";
|
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateGithubJwt($private_key, $app_id): string
|
|
||||||
{
|
|
||||||
$configuration = Configuration::forAsymmetricSigner(
|
|
||||||
new Sha256,
|
|
||||||
InMemory::plainText($private_key),
|
|
||||||
InMemory::plainText($private_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
$now = time();
|
|
||||||
|
|
||||||
return $configuration->builder()
|
|
||||||
->issuedBy((string) $app_id)
|
|
||||||
->permittedFor('https://api.github.com')
|
|
||||||
->identifiedBy((string) $now)
|
|
||||||
->issuedAt(new \DateTimeImmutable("@{$now}"))
|
|
||||||
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
|
|
||||||
->getToken($configuration->signer(), $configuration->signingKey())
|
|
||||||
->toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateGithubAppName()
|
public function updateGithubAppName()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->github_app);
|
$this->authorize('update', $this->github_app);
|
||||||
|
|
||||||
$privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id);
|
$this->github_app->app_id = $this->appId;
|
||||||
|
$this->github_app->private_key_id = $this->privateKeyId;
|
||||||
|
$this->github_app->unsetRelation('privateKey');
|
||||||
|
|
||||||
if (! $privateKey) {
|
if (! $this->appId) {
|
||||||
|
$this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) {
|
||||||
$this->dispatch('error', 'No private key found for this GitHub App.');
|
$this->dispatch('error', 'No private key found for this GitHub App.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id);
|
$appSlug = syncGithubAppName($this->github_app, true);
|
||||||
|
|
||||||
$response = Http::withHeaders([
|
if ($appSlug) {
|
||||||
'Accept' => 'application/vnd.github+json',
|
$this->name = str($appSlug)->kebab();
|
||||||
'X-GitHub-Api-Version' => '2022-11-28',
|
$this->dispatch('success', 'GitHub App name and private key name synchronized successfully.');
|
||||||
'Authorization' => "Bearer {$jwt}",
|
|
||||||
])->get("{$this->github_app->api_url}/app");
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$app_data = $response->json();
|
|
||||||
$app_slug = $app_data['slug'] ?? null;
|
|
||||||
|
|
||||||
if ($app_slug) {
|
|
||||||
$this->github_app->name = $app_slug;
|
|
||||||
$this->name = str($app_slug)->kebab();
|
|
||||||
$privateKey->name = "github-app-{$app_slug}";
|
|
||||||
$privateKey->save();
|
|
||||||
$this->github_app->save();
|
|
||||||
$this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.');
|
|
||||||
} else {
|
|
||||||
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$error_message = $response->json()['message'] ?? 'Unknown error';
|
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
|
||||||
$this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}");
|
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Models\GithubApp;
|
use App\Models\GithubApp;
|
||||||
use App\Models\GitlabApp;
|
use App\Models\GitlabApp;
|
||||||
|
use App\Models\PrivateKey;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
@ -13,9 +14,9 @@
|
||||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||||
use Lcobucci\JWT\Token\Builder;
|
use Lcobucci\JWT\Token\Builder;
|
||||||
|
|
||||||
function generateGithubToken(GithubApp $source, string $type)
|
function assertGithubClockInSync(string $apiUrl): void
|
||||||
{
|
{
|
||||||
$response = Http::get("{$source->api_url}/zen");
|
$response = Http::get("{$apiUrl}/zen");
|
||||||
$serverTime = CarbonImmutable::now()->setTimezone('UTC');
|
$serverTime = CarbonImmutable::now()->setTimezone('UTC');
|
||||||
$githubTime = Carbon::parse($response->header('date'));
|
$githubTime = Carbon::parse($response->header('date'));
|
||||||
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
|
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
|
||||||
|
|
@ -29,6 +30,11 @@ function generateGithubToken(GithubApp $source, string $type)
|
||||||
'Please synchronize your system clock.'
|
'Please synchronize your system clock.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateGithubToken(GithubApp $source, string $type)
|
||||||
|
{
|
||||||
|
assertGithubClockInSync($source->api_url);
|
||||||
|
|
||||||
$signingKey = InMemory::plainText($source->privateKey->private_key);
|
$signingKey = InMemory::plainText($source->privateKey->private_key);
|
||||||
$algorithm = new Sha256;
|
$algorithm = new Sha256;
|
||||||
|
|
@ -117,10 +123,81 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateGithubAppJwt(string $privateKey, string|int $appId): string
|
||||||
|
{
|
||||||
|
$algorithm = new Sha256;
|
||||||
|
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
|
||||||
|
$now = CarbonImmutable::now()->setTimezone('UTC');
|
||||||
|
$now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s'));
|
||||||
|
|
||||||
|
return $tokenBuilder
|
||||||
|
->issuedBy((string) $appId)
|
||||||
|
->issuedAt($now->modify('-1 minute'))
|
||||||
|
->expiresAt($now->modify('+8 minutes'))
|
||||||
|
->getToken($algorithm, InMemory::plainText($privateKey))
|
||||||
|
->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (blank($source->app_id) || blank($source->private_key_id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id);
|
||||||
|
|
||||||
|
if (! $privateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertGithubClockInSync($source->api_url);
|
||||||
|
|
||||||
|
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Accept' => 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version' => '2022-11-28',
|
||||||
|
'Authorization' => "Bearer {$jwt}",
|
||||||
|
])->get("{$source->api_url}/app");
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$appSlug = data_get($response->json(), 'slug');
|
||||||
|
|
||||||
|
if (blank($appSlug)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source->name = $appSlug;
|
||||||
|
|
||||||
|
if ($source->exists) {
|
||||||
|
$source->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$privateKey->name = "github-app-{$appSlug}";
|
||||||
|
$privateKey->save();
|
||||||
|
|
||||||
|
return $appSlug;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($throw) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getInstallationPath(GithubApp $source): string
|
function getInstallationPath(GithubApp $source): string
|
||||||
{
|
{
|
||||||
$name = str(Str::kebab($source->name));
|
$name = str(Str::kebab($source->name));
|
||||||
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
|
$baseUrl = rtrim($source->html_url, '/');
|
||||||
|
$host = parse_url($source->html_url, PHP_URL_HOST);
|
||||||
|
$host = blank($host) ? null : Str::lower($host);
|
||||||
|
$usesDataResidencyPath = filled($host) && Str::endsWith($host, '.ghe.com');
|
||||||
|
$installation_path = $host === 'github.com' || $usesDataResidencyPath ? 'apps' : 'github-apps';
|
||||||
$state = Str::random(64);
|
$state = Str::random(64);
|
||||||
|
|
||||||
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||||
|
|
@ -129,7 +206,17 @@ function getInstallationPath(GithubApp $source): string
|
||||||
'team_id' => $source->team_id,
|
'team_id' => $source->team_id,
|
||||||
], now()->addMinutes(60));
|
], now()->addMinutes(60));
|
||||||
|
|
||||||
return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
|
if ($usesDataResidencyPath) {
|
||||||
|
$organization = str($source->organization)->trim('/');
|
||||||
|
|
||||||
|
if ($organization->isNotEmpty()) {
|
||||||
|
$organization = rawurlencode((string) $organization);
|
||||||
|
|
||||||
|
return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$baseUrl/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPermissionsPath(GithubApp $source)
|
function getPermissionsPath(GithubApp $source)
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,125 @@ function validPrivateKey(): string
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ghe.com installation path uses github cloud owner scoped route', function () {
|
||||||
|
$githubApp = new GithubApp;
|
||||||
|
$githubApp->forceFill([
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'provided-github-app',
|
||||||
|
'organization' => 'acme-enterprise',
|
||||||
|
'html_url' => 'https://octocorp.ghe.com',
|
||||||
|
'team_id' => 456,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$installationUrl = getInstallationPath($githubApp);
|
||||||
|
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||||
|
$installState = $query['state'] ?? null;
|
||||||
|
|
||||||
|
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/provided-github-app/installations/new?')
|
||||||
|
->and($installState)->not->toBeEmpty()
|
||||||
|
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
|
||||||
|
->toMatchArray([
|
||||||
|
'action' => 'install',
|
||||||
|
'github_app_id' => 123,
|
||||||
|
'team_id' => 456,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installation path is pure and never calls github or mutates the app', function () {
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$privateKey = PrivateKey::create([
|
||||||
|
'name' => 'github-app-local-name',
|
||||||
|
'private_key' => validPrivateKey(),
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'is_git_related' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$githubApp = GithubApp::create([
|
||||||
|
'name' => 'Local Display Name',
|
||||||
|
'organization' => 'acme-enterprise',
|
||||||
|
'api_url' => 'https://api.github.com',
|
||||||
|
'html_url' => 'https://octocorp.ghe.com',
|
||||||
|
'custom_user' => 'git',
|
||||||
|
'custom_port' => 22,
|
||||||
|
'app_id' => 12345,
|
||||||
|
'private_key_id' => $privateKey->id,
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'is_system_wide' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$installationUrl = getInstallationPath($githubApp);
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
|
||||||
|
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/local-display-name/installations/new?')
|
||||||
|
->and($githubApp->refresh()->name)->toBe('Local Display Name')
|
||||||
|
->and($privateKey->refresh()->name)->toBe('github-app-local-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncGithubAppName persists the github slug and renames the private key', function () {
|
||||||
|
Http::fake([
|
||||||
|
'*/app' => Http::response(['slug' => 'actual-github-slug']),
|
||||||
|
'*/zen' => Http::response('Keep it logically awesome.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$privateKey = PrivateKey::create([
|
||||||
|
'name' => 'github-app-local-name',
|
||||||
|
'private_key' => validPrivateKey(),
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'is_git_related' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$githubApp = GithubApp::create([
|
||||||
|
'name' => 'Local Display Name',
|
||||||
|
'organization' => 'acme-enterprise',
|
||||||
|
'api_url' => 'https://api.github.com',
|
||||||
|
'html_url' => 'https://octocorp.ghe.com',
|
||||||
|
'custom_user' => 'git',
|
||||||
|
'custom_port' => 22,
|
||||||
|
'app_id' => 12345,
|
||||||
|
'private_key_id' => $privateKey->id,
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'is_system_wide' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appSlug = syncGithubAppName($githubApp, true);
|
||||||
|
|
||||||
|
expect($appSlug)->toBe('actual-github-slug')
|
||||||
|
->and($githubApp->refresh()->name)->toBe('actual-github-slug')
|
||||||
|
->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ghe.com installation path encodes the organization segment', function () {
|
||||||
|
$githubApp = new GithubApp;
|
||||||
|
$githubApp->forceFill([
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'provided-github-app',
|
||||||
|
'organization' => '/acme enterprise/',
|
||||||
|
'html_url' => 'https://octocorp.ghe.com',
|
||||||
|
'team_id' => 456,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$installationUrl = getInstallationPath($githubApp);
|
||||||
|
|
||||||
|
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
|
||||||
|
$githubApp = new GithubApp;
|
||||||
|
$githubApp->forceFill([
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'provided-github-app',
|
||||||
|
'organization' => null,
|
||||||
|
'html_url' => 'https://octocorp.ghe.com',
|
||||||
|
'team_id' => 456,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$installationUrl = getInstallationPath($githubApp);
|
||||||
|
|
||||||
|
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?');
|
||||||
|
});
|
||||||
|
|
||||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||||
config(['app.url' => 'http://localhost:8000']);
|
config(['app.url' => 'http://localhost:8000']);
|
||||||
|
|
||||||
|
|
@ -207,6 +326,10 @@ function validPrivateKey(): string
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can mount with fully configured github app', function () {
|
test('can mount with fully configured github app', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||||
|
]);
|
||||||
|
|
||||||
$privateKey = PrivateKey::create([
|
$privateKey = PrivateKey::create([
|
||||||
'name' => 'Test Key',
|
'name' => 'Test Key',
|
||||||
'private_key' => validPrivateKey(),
|
'private_key' => validPrivateKey(),
|
||||||
|
|
@ -241,6 +364,10 @@ function validPrivateKey(): string
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can update github app from null to valid values', function () {
|
test('can update github app from null to valid values', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
|
||||||
|
]);
|
||||||
|
|
||||||
$privateKey = PrivateKey::create([
|
$privateKey = PrivateKey::create([
|
||||||
'name' => 'Test Key',
|
'name' => 'Test Key',
|
||||||
'private_key' => validPrivateKey(),
|
'private_key' => validPrivateKey(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue