diff --git a/README.md b/README.md index bb4d257..43bee07 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ First-party Paca plugin that is the GitHub provider for Paca's platform-neutral Core owns repositories, project links, identities, change requests and audit. This plugin only adapts GitHub to the provider SPI: -- Identity setup through the GitHub App manifest flow (one App per Paca instance or agent). -- Per-owner installation grants, requested when a repository is linked. -- Short-lived installation tokens for read/write repository credentials (never stored in remotes). +- Connection setup, either through the GitHub App manifest flow (an App per workspace, project or agent) or by pasting a personal access token (the webhook secret is generated). A workspace or project may hold several connections of either kind. +- Apps: per-owner installation grants, requested when a repository is linked, and short-lived installation tokens for read/write repository credentials (never stored in remotes). +- Tokens: static credentials handed to git per command by core, a webhook registered on each linked repository with a generated secret (`webhook_management`; the token needs the webhook permission), and commit statuses instead of check runs. A token acts as its owner on GitHub, so a dedicated machine user is recommended. - Repository listing, webhook verification and event normalisation. ## Structure @@ -21,12 +21,16 @@ The plugin has no HTTP routes of its own: core calls the internal-only `POST /_g ### Backend - `spi_*.go` implement the provider operations; the conformance suite (`spi_conformance_test.go`) runs them against a fake GitHub. -- Owns the schema `plugin_data_com_paca_github` (`github_apps`, `github_app_installations`, `github_manifest_sessions`); App private keys and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. -- Personal access tokens are not supported: every connection is a GitHub App installation. +- Owns the schema `plugin_data_com_paca_github` (`github_apps`, `github_app_installations`, `github_tokens`, `github_manifest_sessions`); App private keys, tokens and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. +- `spi_token.go` holds everything specific to token connections; the kind a setup creates is chosen by `Params["kind"]` (`app` by default, `token`). The provider reports per-kind behaviour in `capabilities.kinds`. ### Upgrading from 0.4.x -Migration `0002_git_provider.sql` **drops** the previous release's tables (`github_integrations`, `github_repositories`, `github_pull_requests`, `github_task_branches`, `github_task_pr_links`). Nothing is migrated, because a personal access token cannot back a provider identity. Export anything you still need from `plugin_data_com_paca_github` before upgrading, then set up a GitHub App identity and relink repositories in Paca. +Migration `0002_git_provider.sql` moves the previous release's data into core and drops its tables (`github_integrations`, `github_repositories`, `github_pull_requests`, `github_task_branches`, `github_task_pr_links`): + +- Each project's access token becomes a **project-scoped token connection** (the ciphertext is copied as is, so the same `ENCRYPTION_KEY` must be in place), with its linked repositories, branch links and pull request links. +- The token's owner login and a webhook secret are not known to the migration; they are filled in the first time the token is used (GitHub is asked who owns it, a secret is generated). Until then the connection shows no `@handle`. +- The webhooks the old release registered on GitHub pointed at the old plugin and are left there. Open the project's Git settings and press *Try again* on each repository to register a new one (the token needs the webhook permission), and delete the old hooks on GitHub. ### Build diff --git a/backend/go.mod b/backend/go.mod index 147f1c6..3fbfeb7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/github go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.4.0 +require github.com/Paca-AI/plugin-sdk-go v0.4.1 diff --git a/backend/go.sum b/backend/go.sum index 78f3884..9925b1c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.4.0 h1:u3xDC+VGfHrgjI2e+XCgVMdnnKo5cedX36KPg6X0+/4= -github.com/Paca-AI/plugin-sdk-go v0.4.0/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.4.1 h1:Fid4T5OryhJca+9dBtM09iTipli9KouY/3G5ju2Sz5Y= +github.com/Paca-AI/plugin-sdk-go v0.4.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= diff --git a/backend/migrations/0002_git_provider.sql b/backend/migrations/0002_git_provider.sql index 1eab48e..c135b01 100644 --- a/backend/migrations/0002_git_provider.sql +++ b/backend/migrations/0002_git_provider.sql @@ -1,9 +1,9 @@ -- 0002_git_provider.sql --- The GitHub provider's own state: instance- or agent-level GitHub Apps --- (created through the GitHub App manifest flow, key material encrypted), the --- accounts they are installed on, and the setup sessions that tie the --- manifest flow's redirects together. Drops the previous release's --- project-bound tables (see the end). +-- The GitHub provider's own state: GitHub Apps (created through the GitHub App +-- manifest flow, key material encrypted), the accounts they are installed on, +-- personal access token connections (token and webhook secret encrypted), and +-- the setup sessions that tie a setup's redirects or token form together. The +-- previous release's project-bound tables are migrated and dropped (see the end). -- Run with search_path = plugin_data_com_paca_github, public. CREATE TABLE IF NOT EXISTS github_apps ( @@ -43,6 +43,8 @@ CREATE TABLE IF NOT EXISTS github_manifest_sessions ( owner_org TEXT NOT NULL DEFAULT '', -- Whom the identity is for: the shared one, or one agent's own. scope TEXT NOT NULL DEFAULT 'shared', + -- Which kind of identity the attempt creates: an App (manifest flow) or a token. + kind TEXT NOT NULL DEFAULT 'app', agent_id TEXT NOT NULL DEFAULT '', -- Set once the manifest code has been exchanged; the second leg -- (installation) then runs against this app. @@ -53,10 +55,97 @@ CREATE TABLE IF NOT EXISTS github_manifest_sessions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- The plugin is a Git provider: repositories, pull requests, task links and --- the per-project connection now live in Paca core (git_* tables). The --- project-bound tables of the previous release are dropped; PAT connections --- cannot back a provider identity, so no data is migrated. +-- Personal access token identities, next to the Apps: a user pastes a token and +-- a webhook secret; the token is checked against GitHub and stored encrypted. +CREATE TABLE IF NOT EXISTS github_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + login TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + token_enc TEXT NOT NULL, + webhook_secret_enc TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Migrating the previous release ────────────────────────────────────────── +-- The plugin is a Git provider: repositories, pull requests, task links and the +-- per-project connection now live in Paca core (git_* tables). The previous +-- release kept one personal access token per project (github_integrations) and +-- the repositories linked with it. Each becomes a project-scoped token +-- connection in core with its repositories, branch links and pull request links, +-- and then the old tables go. +-- +-- What cannot be known here is completed on the token's first use (see +-- completeMigratedToken in spi_token.go): the owner's login is asked of GitHub +-- and a webhook secret is generated. The webhooks the old release registered on +-- GitHub pointed at the old plugin and used per-repository secrets; they are +-- left on GitHub, and "Try again" on a repository registers a new one. +-- Core's tables are created before any plugin migration runs; if they are not +-- there (a plugin-only database) the old data is only dropped. + +-- The ciphertexts are copied as they are: the same ENCRYPTION_KEY protects both. +INSERT INTO github_tokens (id, login, name, token_enc, webhook_secret_enc) +SELECT i.id, '', '', i.access_token_enc, '' +FROM github_integrations i +ON CONFLICT (id) DO NOTHING; + +DO $$ +BEGIN + IF to_regclass('public.git_identities') IS NULL THEN + RETURN; + END IF; + + INSERT INTO public.git_identities (provider_id, host, provider_identity_id, kind, scope, project_id, handle, display_name) + SELECT 'com.paca.github', 'github.com', i.id::text, 'token', 'project', i.project_id, '', 'GitHub access token' + FROM github_integrations i + WHERE NOT EXISTS ( + SELECT 1 FROM public.git_identities g + WHERE g.provider_id = 'com.paca.github' AND g.provider_identity_id = i.id::text + ); + + -- Webhook deliveries for a connection arrive at its own endpoint. + INSERT INTO public.git_webhook_endpoints (key, provider_id, identity_id) + SELECT replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''), g.provider_id, g.id + FROM public.git_identities g + WHERE g.provider_id = 'com.paca.github' AND g.kind = 'token' AND g.scope = 'project' + AND NOT EXISTS (SELECT 1 FROM public.git_webhook_endpoints e WHERE e.identity_id = g.id); + + INSERT INTO public.git_repositories (project_id, provider_id, host, external_id, full_path, default_branch, clone_url, identity_id) + SELECT r.project_id, 'com.paca.github', 'github.com', '', r.full_name, r.default_branch, + 'https://github.com/' || r.full_name || '.git', g.id + FROM github_repositories r + JOIN public.git_identities g ON g.provider_id = 'com.paca.github' AND g.provider_identity_id = r.integration_id::text + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_change_requests (repository_id, number, title, state, head_ref, base_ref, author, html_url, merged_at) + SELECT cr.id, p.pr_number::text, p.title, + CASE WHEN p.state IN ('open', 'closed', 'merged') THEN p.state ELSE 'open' END, + p.head_branch, p.base_branch, p.author, p.html_url, p.merged_at + FROM github_pull_requests p + JOIN github_repositories r ON r.id = p.repo_id + JOIN public.git_repositories cr ON cr.project_id = r.project_id AND cr.provider_id = 'com.paca.github' + AND cr.host = 'github.com' AND lower(cr.full_path) = lower(r.full_name) + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_task_links (task_id, change_request_id) + SELECT l.task_id, cr.id + FROM github_task_pr_links l + JOIN github_pull_requests p ON p.id = l.pull_request_id + JOIN github_repositories r ON r.id = p.repo_id + JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.github' + AND gr.host = 'github.com' AND lower(gr.full_path) = lower(r.full_name) + JOIN public.git_change_requests cr ON cr.repository_id = gr.id AND cr.number = p.pr_number::text + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_task_branches (task_id, repository_id, branch_name) + SELECT b.task_id, gr.id, b.branch_name + FROM github_task_branches b + JOIN github_repositories r ON r.id = b.repo_id + JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.github' + AND gr.host = 'github.com' AND lower(gr.full_path) = lower(r.full_name) + ON CONFLICT DO NOTHING; +END $$; + DROP TABLE IF EXISTS github_task_pr_links; DROP TABLE IF EXISTS github_task_branches; DROP TABLE IF EXISTS github_pull_requests; diff --git a/backend/plugin.go b/backend/plugin.go index 3aa3bb3..3a16785 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -1,8 +1,8 @@ // Package main implements the com.paca.github backend WASM plugin: the GitHub // provider of Paca's Git provider SPI (spi_*.go). Core drives it through the // internal-only POST /_git/:op routes; the plugin has no routes of its own, no -// per-project data and no frontend. Connections are GitHub App installations -// only; personal access tokens are not supported. +// per-project data and no frontend. A connection is a GitHub App (manifest +// flow, installed per account) or a personal access token (spi_token.go). package main import ( diff --git a/backend/spi_api.go b/backend/spi_api.go index 6fefe8f..b44f996 100644 --- a/backend/spi_api.go +++ b/backend/spi_api.go @@ -16,6 +16,9 @@ import ( // error type; GitHub's wording is kept short and never carries credentials. type ghAPI struct { token string + // user is set for a personal access token: it acts as a person, so it has no + // check runs (only GitHub Apps create those). + user bool // refresh, when set, replaces a token GitHub answered 401 to (revoked, for // example because the App was uninstalled and installed again) and the // request is retried once. diff --git a/backend/spi_assert.go b/backend/spi_assert.go index bd38886..c2bfe8c 100644 --- a/backend/spi_assert.go +++ b/backend/spi_assert.go @@ -14,5 +14,6 @@ var ( _ gp.AssigneeSetter = (*ghProvider)(nil) _ gp.Merger = (*ghProvider)(nil) _ gp.IdentityDeleter = (*ghProvider)(nil) + _ gp.WebhookManager = (*ghProvider)(nil) _ gp.AccessGranter = (*ghProvider)(nil) ) diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index acfac51..a7014dd 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -20,6 +20,7 @@ type memStore struct { apps map[string]appRecord installs map[string][]installRecord setups map[string]setupSession + tokens map[string]tokenRecord cache map[string]memToken seq int } @@ -30,7 +31,7 @@ type memToken struct { } func newMemStore() *memStore { - return &memStore{apps: map[string]appRecord{}, installs: map[string][]installRecord{}, setups: map[string]setupSession{}, cache: map[string]memToken{}} + return &memStore{apps: map[string]appRecord{}, installs: map[string][]installRecord{}, setups: map[string]setupSession{}, tokens: map[string]tokenRecord{}, cache: map[string]memToken{}} } func (m *memStore) loadApp(id string) (appRecord, error) { @@ -42,6 +43,45 @@ func (m *memStore) loadApp(id string) (appRecord, error) { } return a, nil } +func (m *memStore) loadToken(id string) (tokenRecord, error) { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.tokens[id] + if !ok { + return tokenRecord{}, errNoSuchToken + } + return t, nil +} +func (m *memStore) createToken(t tokenRecord) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.seq++ + t.ID = "created-token-" + string(rune('0'+m.seq)) + m.tokens[t.ID] = t + return t.ID, nil +} +func (m *memStore) updateToken(t tokenRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.tokens[t.ID]; !ok { + return errNoSuchToken + } + cur := m.tokens[t.ID] + if cur.Login == "" { + cur.Login, cur.Name = t.Login, t.Name + } + if cur.WebhookSecret == "" { + cur.WebhookSecret = t.WebhookSecret + } + m.tokens[t.ID] = cur + return nil +} +func (m *memStore) deleteToken(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.tokens, id) + return nil +} func (m *memStore) installations(appID string) ([]installRecord, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go index 829a684..25c3a21 100644 --- a/backend/spi_dbstore.go +++ b/backend/spi_dbstore.go @@ -48,6 +48,75 @@ func (s dbStore) loadApp(id string) (appRecord, error) { }, nil } +func (s dbStore) loadToken(id string) (tokenRecord, error) { + r, err := s.db.Query(`SELECT id, login, name, token_enc, webhook_secret_enc FROM github_tokens WHERE id = $1`, id) + if err != nil { + return tokenRecord{}, err + } + if len(r.Rows) == 0 { + return tokenRecord{}, errNoSuchToken + } + sc := newRowScanner(r.Columns, r.Rows[0]) + tok, err := s.p.decrypt(sc.str("token_enc")) + if err != nil { + return tokenRecord{}, err + } + // A token migrated from the previous release has no webhook secret yet. + secret := "" + if enc := sc.str("webhook_secret_enc"); enc != "" { + if secret, err = s.p.decrypt(enc); err != nil { + return tokenRecord{}, err + } + } + return tokenRecord{ID: sc.str("id"), Login: sc.str("login"), Name: sc.str("name"), Token: tok, WebhookSecret: secret}, nil +} + +func (s dbStore) createToken(t tokenRecord) (string, error) { + tok, err := s.p.encrypt(t.Token) + if err != nil { + return "", err + } + hook, err := s.p.encrypt(t.WebhookSecret) + if err != nil { + return "", err + } + now := nowStr() + r, err := s.db.Query(` + INSERT INTO github_tokens (login, name, token_enc, webhook_secret_enc, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$5) RETURNING id`, t.Login, t.Name, tok, hook, now) + if err != nil { + return "", err + } + if len(r.Rows) == 0 { + return "", errNoSuchToken + } + return newRowScanner(r.Columns, r.Rows[0]).str("id"), nil +} + +// updateToken fills a token's blanks (login, name, webhook secret) and never +// overwrites a value that is already there, so two first uses cannot disagree. +func (s dbStore) updateToken(t tokenRecord) error { + hook := "" + if t.WebhookSecret != "" { + var err error + if hook, err = s.p.encrypt(t.WebhookSecret); err != nil { + return err + } + } + _, err := s.db.Exec(`UPDATE github_tokens SET + login = CASE WHEN login = '' THEN $2 ELSE login END, + name = CASE WHEN login = '' THEN $3 ELSE name END, + webhook_secret_enc = CASE WHEN webhook_secret_enc = '' THEN $4 ELSE webhook_secret_enc END, + updated_at = $5 WHERE id = $1`, + t.ID, t.Login, t.Name, hook, nowStr()) + return err +} + +func (s dbStore) deleteToken(id string) error { + _, err := s.db.Exec(`DELETE FROM github_tokens WHERE id = $1`, id) + return err +} + func (s dbStore) installations(appID string) ([]installRecord, error) { r, err := s.db.Query(`SELECT id, installation_id, account_login, account_type, repository_selection, suspended_at FROM github_app_installations WHERE app_id = $1`, appID) if err != nil { @@ -126,14 +195,14 @@ func (s dbStore) saveSetup(x setupSession) error { appID = x.AppID } _, err := s.db.Exec(` - INSERT INTO github_manifest_sessions (state, created_by, app_name, owner_org, scope, agent_id, app_id, expires_at, created_at) - VALUES ($1,'git-provider',$2,$3,$4,$5,$6,$7,$8)`, - x.State, x.AppName, x.OwnerOrg, x.Scope, x.AgentID, appID, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr()) + INSERT INTO github_manifest_sessions (state, created_by, app_name, owner_org, scope, kind, agent_id, app_id, expires_at, created_at) + VALUES ($1,'git-provider',$2,$3,$4,$5,$6,$7,$8,$9)`, + x.State, x.AppName, x.OwnerOrg, x.Scope, x.Kind, x.AgentID, appID, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr()) return err } func (s dbStore) loadSetup(state string) (setupSession, error) { - r, err := s.db.Query(`SELECT state, app_name, owner_org, scope, agent_id, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, state) + r, err := s.db.Query(`SELECT state, app_name, owner_org, scope, kind, agent_id, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, state) if err != nil { return setupSession{}, err } @@ -143,7 +212,7 @@ func (s dbStore) loadSetup(state string) (setupSession, error) { sc := newRowScanner(r.Columns, r.Rows[0]) exp, _ := time.Parse(time.RFC3339Nano, sc.str("expires_at")) return setupSession{ - State: sc.str("state"), Scope: sc.str("scope"), AgentID: sc.str("agent_id"), + State: sc.str("state"), Scope: sc.str("scope"), Kind: sc.str("kind"), AgentID: sc.str("agent_id"), AppName: sc.str("app_name"), OwnerOrg: sc.str("owner_org"), AppID: sc.str("app_id"), CodeUsed: sc.strPtr("code_used_at") != nil, Completed: sc.strPtr("completed_at") != nil, ExpiresAt: exp, }, nil diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 317cc83..aeea690 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -21,8 +21,8 @@ const ( // ghProvider is the GitHub implementation of the Git provider SPI // (gitprovider.v1): identities are GitHub Apps created through the manifest -// flow, credentials are installation tokens, webhooks are the app-level -// webhook core terminates. See docs/plugins/git-provider-spi.md in the Paca +// flow (credentials are installation tokens, the webhook is app-level) or +// personal access tokens (static credentials, a webhook per repository). See docs/plugins/git-provider-spi.md in the Paca // repository for the contract this implements. type ghProvider struct { store spiStore @@ -37,8 +37,9 @@ func newGHProvider(store spiStore) *ghProvider { // - review_request / assignment are declared for requests and assignments that // name the App's own bot; GitHub's pickers list people and teams, so for an // ordinary App a mention or a label is the dependable way to address the bot; -// - code_search, rerun_checks, reactions and webhook_management are not -// implemented (the app-level webhook is managed by core, not per repo). +// - code_search, rerun_checks and reactions are not implemented; +// - webhook_management is for token identities, whose webhook is per +// repository (an App's webhook is app-level and managed by core). func (p *ghProvider) Describe() gp.Info { return gp.Info{ SPIVersion: gp.SPIVersion, @@ -49,22 +50,29 @@ func (p *ghProvider) Describe() gp.Info { SelfHostedHosts: false, Terms: gp.Terms{ChangeRequest: "Pull request", ChangeRequestPlural: "Pull requests", Repository: "Repository"}, Capabilities: gp.Capabilities{ - IdentityKinds: []gp.IdentityKind{gp.IdentityApp}, + IdentityKinds: []gp.IdentityKind{gp.IdentityApp, gp.IdentityToken}, CredentialModel: gp.CredentialEphemeral, WebhookScope: gp.WebhookApp, StatusKind: gp.StatusCheckRun, Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, MultipleIdentities: true, + // An App is the default kind; a personal access token is static, has + // no installation to grant and delivers events per repository. + Kinds: []gp.KindProfile{ + {Kind: gp.IdentityApp, CredentialModel: gp.CredentialEphemeral, WebhookScope: gp.WebhookApp, AccessGrants: true}, + {Kind: gp.IdentityToken, CredentialModel: gp.CredentialStatic, WebhookScope: gp.WebhookRepo}, + }, - ResolveThreads: true, - FileRead: true, - Labels: true, - ReviewerRequests: true, - Assignees: true, - Merge: true, - IdentityDeletion: true, - Branches: true, - AccessGrants: true, + ResolveThreads: true, + FileRead: true, + Labels: true, + ReviewerRequests: true, + Assignees: true, + Merge: true, + IdentityDeletion: true, + WebhookManagement: true, + Branches: true, + AccessGrants: true, BatchReview: true, HiddenMarkers: true, @@ -194,10 +202,14 @@ func (p *ghProvider) apiFor(t gp.Target) (*ghAPI, string, error) { if err != nil { return nil, "", err } - app, err := p.loadApp(t.Identity.ID) + ident, err := p.loadIdentity(t.Identity.ID) if err != nil { return nil, "", err } + if ident.token != nil { + return &ghAPI{token: ident.token.Token, user: true}, repo, nil + } + app := *ident.app in, err := p.installationFor(app, owner) if err != nil { return nil, "", err @@ -230,10 +242,14 @@ func repoRefOf(r ghRepoJSON, installationID int64) gp.RepoRef { // ListRepositories implements gp.Provider. The cursor is ":" // over the identity's installations in a stable order, so paging is exact. func (p *ghProvider) ListRepositories(_ context.Context, req gp.ListReposRequest) (gp.RepositoryPage, error) { - app, err := p.loadApp(req.Identity.ID) + ident, err := p.loadIdentity(req.Identity.ID) if err != nil { return gp.RepositoryPage{}, err } + if ident.token != nil { + return p.listTokenRepositories(*ident.token, req) + } + app := *ident.app list, err := p.store.installations(app.ID) if err != nil { return gp.RepositoryPage{}, gp.Errorf(gp.CodeInternal, "could not load installations") @@ -341,11 +357,23 @@ func (p *ghProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ } var last error for _, c := range req.Candidates { - app, err := p.loadApp(c.ID) + ident, err := p.loadIdentity(c.ID) if err != nil { last = err continue } + if ident.token != nil { + res, err := p.resolveWithToken(*ident.token, owner, name) + if err != nil { + if code := gp.AsError(err).Code; code == gp.CodeNotFound || code == gp.CodeForbidden { + last = gp.Errorf(gp.CodeNotFound, "the token cannot see %s/%s", owner, name) + continue + } + return gp.ResolvedRepository{}, err + } + return res, nil + } + app := *ident.app in, err := p.installationFor(app, owner) if err != nil { last = err @@ -411,10 +439,16 @@ func (p *ghProvider) MintCredential(_ context.Context, req gp.MintRequest) (gp.C if !ok { return gp.Credential{}, gp.Errorf(gp.CodeValidation, "repository must be owner/name") } - app, err := p.loadApp(req.Target.Identity.ID) + ident, err := p.loadIdentity(req.Target.Identity.ID) if err != nil { return gp.Credential{}, err } + if ident.token != nil { + // Static: the token cannot be narrowed to one repository or to read-only, + // and has no expiry Paca knows of. The core never hands it to an agent. + return gp.Credential{Username: tokenCredUsername, Secret: ident.token.Token}, nil + } + app := *ident.app in, err := p.installationFor(app, owner) if err != nil { return gp.Credential{}, err diff --git a/backend/spi_setup.go b/backend/spi_setup.go index 0b0cc71..8429106 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -26,6 +26,9 @@ func (p *ghProvider) BeginIdentitySetup(_ context.Context, req gp.BeginSetupRequ if req.Scope != gp.SetupShared && req.Scope != gp.SetupAgent { return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "scope must be shared or agent") } + if req.Params["kind"] == kindToken { + return p.beginTokenSetup(req) + } if req.CallbackURL == "" || req.WebhookURL == "" { return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "callback_url and webhook_url are required") } @@ -105,6 +108,14 @@ func buildSPIManifest(name, home, hookURL, callbackURL string, hookActive, publi // CompleteIdentitySetup implements gp.Provider. func (p *ghProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSetupRequest) (gp.Identity, error) { + if strings.TrimSpace(req.State) != "" { + if s, err := p.store.loadSetup(req.State); err == nil && s.Kind == kindToken { + if s.Completed || !p.now().Before(s.ExpiresAt) { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "unknown or expired setup state") + } + return p.completeToken(s, req.Params) + } + } code := strings.TrimSpace(req.Params["code"]) if strings.TrimSpace(req.State) == "" || code == "" { return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and code are required") @@ -178,16 +189,25 @@ func (a *ghAPI) convertManifest(code string) (*ghManifestConversion, error) { return &out, nil } -// DeleteIdentity implements gp.IdentityDeleter: forget the App locally. The -// App itself stays on GitHub (there is no API to delete it); the operator -// removes it there. +// DeleteIdentity implements gp.IdentityDeleter: forget the App (or token) +// locally. The App itself stays on GitHub (there is no API to delete it); the +// operator removes it there. func (p *ghProvider) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) (gp.Empty, error) { - if _, err := p.loadApp(req.Identity.ID); err != nil { + ident, err := p.loadIdentity(req.Identity.ID) + if err != nil { if gp.AsError(err).Code == gp.CodeNotFound { return gp.Empty{}, nil // already gone } return gp.Empty{}, err } + if ident.token != nil { + // The token itself stays valid on GitHub until its owner revokes it, and + // the webhooks registered with it stay on the repositories. + if err := p.store.deleteToken(req.Identity.ID); err != nil { + return gp.Empty{}, gp.Errorf(gp.CodeInternal, "could not delete the identity") + } + return gp.Empty{}, nil + } if err := p.store.deleteApp(req.Identity.ID); err != nil { return gp.Empty{}, gp.Errorf(gp.CodeInternal, "could not delete the identity") } diff --git a/backend/spi_store.go b/backend/spi_store.go index e070240..8b5fdd3 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -18,6 +18,14 @@ type spiStore interface { // same App on the same account is dead and is removed. upsertInstallation(appID string, i installRecord) error deleteApp(id string) error + + // Personal access token identities. + loadToken(id string) (tokenRecord, error) + createToken(t tokenRecord) (id string, err error) + // updateToken rewrites a token's login, name and webhook secret (the fields a + // migrated token fills in on first use). + updateToken(t tokenRecord) error + deleteToken(id string) error // deleteInstallation forgets an installation GitHub no longer has. deleteInstallation(appID string, installationID int64) error @@ -35,6 +43,7 @@ type spiStore interface { } var errNoSuchApp = errors.New("github app not found") +var errNoSuchToken = errors.New("github token not found") var errNoSuchSetup = errors.New("setup session not found") // appRecord is a GitHub App the instance created through the manifest flow. @@ -48,6 +57,20 @@ type appRecord struct { WebhookSecret string PrivateKeyPEM string WebhookActive bool + // IsUser marks a stand-in built from a token identity: its Slug is the + // token owner's login, which is a user account rather than "[bot]". + IsUser bool +} + +// tokenRecord is a personal access token identity: the token acts as the user +// it belongs to, and the webhook secret is the one the user chose for the +// repository webhooks Paca registers with it. +type tokenRecord struct { + ID string // row id; the SPI IdentityRef.ID + Login string + Name string + Token string + WebhookSecret string } // installRecord is an installation of an App on a GitHub account. @@ -64,6 +87,7 @@ type installRecord struct { type setupSession struct { State string Scope string // shared | agent + Kind string // "" or app: the manifest flow; token: a pasted access token AgentID string AppName string OwnerOrg string diff --git a/backend/spi_test.go b/backend/spi_test.go index 29e3866..55d94cd 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -43,10 +43,19 @@ func TestDescribe_DeclaresWhatGitHubCanDo(t *testing.T) { if c.CredentialModel != gp.CredentialEphemeral || c.WebhookScope != gp.WebhookApp || c.StatusKind != gp.StatusCheckRun { t.Errorf("unexpected model: %+v", c) } + // Two kinds of identity: an App (ephemeral credentials, app-level webhook, + // installed per account) and a token (static, a webhook per repository). + app, tok := c.Profile(gp.IdentityApp), c.Profile(gp.IdentityToken) + if app.CredentialModel != gp.CredentialEphemeral || app.WebhookScope != gp.WebhookApp || !app.AccessGrants { + t.Errorf("app profile = %+v", app) + } + if tok.CredentialModel != gp.CredentialStatic || tok.WebhookScope != gp.WebhookRepo || tok.AccessGrants || !c.WebhookManagement { + t.Errorf("token profile = %+v (webhook management %v)", tok, c.WebhookManagement) + } if !strings.Contains(strings.Join(c.CIConfigGlobs, ","), ".github/workflows/**") { t.Errorf("CI config globs = %v", c.CIConfigGlobs) } - if c.CodeSearch || c.RerunChecks || c.Reactions || c.WebhookManagement { + if c.CodeSearch || c.RerunChecks || c.Reactions { t.Errorf("operations that are not implemented must not be declared: %+v", c) } } diff --git a/backend/spi_token.go b/backend/spi_token.go new file mode 100644 index 0000000..7293629 --- /dev/null +++ b/backend/spi_token.go @@ -0,0 +1,441 @@ +package main + +import ( + "context" + "net/url" + "sort" + "strconv" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Personal access token identities. They exist for people who cannot or do not +// want to create a GitHub App: the user pastes a token and a webhook secret, the +// provider checks the token against GitHub and keeps it encrypted. Differences +// from an App identity: +// +// - the credential is static: git and the API use the token itself, so it +// cannot be narrowed to one repository or to read-only; +// - there is no installation: the token sees whatever its user can see, and no +// access grant is needed; +// - events arrive through a webhook registered on each linked repository +// (EnsureWebhook), signed with the secret the user chose; +// - the identity is a person's account, so GitHub shows its actions as that +// person. A dedicated machine user is the recommended owner of the token. + +const ( + kindApp = "app" + kindToken = "token" + + fieldToken = "token" + fieldWebhookSecret = "webhook_secret" + + // minWebhookSecretLen keeps a guessable secret from guarding the webhook + // endpoint: anyone who knows the secret can forge a delivery. + minWebhookSecretLen = 16 + + tokenCredUsername = "x-access-token" +) + +// ghIdentity is whatever an IdentityRef.ID names: an App or a token. +type ghIdentity struct { + app *appRecord + token *tokenRecord +} + +// loadIdentity finds the App or the token behind an identity id. +func (p *ghProvider) loadIdentity(id string) (ghIdentity, error) { + app, err := p.loadApp(id) + if err == nil { + return ghIdentity{app: &app}, nil + } + if gp.AsError(err).Code != gp.CodeNotFound { + return ghIdentity{}, err + } + tok, terr := p.store.loadToken(id) + switch terr { + case nil: + if tok.Login == "" || tok.WebhookSecret == "" { + if err := p.completeMigratedToken(&tok); err != nil { + return ghIdentity{}, err + } + } + return ghIdentity{token: &tok}, nil + case errNoSuchToken: + return ghIdentity{}, err + } + return ghIdentity{}, gp.Errorf(gp.CodeInternal, "could not load the GitHub identity") +} + +// completeMigratedToken fills in what a token carried over from the previous +// release lacks: the owner's login (asked of GitHub) and a webhook secret +// (generated; the old per-repository secrets belonged to hooks that pointed at +// the old plugin). It runs on first use and stores the result. +func (p *ghProvider) completeMigratedToken(t *tokenRecord) error { + if t.Login == "" { + var u ghAuthUser + if err := (&ghAPI{token: t.Token}).get("/user", &u); err != nil { + return err + } + if u.Login == "" { + return gp.Errorf(gp.CodeInternal, "GitHub returned an incomplete user") + } + t.Login, t.Name = u.Login, u.Name + } + if t.WebhookSecret == "" { + secret, err := randomHex(32) + if err != nil { + return gp.Errorf(gp.CodeInternal, "could not generate a webhook secret") + } + t.WebhookSecret = secret + } + if err := p.store.updateToken(*t); err != nil { + return gp.Errorf(gp.CodeInternal, "could not store the token") + } + // updateToken only fills blanks, so a concurrent first use may have stored a + // different secret first: read back what won, or hooks signed with ours fail. + won, err := p.store.loadToken(t.ID) + if err != nil { + return gp.Errorf(gp.CodeInternal, "could not store the token") + } + *t = won + return nil +} + +// loadTokenIdentity is loadIdentity for operations that only a token supports. +func (p *ghProvider) loadTokenIdentity(id string) (tokenRecord, error) { + ident, err := p.loadIdentity(id) + if err != nil { + return tokenRecord{}, err + } + if ident.token == nil { + return tokenRecord{}, gp.Errorf(gp.CodeUnsupported, "this identity is a GitHub App: its webhook is managed by the App") + } + return *ident.token, nil +} + +// asBotRecord lets the webhook parser treat a token like an App: it addresses +// the identity by the token owner's login and compares users by that login. +func (t tokenRecord) asBotRecord() appRecord { + return appRecord{ID: t.ID, Slug: t.Login, IsUser: true} +} + +func (p *ghProvider) beginTokenSetup(req gp.BeginSetupRequest) (gp.SetupStep, error) { + if req.WebhookURL == "" { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "webhook_url is required") + } + state, err := randomHex(24) + if err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") + } + if err := p.store.saveSetup(setupSession{ + State: state, Scope: string(req.Scope), AgentID: req.AgentID, Kind: kindToken, ExpiresAt: p.now().Add(setupSessionLife), + }); err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not start setup") + } + prompt := "Create a personal access token on GitHub, ideally on a dedicated machine-user account, and paste it below. " + + "Fine-grained token: pick the repositories and grant Contents, Pull requests, Issues, Commit statuses and Webhooks (read and write) plus Metadata (read). " + + "Classic token: the repo and admin:repo_hook scopes. " + + "Paca registers the webhook on each repository you link, which is why the token needs the webhook permission." + return gp.SetupStep{ + Kind: gp.StepTokenForm, + Prompt: prompt, + State: state, + Form: []gp.FormField{ + {Name: fieldToken, Label: "Personal access token", Secret: true, Required: true, + Help: "Used for every API call and for git clone, fetch and push. It acts as its owner on GitHub."}, + }, + }, nil +} + +type ghAuthUser struct { + Login string `json:"login"` + Name string `json:"name"` + Type string `json:"type"` +} + +func (p *ghProvider) completeToken(s setupSession, params map[string]string) (gp.Identity, error) { + token := strings.TrimSpace(params[fieldToken]) + secret := strings.TrimSpace(params[fieldWebhookSecret]) + if token == "" { + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "the access token is required") + } + switch { + case secret == "": + var err error + if secret, err = randomHex(32); err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not generate a webhook secret") + } + case len(secret) < minWebhookSecretLen: + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "the webhook secret must be at least %d characters", minWebhookSecretLen) + } + // Prove the token before spending the state: a typo can be retried. + var u ghAuthUser + status, text, headers, err := (&ghAPI{token: token}).doRaw("GET", "/user", nil, "") + switch { + case err != nil: + switch gp.AsError(err).Code { + case gp.CodeForbidden, gp.CodeNotFound: + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "GitHub rejected the token: check that it is correct and has not expired") + } + return gp.Identity{}, err + case status >= 400: + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "GitHub rejected the token") + } + if err := decodeInto(text, &u); err != nil { + return gp.Identity{}, err + } + if u.Login == "" { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "GitHub returned an incomplete user") + } + + claimed, err := p.store.claimSetupCode(s.State) + if err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + if !claimed { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "this setup was already completed") + } + rec := tokenRecord{Login: u.Login, Name: u.Name, Token: token, WebhookSecret: secret} + id, err := p.store.createToken(rec) + if err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not store the token") + } + rec.ID = id + s.CodeUsed, s.Completed = true, true + if err := p.store.updateSetup(s); err != nil { + // The state is spent and the caller sees a failure: do not leave a token + // nobody was told about. + _ = p.store.deleteToken(id) + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + ident := identityOfToken(rec) + if exp := tokenExpiry(headers); !exp.IsZero() { + ident.ExpiresAt = exp + } + return ident, nil +} + +func identityOfToken(t tokenRecord) gp.Identity { + name := t.Login + if t.Name != "" { + name = t.Name + " (" + t.Login + ")" + } + return gp.Identity{ + Ref: gp.IdentityRef{ID: t.ID, Handle: t.Login}, + Host: githubHost, + Kind: gp.IdentityToken, + DisplayName: name, + } +} + +// tokenExpiry reads the expiry GitHub reports for tokens that have one +// ("2026-11-30 10:00:00 UTC"); zero when there is none. +func tokenExpiry(h map[string]string) time.Time { + v := strings.TrimSpace(headerOf(h, "github-authentication-token-expiration")) + if v == "" { + return time.Time{} + } + t, err := time.Parse("2006-01-02 15:04:05 MST", v) + if err != nil { + return time.Time{} + } + return t.UTC() +} + +// listTokenRepositories pages through the repositories the token can reach. +func (p *ghProvider) listTokenRepositories(tok tokenRecord, req gp.ListReposRequest) (gp.RepositoryPage, error) { + per := req.PageSize + if per <= 0 || per > 100 { + per = 30 + } + page := 1 + if req.Cursor != "" { + n, err := strconv.Atoi(req.Cursor) + if err != nil || n < 1 { + return gp.RepositoryPage{}, gp.Errorf(gp.CodeValidation, "invalid cursor") + } + page = n + } + q := strings.ToLower(strings.TrimSpace(req.Query)) + out := gp.RepositoryPage{Items: []gp.Repository{}} + // GitHub cannot filter this listing, so with a query the pages are scanned + // (bounded) until a page's worth of matches is found: a match on a later page + // is not hidden behind an empty first page. + for scanned := 0; scanned < maxTokenListPages; scanned++ { + var batch []ghRepoJSON + path := "/user/repos?affiliation=owner,collaborator,organization_member&sort=full_name&per_page=" + strconv.Itoa(per) + "&page=" + strconv.Itoa(page) + if err := (&ghAPI{token: tok.Token}).get(path, &batch); err != nil { + return gp.RepositoryPage{}, err + } + for _, r := range batch { + if q != "" && !strings.Contains(strings.ToLower(r.FullName), q) { + continue + } + out.Items = append(out.Items, gp.Repository{Ref: repoRefOf(r, 0), Private: r.Private, Description: r.Description}) + } + page++ + if len(batch) < per { + return out, nil + } + if q == "" || len(out.Items) >= per { + break + } + } + out.NextCursor = strconv.Itoa(page) + return out, nil +} + +// maxTokenListPages bounds how many pages one filtered listing scans. +const maxTokenListPages = 10 + +// resolveWithToken looks a repository up as a token identity. A repository the +// token cannot see is not_found, like an App that is not installed there. +func (p *ghProvider) resolveWithToken(tok tokenRecord, owner, name string) (gp.ResolvedRepository, error) { + var r ghRepoJSON + if err := (&ghAPI{token: tok.Token}).get("/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(name), &r); err != nil { + return gp.ResolvedRepository{}, err + } + return gp.ResolvedRepository{ + Repository: gp.Repository{Ref: repoRefOf(r, 0), Private: r.Private, Description: r.Description}, + Identity: gp.IdentityRef{ID: tok.ID, Handle: tok.Login}, + }, nil +} + +// ── repository webhooks ────────────────────────────────────────────────────── + +type ghHook struct { + ID int64 `json:"id"` + Active bool `json:"active"` + Config struct { + URL string `json:"url"` + } `json:"config"` +} + +// EnsureWebhook implements gp.WebhookManager for token identities. It is +// idempotent: a hook already pointing at the URL is updated (its secret and +// events refreshed) instead of duplicated. +func (p *ghProvider) EnsureWebhook(_ context.Context, req gp.EnsureWebhookRequest) (gp.WebhookRef, error) { + if strings.TrimSpace(req.URL) == "" { + return gp.WebhookRef{}, gp.Errorf(gp.CodeValidation, "url is required") + } + tok, err := p.loadTokenIdentity(req.Target.Identity.ID) + if err != nil { + return gp.WebhookRef{}, err + } + repo, err := repoPath(req.Target.Repo.FullPath) + if err != nil { + return gp.WebhookRef{}, err + } + api := &ghAPI{token: tok.Token} + body := map[string]any{ + "name": "web", "active": !isLocalURL(req.URL), "events": hookEvents, + "config": map[string]any{"url": req.URL, "content_type": "json", "secret": tok.WebhookSecret, "insecure_ssl": "0"}, + } + var hooks []ghHook + if err := api.get(repo+"/hooks?per_page=100", &hooks); err != nil { + return gp.WebhookRef{}, err + } + for _, h := range hooks { + if h.Config.URL != req.URL { + continue + } + var out ghHook + if err := api.send("PATCH", repo+"/hooks/"+strconv.FormatInt(h.ID, 10), body, &out); err != nil { + return gp.WebhookRef{}, err + } + return gp.WebhookRef{ID: strconv.FormatInt(h.ID, 10)}, nil + } + var out ghHook + if err := api.send("POST", repo+"/hooks", body, &out); err != nil { + return gp.WebhookRef{}, err + } + if out.ID == 0 { + return gp.WebhookRef{}, gp.Errorf(gp.CodeInternal, "GitHub returned no webhook id") + } + return gp.WebhookRef{ID: strconv.FormatInt(out.ID, 10)}, nil +} + +// RemoveWebhook implements gp.WebhookManager. A webhook that is already gone is fine. +func (p *ghProvider) RemoveWebhook(_ context.Context, req gp.RemoveWebhookRequest) (gp.Empty, error) { + if _, err := strconv.ParseUint(req.Webhook.ID, 10, 63); err != nil { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "webhook id is not a GitHub hook id") + } + tok, err := p.loadTokenIdentity(req.Target.Identity.ID) + if err != nil { + return gp.Empty{}, err + } + repo, err := repoPath(req.Target.Repo.FullPath) + if err != nil { + return gp.Empty{}, err + } + if err := (&ghAPI{token: tok.Token}).send("DELETE", repo+"/hooks/"+req.Webhook.ID, nil, nil); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.Empty{}, nil + } + return gp.Empty{}, err + } + return gp.Empty{}, nil +} + +// hookEvents are the events a repository webhook subscribes to: the same +// names the App manifest lists. +var hookEvents = func() []string { + ev := append([]string(nil), manifestEvents...) + sort.Strings(ev) + return ev +}() + +// ── commit statuses (tokens cannot create check runs) ──────────────────────── + +// setCommitStatus reports a run's state as a commit status: only a GitHub App +// can create check runs. The status ref is the commit, and a later call with the +// same title replaces the earlier status in GitHub's UI. +func (p *ghProvider) setCommitStatus(api *ghAPI, repo string, req gp.SetStatusRequest, summary string) (gp.StatusResult, error) { + state := "pending" + switch req.State { + case gp.RunQueued, gp.RunInProgress: + case gp.RunSuccess: + state = "success" + case gp.RunFailure: + state = "failure" + case gp.RunCancelled: + state = "error" + } + sha := req.SHA + if sha == "" { + sha = req.StatusRef + } + if sha == "" && req.Subject.Kind == gp.SubjectChangeRequest && req.Subject.Number != "" { + num, err := number(req.Subject.Number) + if err != nil { + return gp.StatusResult{}, err + } + pr, err := p.getPR(api, repo, num) + if err != nil { + return gp.StatusResult{}, err + } + sha = pr.Head.SHA + } + if sha == "" { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "sha or a change request is required") + } + body := map[string]any{"state": state, "context": req.Title, "description": clipRunes(summary, 140)} + if req.URL != "" { + body["target_url"] = req.URL + } + if err := api.send("POST", repo+"/statuses/"+url.PathEscape(sha), body, nil); err != nil { + return gp.StatusResult{}, err + } + return gp.StatusResult{StatusRef: sha}, nil +} + +func clipRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} diff --git a/backend/spi_token_test.go b/backend/spi_token_test.go new file mode 100644 index 0000000..f41e32c --- /dev/null +++ b/backend/spi_token_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "sync" + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +const ( + patSecret = "ghp_fake_personal_access_token" + patHookKey = "a-webhook-secret-of-16+chars" + patHookURL = "https://paca.example.com/api/v1/git/webhooks/key1" + patLogin = "paca-machine" + patIdentity = "token-row-1" + patRepoSHA = "feedbeef" + patCheckName = "Paca review" +) + +// fakeUserGitHub is a GitHub that knows one personal access token and one +// repository, enough to drive the token identity paths. +type fakeUserGitHub struct { + t *testing.T + mu sync.Mutex + calls []string + hooks map[int64]map[string]any + next int64 + // statuses are the bodies posted to /statuses/. + statuses []map[string]any + expiry string +} + +func newFakeUserGitHub(t *testing.T) *fakeUserGitHub { + t.Helper() + g := &fakeUserGitHub{t: t, hooks: map[int64]map[string]any{}, next: 700} + old := fetch + fetch = g.fetch + t.Cleanup(func() { fetch = old }) + return g +} + +func (g *fakeUserGitHub) called(prefix string) int { + g.mu.Lock() + defer g.mu.Unlock() + n := 0 + for _, c := range g.calls { + if strings.HasPrefix(c, prefix) { + n++ + } + } + return n +} + +func (g *fakeUserGitHub) fetch(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + g.mu.Lock() + defer g.mu.Unlock() + u, _ := url.Parse(rawURL) + if u.Host != "api.github.com" { + g.t.Errorf("request outside the allow-list: %s", rawURL) + return nil, fmt.Errorf("blocked") + } + path := u.Path + g.calls = append(g.calls, method+" "+path) + if headers["Authorization"] != "Bearer "+patSecret { + return resp(401, map[string]string{"message": "Bad credentials"}) + } + var payload map[string]any + if body != "" { + _ = json.Unmarshal([]byte(body), &payload) + } + switch { + case method == "GET" && path == "/user": + r, _ := resp(200, map[string]any{"login": patLogin, "name": "Paca Machine", "type": "User"}) + if g.expiry != "" { + r.Headers["github-authentication-token-expiration"] = g.expiry + } + return r, nil + case method == "GET" && path == "/user/repos": + return resp(200, []map[string]any{{"id": 1, "full_name": "acme/web", "default_branch": "main", "private": true}}) + case method == "GET" && path == "/repos/acme/web": + return resp(200, map[string]any{"id": 1, "full_name": "acme/web", "default_branch": "main", "private": true}) + case method == "GET" && path == "/repos/acme/web/hooks": + var out []map[string]any + for id, h := range g.hooks { + out = append(out, map[string]any{"id": id, "active": h["active"], "config": h["config"]}) + } + return resp(200, out) + case method == "POST" && path == "/repos/acme/web/hooks": + g.next++ + g.hooks[g.next] = payload + return resp(201, map[string]any{"id": g.next}) + case strings.HasPrefix(path, "/repos/acme/web/hooks/"): + id, _ := strconv.ParseInt(strings.TrimPrefix(path, "/repos/acme/web/hooks/"), 10, 64) + if _, ok := g.hooks[id]; !ok { + return notFound() + } + if method == "DELETE" { + delete(g.hooks, id) + return text(204, "") + } + g.hooks[id] = payload + return resp(200, map[string]any{"id": id}) + case method == "POST" && path == "/repos/acme/web/statuses/"+patRepoSHA: + g.statuses = append(g.statuses, payload) + return resp(201, map[string]any{"id": 1}) + } + return notFound() +} + +func tokenProvider(t *testing.T) (*ghProvider, *fakeUserGitHub, *memStore) { + t.Helper() + g := newFakeUserGitHub(t) + st := newMemStore() + st.tokens[patIdentity] = tokenRecord{ID: patIdentity, Login: patLogin, Token: patSecret, WebhookSecret: patHookKey} + return newGHProvider(st), g, st +} + +func tokenTarget() gp.Target { + return gp.Target{Identity: gp.IdentityRef{ID: patIdentity, Handle: patLogin}, Repo: gp.RepoRef{Host: githubHost, FullPath: "acme/web"}} +} + +func beginToken(t *testing.T, p *ghProvider) gp.SetupStep { + t.Helper() + step, err := p.BeginIdentitySetup(bg, gp.BeginSetupRequest{ + Scope: gp.SetupShared, Host: githubHost, WebhookURL: patHookURL, CallbackURL: "https://paca.example.com/git/setup/callback", + Params: map[string]string{"kind": "token"}, + }) + if err != nil { + t.Fatal(err) + } + return step +} + +func TestTokenSetup_FormThenValidatedCompletion(t *testing.T) { + p, g, st := tokenProvider(t) + step := beginToken(t, p) + if step.Kind != gp.StepTokenForm || step.State == "" || len(step.Form) != 1 || step.Form[0].Name != fieldToken { + t.Fatalf("step = %+v", step) + } + if strings.Contains(step.Prompt, patHookURL) { + t.Error("Paca registers the webhook itself: the prompt must not send the user to do it by hand") + } + if s, _ := st.loadSetup(step.State); s.Kind != kindToken { + t.Fatalf("the session must remember it is a token setup: %+v", s) + } + + complete := func(token, secret string) (gp.Identity, error) { + return p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: githubHost, State: step.State, + Params: map[string]string{"token": token, "webhook_secret": secret}}) + } + if _, err := complete(patSecret, "short"); !hasCode(err, gp.CodeValidation) { + t.Errorf("short webhook secret: %v", err) + } + if _, err := complete("", patHookKey); !hasCode(err, gp.CodeValidation) { + t.Errorf("missing token: %v", err) + } + // A mistyped token is a validation error and does not spend the state. + if _, err := complete("ghp_wrong", patHookKey); !hasCode(err, gp.CodeValidation) { + t.Fatalf("rejected token: %v", err) + } + if len(st.tokens) != 1 { + t.Fatal("nothing may be stored for a token GitHub rejected") + } + g.expiry = "2030-01-02 03:04:05 UTC" + ident, err := complete(patSecret, patHookKey) + if err != nil { + t.Fatalf("retry with the right token: %v", err) + } + if ident.Kind != gp.IdentityToken || ident.Ref.Handle != patLogin || ident.Host != githubHost || ident.Ref.ID == "" { + t.Fatalf("identity = %+v", ident) + } + if ident.ExpiresAt.Year() != 2030 { + t.Errorf("expiry = %v, want the one GitHub reports", ident.ExpiresAt) + } + stored, err := st.loadToken(ident.Ref.ID) + if err != nil || stored.Token != patSecret || stored.WebhookSecret != patHookKey || stored.Login != patLogin { + t.Fatalf("stored = %+v %v", stored, err) + } + if strings.Contains(fmt.Sprint(ident), patSecret) { + t.Error("the identity must not carry the token") + } + // Finished: the same state cannot create a second identity. + if _, err := complete(patSecret, patHookKey); !hasCode(err, gp.CodeForbidden) { + t.Errorf("reusing the state: %v", err) + } +} + +func TestTokenIdentity_ResolveCredentialAndList(t *testing.T) { + p, _, _ := tokenProvider(t) + res, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{Host: githubHost, PathOrURL: "https://github.com/acme/web", + Candidates: []gp.IdentityRef{{ID: "no-such-identity"}, {ID: patIdentity}}}) + if err != nil || res.Identity.ID != patIdentity || res.Repository.Ref.FullPath != "acme/web" || res.Repository.Ref.ExternalID != "1" { + t.Fatalf("resolve = %+v %v", res, err) + } + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{Host: githubHost, PathOrURL: "acme/hidden", Candidates: []gp.IdentityRef{{ID: patIdentity}}}); !hasCode(err, gp.CodeNotFound) { + t.Errorf("a repository the token cannot see: %v", err) + } + + for _, access := range []gp.Access{gp.AccessRead, gp.AccessWrite} { + c, err := p.MintCredential(bg, gp.MintRequest{Target: tokenTarget(), Access: access, TTLSeconds: 60}) + if err != nil || c.Secret != patSecret || c.Username == "" || !c.ExpiresAt.IsZero() { + t.Errorf("%s credential = %+v %v: static, no expiry", access, c, err) + } + } + if _, err := p.MintCredential(bg, gp.MintRequest{Target: tokenTarget(), Access: "admin"}); !hasCode(err, gp.CodeValidation) { + t.Errorf("bad access: %v", err) + } + + page, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: gp.IdentityRef{ID: patIdentity}, PageSize: 30}) + if err != nil || len(page.Items) != 1 || page.Items[0].Ref.FullPath != "acme/web" || page.NextCursor != "" { + t.Fatalf("list = %+v %v", page, err) + } +} + +func TestTokenIdentity_WebhookManagement(t *testing.T) { + p, g, _ := tokenProvider(t) + req := gp.EnsureWebhookRequest{Target: tokenTarget(), URL: patHookURL} + a, err := p.EnsureWebhook(bg, req) + if err != nil || a.ID == "" { + t.Fatalf("ensure: %+v %v", a, err) + } + h := g.hooks[mustInt(t, a.ID)] + cfg, _ := h["config"].(map[string]any) + if cfg["url"] != patHookURL || cfg["secret"] != patHookKey || cfg["content_type"] != "json" { + t.Fatalf("hook config = %+v", cfg) + } + b, err := p.EnsureWebhook(bg, req) + if err != nil || b.ID != a.ID || len(g.hooks) != 1 { + t.Fatalf("ensure again must update the same hook: %+v %v hooks=%d", b, err, len(g.hooks)) + } + if _, err := p.RemoveWebhook(bg, gp.RemoveWebhookRequest{Target: tokenTarget(), Webhook: a}); err != nil || len(g.hooks) != 0 { + t.Fatalf("remove: %v hooks=%d", err, len(g.hooks)) + } + if _, err := p.RemoveWebhook(bg, gp.RemoveWebhookRequest{Target: tokenTarget(), Webhook: a}); err != nil { + t.Errorf("removing it again: %v", err) + } + if _, err := p.RemoveWebhook(bg, gp.RemoveWebhookRequest{Target: tokenTarget(), Webhook: gp.WebhookRef{ID: "../x"}}); !hasCode(err, gp.CodeValidation) { + t.Errorf("a hook id that is not a number: %v", err) + } + + // An App's webhook is app-level: there is nothing to register per repository. + pa, _, _ := seededProvider(t) + if _, err := pa.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: webTarget(), URL: patHookURL}); !hasCode(err, gp.CodeUnsupported) { + t.Errorf("app identity: %v", err) + } +} + +func tokenDelivery(event, id string, payload map[string]any, secret string) gp.WebhookRequest { + body, _ := json.Marshal(payload) + return gp.WebhookRequest{ + Identity: gp.IdentityRef{ID: patIdentity, Handle: patLogin}, Method: "POST", Body: body, + Headers: map[string]string{"x-github-event": event, "x-github-delivery": id, "x-hub-signature-256": sign(body, secret)}, + } +} + +func TestTokenIdentity_WebhookIsVerifiedWithItsOwnSecretAndAddressedByLogin(t *testing.T) { + p, _, _ := tokenProvider(t) + comment := map[string]any{"action": "created", "repository": repoPayload, "sender": userSender, + "issue": map[string]any{"number": 1, "title": "Add login", "user": userSender, "pull_request": map[string]any{"url": "x"}}, + "comment": map[string]any{"id": 11, "body": "@" + patLogin + " please fix the test"}} + + evs, err := p.VerifyAndParseWebhook(bg, tokenDelivery("issue_comment", "d1", comment, patHookKey)) + if err != nil || len(evs) != 1 || len(evs[0].Addressed) != 1 || evs[0].Addressed[0].IdentityID != patIdentity || evs[0].Addressed[0].Via != gp.ViaMention { + t.Fatalf("mention = %+v %v", evs, err) + } + // The App's secret does not verify a token identity's deliveries. + if _, err := p.VerifyAndParseWebhook(bg, tokenDelivery("issue_comment", "d2", comment, fakeWebhookSecret)); !hasCode(err, gp.CodeInvalidSignature) { + t.Errorf("wrong secret: %v", err) + } + + // A review requested from the token's user is addressed to it; the user's + // login has no "[bot]" suffix, unlike an App's account. + rr := func(login string) gp.WebhookRequest { + return tokenDelivery("pull_request", "d3-"+login, map[string]any{"action": "review_requested", "repository": repoPayload, "sender": userSender, + "requested_reviewer": map[string]any{"login": login, "type": "User"}, + "pull_request": map[string]any{"number": 4, "title": "Add x", "user": userSender, + "head": map[string]any{"ref": "x", "sha": "s1", "repo": map[string]string{"full_name": "acme/web"}}, "base": map[string]any{"ref": "main"}}}, patHookKey) + } + if evs, err := p.VerifyAndParseWebhook(bg, rr(patLogin)); err != nil || len(evs) != 1 || evs[0].Addressed[0].Via != gp.ViaReviewRequest { + t.Errorf("review requested from the token's user: %+v %v", evs, err) + } + if evs, err := p.VerifyAndParseWebhook(bg, rr("someone-else")); err != nil || len(evs) != 0 { + t.Errorf("review requested from somebody else: %+v %v", evs, err) + } +} + +func TestTokenIdentity_StatusIsACommitStatus(t *testing.T) { + p, g, _ := tokenProvider(t) + res, err := p.SetStatus(bg, gp.SetStatusRequest{Target: tokenTarget(), Title: patCheckName, State: gp.RunInProgress, SHA: patRepoSHA, Summary: "working", URL: "https://paca.example.com/r/1"}) + if err != nil || res.StatusRef != patRepoSHA { + t.Fatalf("status = %+v %v", res, err) + } + if g.called("POST /repos/acme/web/check-runs") != 0 { + t.Fatal("a token cannot create check runs") + } + // A later call updates through the ref, which is the commit. + if _, err := p.SetStatus(bg, gp.SetStatusRequest{Target: tokenTarget(), Title: patCheckName, State: gp.RunFailure, StatusRef: res.StatusRef, Summary: strings.Repeat("é", 300)}); err != nil { + t.Fatal(err) + } + if len(g.statuses) != 2 || g.statuses[0]["state"] != "pending" || g.statuses[0]["context"] != patCheckName || g.statuses[0]["target_url"] != "https://paca.example.com/r/1" { + t.Fatalf("statuses = %+v", g.statuses) + } + last := g.statuses[1] + if last["state"] != "failure" || len([]rune(last["description"].(string))) > 140 { + t.Errorf("failure status = %+v: GitHub caps descriptions at 140 characters", last) + } +} + +func TestTokenIdentity_DeleteForgetsTheToken(t *testing.T) { + p, _, st := tokenProvider(t) + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: gp.IdentityRef{ID: patIdentity}}); err != nil { + t.Fatal(err) + } + if _, ok := st.tokens[patIdentity]; ok { + t.Fatal("the token should be gone") + } + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: gp.IdentityRef{ID: patIdentity}}); err != nil { + t.Errorf("deleting it again: %v", err) + } + if _, err := p.MintCredential(bg, gp.MintRequest{Target: tokenTarget(), Access: gp.AccessRead}); !hasCode(err, gp.CodeNotFound) { + t.Errorf("credential for a deleted identity: %v", err) + } +} + +func mustInt(t *testing.T, s string) int64 { + t.Helper() + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + t.Fatal(err) + } + return n +} + +// hasCode reports whether err is a provider error with the given code. +func hasCode(err error, code gp.ErrorCode) bool { + return err != nil && gp.AsError(err).Code == code +} + +// A token migrated from the previous release has no login and no webhook secret: +// the first use asks GitHub who owns it and generates the secret, once. +func TestTokenIdentity_MigratedTokenIsCompletedOnFirstUse(t *testing.T) { + p, g, st := tokenProvider(t) + st.tokens["legacy"] = tokenRecord{ID: "legacy", Token: patSecret} + tg := gp.Target{Identity: gp.IdentityRef{ID: "legacy"}, Repo: gp.RepoRef{Host: githubHost, FullPath: "acme/web"}} + + if _, err := p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: tg, URL: patHookURL}); err != nil { + t.Fatal(err) + } + got := st.tokens["legacy"] + if got.Login != patLogin || len(got.WebhookSecret) < minWebhookSecretLen { + t.Fatalf("token after first use = %+v", got) + } + for _, h := range g.hooks { + if cfg, _ := h["config"].(map[string]any); cfg["secret"] != got.WebhookSecret { + t.Errorf("the hook must be signed with the stored secret: %+v", cfg) + } + } + secret := got.WebhookSecret + if _, err := p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: tg, URL: patHookURL}); err != nil { + t.Fatal(err) + } + if st.tokens["legacy"].WebhookSecret != secret || g.called("GET /user") != 1 { + t.Error("completion must happen once") + } +} + +// Leaving the webhook secret empty is fine: the provider generates one. +func TestTokenSetup_GeneratesTheWebhookSecretWhenLeftEmpty(t *testing.T) { + p, _, st := tokenProvider(t) + step := beginToken(t, p) + ident, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: githubHost, State: step.State, + Params: map[string]string{"token": patSecret}}) + if err != nil { + t.Fatal(err) + } + if got := st.tokens[ident.Ref.ID].WebhookSecret; len(got) < minWebhookSecretLen { + t.Fatalf("generated secret = %q", got) + } +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go index b34c443..db131b6 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -18,10 +18,17 @@ import ( // webhook secret and turns the delivery into zero or more normalized events. // Event types Paca has no use for are dropped, not guessed at. func (p *ghProvider) VerifyAndParseWebhook(_ context.Context, req gp.WebhookRequest) ([]gp.Event, error) { - app, err := p.loadApp(req.Identity.ID) + ident, err := p.loadIdentity(req.Identity.ID) if err != nil { return nil, err } + var app appRecord + if ident.app != nil { + app = *ident.app + } else { + app = ident.token.asBotRecord() + app.WebhookSecret = ident.token.WebhookSecret + } sig := req.Headers["x-hub-signature-256"] if app.WebhookSecret == "" || !verifyHMAC(req.Body, app.WebhookSecret, sig) { return nil, gp.Errorf(gp.CodeInvalidSignature, "webhook signature does not match") @@ -325,9 +332,16 @@ func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { return ev, true } -// isApp reports whether u is the app's own bot account ("[bot]"). +// isApp reports whether u is the identity's own account: "[bot]" for an +// App, the owner's login for a token. func isApp(u *ghUser, app appRecord) bool { - return u != nil && app.Slug != "" && strings.EqualFold(u.Login, app.Slug+"[bot]") + if u == nil || app.Slug == "" { + return false + } + if app.IsUser { + return strings.EqualFold(u.Login, app.Slug) + } + return strings.EqualFold(u.Login, app.Slug+"[bot]") } // mentionsHandle reports whether text @-mentions handle (also as handle[bot]). diff --git a/backend/spi_writes.go b/backend/spi_writes.go index 57e523b..3f21465 100644 --- a/backend/spi_writes.go +++ b/backend/spi_writes.go @@ -218,6 +218,9 @@ func (p *ghProvider) SetStatus(_ context.Context, req gp.SetStatusRequest) (gp.S if blank(summary) { summary = req.Title } + if api.user { + return p.setCommitStatus(api, repo, req, summary) + } body := map[string]any{"name": req.Title, "status": status, "output": map[string]any{"title": req.Title, "summary": summary}} if conclusion != "" { body["conclusion"] = conclusion diff --git a/plugin.json b/plugin.json index ea2e064..fe18a5d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,8 +1,8 @@ { "id": "com.paca.github", "displayName": "GitHub", - "description": "GitHub provider for Paca's Git integration: GitHub App identities, repository access, credentials and webhooks.", - "version": "0.5.0", + "description": "GitHub provider for Paca's Git integration: GitHub App or personal access token connections, repository access, credentials and webhooks.", + "version": "0.6.0", "minCoreVersion": "v0.20.0", "capabilities": ["git-provider"], "permissions": ["db.read", "db.write"], @@ -10,7 +10,8 @@ "allowedConfigKeys": ["ENCRYPTION_KEY"], "allowedOutboundDomains": ["api.github.com"], "sensitiveFields": { - "github_apps": ["webhook_secret_enc", "private_key_enc"] + "github_apps": ["webhook_secret_enc", "private_key_enc"], + "github_tokens": ["token_enc", "webhook_secret_enc"] } } }