Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ NUXT_OG_IMAGE_SECRET=
# Signs same-origin image proxy addresses. Keep it separate from NUXT_TOKEN_KEY.
NUXT_IMAGE_PROXY_KEY=
NUXT_AI_READY_RUNTIME_SYNC_SECRET=
# Every Worker GitHub read uses the read App (SKILLD_READ_APP_*): metadata read
# only, installed on skilld-dev, with a quota nothing else spends. The key is a
# PKCS #8 PEM. Without the App secrets, as in local development, reads use
# GITHUB_TOKEN.
#
# With the App set, GITHUB_TOKEN is a fallback only: it repeats a read GitHub
# denied the App, such as an organization that restricts Apps. Builds try
# ARTIFACT_GITHUB_TOKEN first. Each fallback read is logged as an app-denied event.
# Both may stay empty.
GITHUB_TOKEN=
# Public Artifact builds read GitHub with this token when it is set. Use a token
# from another GitHub account, so runs keep a quota the registry sync cannot spend.
ARTIFACT_GITHUB_TOKEN=
# Public Artifact builds read GitHub as the read App: metadata read only,
# installed on skilld-dev. Its installation has a quota nothing else spends.
# The key is a PKCS #8 PEM. Without these, builds use the tokens above.
SKILLD_READ_APP_ID=
SKILLD_READ_APP_INSTALLATION_ID=
SKILLD_READ_APP_PRIVATE_KEY_PKCS8=
Expand Down
14 changes: 7 additions & 7 deletions docs/ops/artifact-delivery-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,17 @@ Preserve these existing values:
- `NUXT_OAUTH_GITHUB_CLIENT_ID`
- `NUXT_OAUTH_GITHUB_CLIENT_SECRET`
- `NUXT_TOKEN_KEY`
- `GITHUB_TOKEN`

The existing `GITHUB_TOKEN` reads public [GitHub](https://github.com) sources for the registry sync and `/gh` pages.
Every Worker GitHub read uses the read App `skilld-dev-registry-reads`: the registry sync, repository maintenance, mention checks, `/gh` pages and public Artifact builds. Set `SKILLD_READ_APP_ID`, `SKILLD_READ_APP_INSTALLATION_ID` and `SKILLD_READ_APP_PRIVATE_KEY_PKCS8` (a PKCS #8 PEM). The App has metadata read permission only, and its installation has its own quota.

Public Artifact builds read GitHub with a credential of their own, in this order:
Personal tokens are fallbacks only. They repeat a read GitHub denied the App, such as an organization that restricts Apps:

1. The read App `skilld-dev-registry-reads`: `SKILLD_READ_APP_ID`, `SKILLD_READ_APP_INSTALLATION_ID` and `SKILLD_READ_APP_PRIVATE_KEY_PKCS8` (a PKCS #8 PEM). It has metadata read permission only, and its installation has its own quota.
2. `ARTIFACT_GITHUB_TOKEN`.
3. `GITHUB_TOKEN`.
1. Builds try `ARTIFACT_GITHUB_TOKEN`, then `GITHUB_TOKEN`, then the token of the signed-in account that asked for the run.
2. Every other caller tries `GITHUB_TOKEN`.

If the App secrets are set but unusable, builds read with the next credential and emit an `artifact-github-credential` event with the reason.
Each denial emits an `app-denied` event: `github-credential` for registry reads and `artifact-github-credential` for builds. The `github.credential` field names the fallback, or `none`. Without a fallback, the read fails for that Repository only: a build answers `SOURCE_ACCESS_DENIED`, and the sync records a failure without pausing.

If the App secrets are set but unusable, reads use the fallback and emit an event with the reason.
Keep the read App apart from the `GITHUB_APP_*` secrets below. Those belong to private delivery.

Changing `NUXT_TOKEN_KEY` breaks stored GitHub credentials.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default withArtifactProblems(defineApiHandler({
repositoryId: privateAccess.repositoryId,
}
: { visibility: 'public' },
requesterAccountId: user?.id ?? null,
})
if (result._tag === 'idempotency-conflict') {
throw createError({
Expand Down
15 changes: 13 additions & 2 deletions layers/artifact-delivery/server/utils/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { checkArtifactSource, checksBlockArtifact, checksPermitSigning } from './checks'
import { canonicalJson, digestHex } from './encoding'
import { isRetryableProblem, storedFilesPassLoadRules } from './github-source'
import { withRequesterFallback } from './requester-github'
import {
ARTIFACT_POLICY_VERSION,
BYTE_COMPATIBLE_POLICY_VERSIONS,
Expand All @@ -46,6 +47,12 @@ export interface ArtifactBuildDependencies {
db: D1Database
github: PublicGithubSourceClient
privateGithub?: (row: ResolutionRow) => Promise<PublicGithubSourceClient | SourceRejection>
/**
* The GitHub client of the signed-in account that asked for a public
* Resolution, or null. A build uses it only for a read the shared
* credential's quota refused, and only for that account's own Resolution.
*/
requesterGithub?: (row: ResolutionRow) => Promise<PublicGithubSourceClient | null>
bucket: R2Bucket
privateArtifacts?: {
put: (input: {
Expand Down Expand Up @@ -824,8 +831,12 @@ async function githubForResolution(
dependencies: ArtifactBuildDependencies,
row: ResolutionRow,
): Promise<PublicGithubSourceClient | SourceRejection> {
if (row.visibility === 'public')
return dependencies.github
if (row.visibility === 'public') {
const requesterGithub = dependencies.requesterGithub
return requesterGithub
? withRequesterFallback(dependencies.github, () => requesterGithub(row))
: dependencies.github
}
if (!dependencies.privateGithub) {
return {
_tag: 'rejected',
Expand Down
86 changes: 1 addition & 85 deletions layers/artifact-delivery/server/utils/github-app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { decryptToken, encryptToken } from '#layers/identity/server/utils/crypto'
import { requestInstallationToken, signGithubAppJwt } from '#shared/server/github-app-credential'
import { base64ToBytes, bytesToBase64Url } from './encoding'
import { fetchNoRedirect } from './fetch-no-redirect'

Expand Down Expand Up @@ -48,11 +49,6 @@ const githubUserSchema = z.object({
login: z.string().min(1).max(100),
}).passthrough()

const installationTokenSchema = z.object({
token: z.string().min(1).max(2048),
expires_at: z.string().datetime(),
}).passthrough()

const refreshedUserTokenSchema = z.object({
access_token: z.string().min(1).max(2048),
expires_in: z.number().int().positive(),
Expand Down Expand Up @@ -541,93 +537,13 @@ export async function verifyGithubWebhookSignature(
return different === 0
}

export type InstallationTokenResult
= | { _tag: 'created', token: string, expiresAt: string }
/** GitHub answered 401, 403 or 404: the App, its key or the installation is gone. */
| { _tag: 'refused', status: number }

/**
* Exchange an App JWT for an installation access token.
*
* A refusal is a value. Any other failure, such as a 5xx or an unreadable
* answer, throws: it says nothing about the App.
*/
export async function requestInstallationToken(input: {
fetch: typeof globalThis.fetch
jwt: string
installationId: number
body: { repository_ids?: number[], permissions: Record<string, 'read'> }
}): Promise<InstallationTokenResult> {
const fetched = await fetchNoRedirect(input.fetch, `${GITHUB_API}/app/installations/${input.installationId}/access_tokens`, {
method: 'POST',
headers: {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${input.jwt}`,
'Content-Type': 'application/json',
'User-Agent': 'skilld.dev',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
},
body: JSON.stringify(input.body),
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
})
if (fetched._tag === 'unexpected-redirect')
throw new Error(`GitHub App request redirected ${fetched.status}`)
const response = fetched.response
if (response.status === 401 || response.status === 403 || response.status === 404) {
await response.body?.cancel()
return { _tag: 'refused', status: response.status }
}
if (!response.ok)
throw new Error(`GitHub App request returned ${response.status}`)
const value = installationTokenSchema.safeParse(await readBoundedJson(response, MAX_GITHUB_TOKEN_RESPONSE_BYTES))
if (!value.success)
throw new Error('GitHub App returned an invalid response')
return { _tag: 'created', token: value.data.token, expiresAt: value.data.expires_at }
}

async function createGithubAppJwt(config: GithubAppConfig): Promise<string> {
const privateKey = decodeCanonicalBase64Url(config.privateKeyPkcs8)
if (!privateKey)
throw new Error('GitHub App private key is malformed')
return await signGithubAppJwt({ issuer: config.clientId, privateKeyPkcs8: privateKey, now: config.now() })
}

/**
* An RS256 GitHub App JWT. `issuer` is the App's client ID or its App ID;
* GitHub accepts either.
*
* It is valid for ten minutes from a minute in the past, which absorbs clock
* drift between the Worker and GitHub. Ten minutes is GitHub's maximum.
*/
export async function signGithubAppJwt(input: {
issuer: string
privateKeyPkcs8: Uint8Array
/** Unix seconds. */
now: number
}): Promise<string> {
const issuedAt = input.now - 60
const header = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })))
const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({
iat: issuedAt,
exp: issuedAt + 600,
iss: input.issuer,
})))
const unsigned = `${header}.${payload}`
const imported = await crypto.subtle.importKey(
'pkcs8',
toArrayBuffer(input.privateKeyPkcs8),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
)
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
imported,
new TextEncoder().encode(unsigned),
)
return `${unsigned}.${bytesToBase64Url(new Uint8Array(signature))}`
}

async function readBoundedJson(response: Response, maximumBytes: number): Promise<unknown> {
const declared = Number(response.headers.get('content-length'))
if (Number.isFinite(declared) && declared > maximumBytes)
Expand Down
Loading