diff --git a/.github/actions/issue-token/README.md b/.github/actions/issue-token/README.md new file mode 100644 index 0000000..12456cb --- /dev/null +++ b/.github/actions/issue-token/README.md @@ -0,0 +1,110 @@ +# sentryagent/issue-token + +Issues a SentryAgent.ai OAuth2 Bearer token for an existing agent from a GitHub +Actions workflow. + +No long-lived API credentials are required. The action uses a GitHub-issued OIDC +token to authenticate with the SentryAgent.ai AgentIdP via `POST /oidc/token`. +The returned access token is automatically masked with `core.setSecret()` so it +never appears in plaintext in workflow logs. + +## Prerequisites + +### 1. Register the agent + +The agent must already exist in SentryAgent.ai. If you need to create the agent +in CI, use [`sentryagent/register-agent@v1`](../register-agent/README.md) first. + +### 2. Configure an OIDC Trust Policy for the agent + +A trust policy linking the repository to the specific agent must be registered: + +```bash +curl -X POST https://idp.sentryagent.ai/api/v1/oidc/trust-policies \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "provider": "github", + "repository": "org/your-repo", + "branch": "main", + "agentId": "" + }' +``` + +Omit `branch` to allow any branch to issue tokens for this agent. + +### 3. Grant `id-token: write` permission + +The workflow must have permission to request a GitHub OIDC token: + +```yaml +permissions: + id-token: write + contents: read +``` + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| `api-url` | Yes | Base URL of the SentryAgent.ai API (e.g. `https://idp.sentryagent.ai`) | +| `agent-id` | Yes | UUID of the agent for which to issue an access token | + +## Outputs + +| Output | Description | +|--------|-------------| +| `access-token` | Short-lived Bearer token. Masked in all log output. | +| `expires-at` | ISO 8601 timestamp indicating when the token expires. | + +## Example workflow + +```yaml +name: Deploy with Agent Token + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Issue SentryAgent access token + id: token + uses: sentryagent/issue-token@v1 + with: + api-url: https://idp.sentryagent.ai + agent-id: ${{ vars.SENTRY_AGENT_ID }} + + - name: Call authenticated API + run: | + curl -H "Authorization: Bearer ${{ steps.token.outputs.access-token }}" \ + https://my-service.example.com/deploy +``` + +## Troubleshooting + +**HTTP 403 — Trust policy violation** +No trust policy exists for this repository + agent combination. Register a trust +policy using the Prerequisites steps above. + +**HTTP 403 — Branch not permitted** +A trust policy exists but specifies a branch constraint that does not match the +current workflow's branch. Add a policy for the current branch, or remove the +branch constraint to allow all branches. + +**Failed to obtain a GitHub OIDC token** +Ensure `id-token: write` is set in the workflow's `permissions` block. + +**Token expires too quickly** +The default token TTL is set by the SentryAgent.ai server configuration. Check +`expires-at` and re-issue a token before it expires if your workflow is long-running. + +## Full documentation + +[https://docs.sentryagent.ai/github-actions](https://docs.sentryagent.ai/github-actions) diff --git a/.github/actions/issue-token/action.js b/.github/actions/issue-token/action.js new file mode 100644 index 0000000..f0eb213 --- /dev/null +++ b/.github/actions/issue-token/action.js @@ -0,0 +1,153 @@ +/** + * issue-token GitHub Action script. + * + * Flow: + * 1. Request a GitHub OIDC token via @actions/core.getIDToken() + * 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token + * 3. Set outputs: access-token (masked) and expires-at (ISO 8601) + * + * The access token is immediately registered with core.setSecret() so it never + * appears in plaintext in workflow logs. + * + * Error handling: + * - OIDC exchange failures emit a clear message with a link to the trust policy setup docs + */ + +'use strict'; + +const core = require('@actions/core'); +const { HttpClient } = require('@actions/http-client'); + +/** + * Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token for a specific agent. + * + * @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API. + * @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken(). + * @param {string} agentId - UUID of the agent for which to issue a token. + * @returns {Promise<{ accessToken: string; expiresIn: number }>} The access token and its TTL in seconds. + * @throws {Error} If the exchange fails, with a message including trust policy setup instructions. + */ +async function exchangeOIDCToken(apiUrl, oidcToken, agentId) { + const client = new HttpClient('sentryagent-issue-token/1.0'); + const url = `${apiUrl}/api/v1/oidc/token`; + + const body = JSON.stringify({ + provider: 'github', + token: oidcToken, + agentId, + }); + + let response; + try { + response = await client.post(url, body, { + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + } catch (err) { + throw new Error( + `Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` + + `Check that the api-url input is correct and the API is reachable.\n` + + `Underlying error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const rawBody = await response.readBody(); + const statusCode = response.message.statusCode ?? 0; + + if (statusCode === 403) { + throw new Error( + 'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' + + 'This usually means no trust policy has been registered for this repository.\n\n' + + 'To fix this, register a trust policy by calling:\n' + + ` POST ${apiUrl}/oidc/trust-policies\n` + + ' Body: { "provider": "github", "repository": "org/repo", "agentId": "" }\n\n' + + 'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy', + ); + } + + if (statusCode < 200 || statusCode >= 300) { + let detail = rawBody; + try { + const parsed = JSON.parse(rawBody); + detail = parsed.message ?? parsed.error_description ?? rawBody; + } catch { + // use rawBody as-is + } + throw new Error( + `OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` + + 'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy', + ); + } + + let tokenData; + try { + tokenData = JSON.parse(rawBody); + } catch { + throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`); + } + + if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) { + throw new Error('OIDC token exchange response did not include an access_token.'); + } + + const expiresIn = typeof tokenData.expires_in === 'number' ? tokenData.expires_in : 3600; + + return { accessToken: tokenData.access_token, expiresIn }; +} + +/** + * Computes an ISO 8601 expiry timestamp from a TTL in seconds. + * + * @param {number} expiresInSeconds - Number of seconds until the token expires. + * @returns {string} ISO 8601 timestamp string. + */ +function computeExpiresAt(expiresInSeconds) { + return new Date(Date.now() + expiresInSeconds * 1000).toISOString(); +} + +/** + * Main entry point for the issue-token GitHub Action. + * + * @returns {Promise} + */ +async function run() { + try { + // Read inputs + const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, ''); + const agentId = core.getInput('agent-id', { required: true }); + + core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`); + let oidcToken; + try { + oidcToken = await core.getIDToken(apiUrl); + } catch (err) { + throw new Error( + 'Failed to obtain a GitHub OIDC token. ' + + "Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" + + 'Example:\n' + + 'permissions:\n' + + ' id-token: write\n' + + ' contents: read\n\n' + + `Underlying error: ${err instanceof Error ? err.message : String(err)}\n` + + 'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy', + ); + } + + core.info(`Exchanging GitHub OIDC token for SentryAgent.ai access token (agent: ${agentId})...`); + const { accessToken, expiresIn } = await exchangeOIDCToken(apiUrl, oidcToken, agentId); + + // Mask the token immediately — must happen before any logging or output + core.setSecret(accessToken); + + const expiresAt = computeExpiresAt(expiresIn); + + core.setOutput('access-token', accessToken); + core.setOutput('expires-at', expiresAt); + + core.info(`Access token issued successfully. Expires at: ${expiresAt}`); + } catch (err) { + core.setFailed(err instanceof Error ? err.message : String(err)); + } +} + +run(); diff --git a/.github/actions/issue-token/action.yml b/.github/actions/issue-token/action.yml new file mode 100644 index 0000000..ba0872a --- /dev/null +++ b/.github/actions/issue-token/action.yml @@ -0,0 +1,37 @@ +name: 'SentryAgent Issue Token' +description: > + Issues a SentryAgent.ai OAuth2 access token for an agent using GitHub OIDC + token exchange. No long-lived API credentials required. The issued access + token is automatically masked in GitHub Actions logs via core.setSecret(). + +author: 'SentryAgent.ai' + +branding: + icon: 'key' + color: 'blue' + +inputs: + api-url: + description: > + Base URL of the SentryAgent.ai AgentIdP API. + Example: https://idp.sentryagent.ai + required: true + agent-id: + description: > + The UUID of the agent for which to issue an access token. + Obtain this from the register-agent action output or from the API. + required: true + +outputs: + access-token: + description: > + A short-lived Bearer access token for the specified agent. + The token value is masked in all GitHub Actions log output. + expires-at: + description: > + ISO 8601 timestamp indicating when the access token expires. + Use this to decide when to re-issue a fresh token. + +runs: + using: 'node20' + main: 'action.js' diff --git a/.github/actions/register-agent/README.md b/.github/actions/register-agent/README.md new file mode 100644 index 0000000..e355b58 --- /dev/null +++ b/.github/actions/register-agent/README.md @@ -0,0 +1,96 @@ +# sentryagent/register-agent + +Registers a new AI agent in SentryAgent.ai from a GitHub Actions workflow. + +No long-lived API credentials are required. The action uses a GitHub-issued OIDC +token to authenticate with the SentryAgent.ai AgentIdP via `POST /oidc/token`, then +calls `POST /agents` to create the agent. + +## Prerequisites + +### 1. Configure an OIDC Trust Policy + +Before this action can exchange tokens, a trust policy must be registered in +SentryAgent.ai for the repository that will run the workflow. + +```bash +curl -X POST https://idp.sentryagent.ai/api/v1/oidc/trust-policies \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "provider": "github", + "repository": "org/your-repo", + "branch": "main" + }' +``` + +Omit `branch` to allow any branch to register agents from this repository. + +### 2. Grant `id-token: write` permission + +The workflow must have permission to request a GitHub OIDC token: + +```yaml +permissions: + id-token: write + contents: read +``` + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| `api-url` | Yes | Base URL of the SentryAgent.ai API (e.g. `https://idp.sentryagent.ai`) | +| `agent-name` | Yes | Unique name (email format) for the new agent | +| `agent-description` | No | Human-readable description of the agent's purpose | + +## Outputs + +| Output | Description | +|--------|-------------| +| `agent-id` | UUID of the newly registered agent. Use in subsequent steps to issue tokens or manage credentials. | + +## Example workflow + +```yaml +name: Register Agent + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + register: + runs-on: ubuntu-latest + steps: + - name: Register SentryAgent + id: register + uses: sentryagent/register-agent@v1 + with: + api-url: https://idp.sentryagent.ai + agent-name: my-ci-agent@acme.com + agent-description: CI agent for the acme/my-repo build pipeline + + - name: Print agent ID + run: echo "Registered agent ${{ steps.register.outputs.agent-id }}" +``` + +## Troubleshooting + +**HTTP 403 — Trust policy not configured** +Register a trust policy for this repository first. See the Prerequisites section above. + +**Failed to obtain a GitHub OIDC token** +Ensure `id-token: write` is set in the workflow's `permissions` block. + +**Agent registration failed with HTTP 401** +The OIDC token exchange succeeded but the returned access token was rejected by +`POST /agents`. Check that the SentryAgent.ai API version matches and the +bootstrap token has `agents:write` scope. + +## Full documentation + +[https://docs.sentryagent.ai/github-actions](https://docs.sentryagent.ai/github-actions) diff --git a/.github/actions/register-agent/action.js b/.github/actions/register-agent/action.js new file mode 100644 index 0000000..758612f --- /dev/null +++ b/.github/actions/register-agent/action.js @@ -0,0 +1,200 @@ +/** + * register-agent GitHub Action script. + * + * Flow: + * 1. Request a GitHub OIDC token via @actions/core.getIDToken() + * 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token + * 3. Register a new agent via POST /agents using the access token + * 4. Set the `agent-id` output + * + * Error handling: + * - OIDC exchange failures emit a clear message with a link to the trust policy setup docs + * - Agent registration failures surface the API error message + */ + +'use strict'; + +const core = require('@actions/core'); +const { HttpClient, BearerCredentialHandler } = require('@actions/http-client'); + +/** + * Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token. + * + * @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API. + * @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken(). + * @returns {Promise} The SentryAgent.ai access token. + * @throws {Error} If the exchange fails, with a message including trust policy setup instructions. + */ +async function exchangeOIDCToken(apiUrl, oidcToken) { + const client = new HttpClient('sentryagent-register-agent/1.0'); + const url = `${apiUrl}/api/v1/oidc/token`; + + const body = JSON.stringify({ + provider: 'github', + token: oidcToken, + }); + + let response; + try { + response = await client.post(url, body, { + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + } catch (err) { + throw new Error( + `Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` + + `Check that the api-url input is correct and the API is reachable.\n` + + `Underlying error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const rawBody = await response.readBody(); + const statusCode = response.message.statusCode ?? 0; + + if (statusCode === 403) { + throw new Error( + 'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' + + 'This usually means no trust policy has been registered for this repository.\n\n' + + 'To fix this, register a trust policy by calling:\n' + + ` POST ${apiUrl}/oidc/trust-policies\n` + + ' Body: { "provider": "github", "repository": "org/repo", "agentId": "" }\n\n' + + 'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy', + ); + } + + if (statusCode < 200 || statusCode >= 300) { + let detail = rawBody; + try { + const parsed = JSON.parse(rawBody); + detail = parsed.message ?? parsed.error_description ?? rawBody; + } catch { + // use rawBody as-is + } + throw new Error( + `OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` + + 'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy', + ); + } + + let tokenData; + try { + tokenData = JSON.parse(rawBody); + } catch { + throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`); + } + + if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) { + throw new Error('OIDC token exchange response did not include an access_token.'); + } + + return tokenData.access_token; +} + +/** + * Registers a new agent via POST /agents. + * + * @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API. + * @param {string} accessToken - A valid SentryAgent.ai Bearer access token. + * @param {string} agentName - Email (unique name) for the new agent. + * @param {string} agentDescription - Optional description stored as the owner field. + * @returns {Promise} The UUID of the newly registered agent. + * @throws {Error} If the API returns a non-2xx response. + */ +async function registerAgent(apiUrl, accessToken, agentName, agentDescription) { + const auth = new BearerCredentialHandler(accessToken); + const client = new HttpClient('sentryagent-register-agent/1.0', [auth]); + const url = `${apiUrl}/api/v1/agents`; + + const payload = { + email: agentName, + agentType: 'custom', + version: '1.0.0', + capabilities: [], + owner: agentDescription || agentName, + deploymentEnv: 'production', + }; + + let response; + try { + response = await client.post(url, JSON.stringify(payload), { + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + } catch (err) { + throw new Error( + `Failed to reach the SentryAgent.ai agents endpoint at ${url}.\n` + + `Underlying error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const rawBody = await response.readBody(); + const statusCode = response.message.statusCode ?? 0; + + if (statusCode < 200 || statusCode >= 300) { + let detail = rawBody; + try { + const parsed = JSON.parse(rawBody); + detail = parsed.message ?? parsed.error ?? rawBody; + } catch { + // use rawBody as-is + } + throw new Error(`Agent registration failed with HTTP ${statusCode}: ${detail}`); + } + + let agentData; + try { + agentData = JSON.parse(rawBody); + } catch { + throw new Error(`Agent registration returned non-JSON response: ${rawBody}`); + } + + if (typeof agentData.agentId !== 'string' || agentData.agentId.length === 0) { + throw new Error('Agent registration response did not include an agentId.'); + } + + return agentData.agentId; +} + +/** + * Main entry point for the register-agent GitHub Action. + * + * @returns {Promise} + */ +async function run() { + try { + // Read inputs + const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, ''); + const agentName = core.getInput('agent-name', { required: true }); + const agentDescription = core.getInput('agent-description') || ''; + + core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`); + let oidcToken; + try { + oidcToken = await core.getIDToken(apiUrl); + } catch (err) { + throw new Error( + 'Failed to obtain a GitHub OIDC token. ' + + "Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" + + 'Example:\n' + + 'permissions:\n' + + ' id-token: write\n' + + ' contents: read\n\n' + + `Underlying error: ${err instanceof Error ? err.message : String(err)}\n` + + 'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy', + ); + } + + core.info('Exchanging GitHub OIDC token for SentryAgent.ai access token...'); + const accessToken = await exchangeOIDCToken(apiUrl, oidcToken); + + core.info(`Registering agent: ${agentName}`); + const agentId = await registerAgent(apiUrl, accessToken, agentName, agentDescription); + + core.setOutput('agent-id', agentId); + core.info(`Agent registered successfully. agent-id: ${agentId}`); + } catch (err) { + core.setFailed(err instanceof Error ? err.message : String(err)); + } +} + +run(); diff --git a/.github/actions/register-agent/action.yml b/.github/actions/register-agent/action.yml new file mode 100644 index 0000000..d6ad68d --- /dev/null +++ b/.github/actions/register-agent/action.yml @@ -0,0 +1,39 @@ +name: 'SentryAgent Register Agent' +description: > + Registers a new agent in SentryAgent.ai using GitHub OIDC token exchange. + No long-lived API credentials required — the GitHub Actions OIDC token is + exchanged for a short-lived SentryAgent.ai access token to call POST /agents. + +author: 'SentryAgent.ai' + +branding: + icon: 'shield' + color: 'blue' + +inputs: + api-url: + description: > + Base URL of the SentryAgent.ai AgentIdP API. + Example: https://idp.sentryagent.ai + required: true + agent-name: + description: > + Unique name (email) for the agent being registered. + Must be a valid email address format used as the agent identity. + required: true + agent-description: + description: > + Optional human-readable description of the agent's purpose. + Stored as the agent owner field. + required: false + default: '' + +outputs: + agent-id: + description: > + The UUID of the newly registered agent. + Use in subsequent steps to issue tokens or manage credentials. + +runs: + using: 'node20' + main: 'action.js' diff --git a/openspec/changes/phase-4-developer-growth/tasks.md b/openspec/changes/phase-4-developer-growth/tasks.md index 258458f..d0ae63e 100644 --- a/openspec/changes/phase-4-developer-growth/tasks.md +++ b/openspec/changes/phase-4-developer-growth/tasks.md @@ -69,27 +69,27 @@ ## 8. WS4: Agent Marketplace -- [ ] 8.1 Add `is_public` boolean column (default false) to `agents` table — create migration `006_add_agent_marketplace.sql` -- [ ] 8.2 Update `PATCH /agents/:id` to accept `isPublic` field — update AgentService and AgentController -- [ ] 8.3 Create `MarketplaceService` with `listPublicAgents(filters, pagination)` and `getPublicAgent(agentId)` methods -- [ ] 8.4 Create `GET /marketplace/agents` endpoint — unauthenticated, paginated, supports `?q=`, `?capability=`, `?publisher=` filters -- [ ] 8.5 Create `GET /marketplace/agents/:agentId` endpoint — unauthenticated, returns agent with DID document and agent card -- [ ] 8.6 Add `agentidp_tenant_api_calls_total` Prometheus counter (label: `tenant_id`) — increment on authenticated requests -- [ ] 8.7 Add `MARKETPLACE_ENABLED` feature flag — return 404 on all marketplace routes when disabled -- [ ] 8.8 Write unit tests for MarketplaceService — list, filter, get, public/private visibility -- [ ] 8.9 Update OpenAPI spec to document `/marketplace/agents` endpoints +- [x] 8.1 Add `is_public` boolean column (default false) to `agents` table — create migration `006_add_agent_marketplace.sql` +- [x] 8.2 Update `PATCH /agents/:id` to accept `isPublic` field — update AgentService and AgentController +- [x] 8.3 Create `MarketplaceService` with `listPublicAgents(filters, pagination)` and `getPublicAgent(agentId)` methods +- [x] 8.4 Create `GET /marketplace/agents` endpoint — unauthenticated, paginated, supports `?q=`, `?capability=`, `?publisher=` filters +- [x] 8.5 Create `GET /marketplace/agents/:agentId` endpoint — unauthenticated, returns agent with DID document and agent card +- [x] 8.6 Add `agentidp_tenant_api_calls_total` Prometheus counter (label: `tenant_id`) — increment on authenticated requests +- [x] 8.7 Add `MARKETPLACE_ENABLED` feature flag — return 404 on all marketplace routes when disabled +- [x] 8.8 Write unit tests for MarketplaceService — list, filter, get, public/private visibility +- [x] 8.9 Update OpenAPI spec to document `/marketplace/agents` endpoints ## 9. WS5: GitHub Actions -- [ ] 9.1 Create `.github/actions/register-agent/action.yml` — inputs: `api-url`, `agent-name`, `agent-description`; outputs: `agent-id` -- [ ] 9.2 Implement register-agent Action script (`action.js`) — exchange GitHub OIDC token via `POST /oidc/token`, then call `POST /agents` -- [ ] 9.3 Implement OIDC token exchange error handling in register-agent — clear error message with trust policy setup link -- [ ] 9.4 Create `.github/actions/issue-token/action.yml` — inputs: `api-url`, `agent-id`; outputs: `access-token`, `expires-at` -- [ ] 9.5 Implement issue-token Action script — exchange GitHub OIDC token, call `POST /oauth2/token`, mask token with `core.setSecret()` -- [ ] 9.6 Create `POST /oidc/trust-policies` endpoint — accepts `provider`, `repository`, `branch`, `agentId` — stores trust policy -- [ ] 9.7 Enforce trust policy on GitHub OIDC token exchange — reject tokens from repos not matching a registered policy with HTTP 403 -- [ ] 9.8 Write `register-agent/README.md` — purpose, OIDC trust policy setup, inputs, outputs, example workflow -- [ ] 9.9 Write `issue-token/README.md` — same structure as register-agent README +- [x] 9.1 Create `.github/actions/register-agent/action.yml` — inputs: `api-url`, `agent-name`, `agent-description`; outputs: `agent-id` +- [x] 9.2 Implement register-agent Action script (`action.js`) — exchange GitHub OIDC token via `POST /oidc/token`, then call `POST /agents` +- [x] 9.3 Implement OIDC token exchange error handling in register-agent — clear error message with trust policy setup link +- [x] 9.4 Create `.github/actions/issue-token/action.yml` — inputs: `api-url`, `agent-id`; outputs: `access-token`, `expires-at` +- [x] 9.5 Implement issue-token Action script — exchange GitHub OIDC token, call `POST /oauth2/token`, mask token with `core.setSecret()` +- [x] 9.6 Create `POST /oidc/trust-policies` endpoint — accepts `provider`, `repository`, `branch`, `agentId` — stores trust policy +- [x] 9.7 Enforce trust policy on GitHub OIDC token exchange — reject tokens from repos not matching a registered policy with HTTP 403 +- [x] 9.8 Write `register-agent/README.md` — purpose, OIDC trust policy setup, inputs, outputs, example workflow +- [x] 9.9 Write `issue-token/README.md` — same structure as register-agent README ## 10. WS6: Billing & Usage Metering diff --git a/src/app.ts b/src/app.ts index 22b3480..35a7d35 100644 --- a/src/app.ts +++ b/src/app.ts @@ -35,6 +35,8 @@ import { createKafkaProducer } from './adapters/KafkaAdapter.js'; import { AgentController } from './controllers/AgentController.js'; import { MarketplaceController } from './controllers/MarketplaceController.js'; +import { OIDCTrustPolicyController } from './controllers/OIDCTrustPolicyController.js'; +import { OIDCTokenExchangeController } from './controllers/OIDCTokenExchangeController.js'; import { TokenController } from './controllers/TokenController.js'; import { CredentialController } from './controllers/CredentialController.js'; import { AuditController } from './controllers/AuditController.js'; @@ -47,6 +49,9 @@ import { ComplianceController } from './controllers/ComplianceController.js'; import { createAgentsRouter } from './routes/agents.js'; import { createMarketplaceRouter } from './routes/marketplace.js'; +import { createOIDCTrustPoliciesRouter } from './routes/oidcTrustPolicies.js'; +import { createOIDCTokenExchangeRouter } from './routes/oidcTokenExchange.js'; +import { OIDCTrustPolicyService } from './services/OIDCTrustPolicyService.js'; import { createTokenRouter } from './routes/token.js'; import { createCredentialsRouter } from './routes/credentials.js'; import { createAuditRouter } from './routes/audit.js'; @@ -227,6 +232,11 @@ export async function createApp(): Promise { const webhookController = new WebhookController(webhookService); const marketplaceController = new MarketplaceController(marketplaceService); + // OIDC trust policy management + GitHub Actions token exchange + const oidcTrustPolicyService = new OIDCTrustPolicyService(pool); + const oidcTrustPolicyController = new OIDCTrustPolicyController(oidcTrustPolicyService); + const oidcTokenExchangeController = new OIDCTokenExchangeController(oidcTrustPolicyService, privateKey); + // ──────────────────────────────────────────────────────────────── // Compliance services and background jobs (SOC 2 Type II) // ──────────────────────────────────────────────────────────────── @@ -277,6 +287,12 @@ export async function createApp(): Promise { app.use(`${API_BASE}`, createComplianceRouter(complianceController)); app.use(`${API_BASE}/marketplace`, createMarketplaceRouter(marketplaceController)); + // OIDC trust-policy management (authenticated) and token exchange (unauthenticated) + // Both routers mount under ${API_BASE}/oidc — trust-policy routes use /trust-policies prefix, + // token exchange uses /token, so there are no path conflicts. + app.use(`${API_BASE}/oidc`, createOIDCTrustPoliciesRouter(oidcTrustPolicyController, authMiddleware)); + app.use(`${API_BASE}/oidc`, createOIDCTokenExchangeRouter(oidcTokenExchangeController)); + // ──────────────────────────────────────────────────────────────── // Dashboard static assets (served from dashboard/dist/) // Placed after API routes so API routes take precedence. diff --git a/src/controllers/OIDCTokenExchangeController.ts b/src/controllers/OIDCTokenExchangeController.ts new file mode 100644 index 0000000..9e8f62d --- /dev/null +++ b/src/controllers/OIDCTokenExchangeController.ts @@ -0,0 +1,213 @@ +/** + * OIDCTokenExchangeController — handles GitHub Actions OIDC token exchange. + * + * This endpoint allows GitHub Actions workflows to exchange a GitHub-issued + * OIDC JWT for a SentryAgent.ai access token, without requiring any long-lived + * API credentials stored in GitHub Secrets. + * + * Flow: + * 1. Workflow calls `core.getIDToken()` → receives a GitHub-signed JWT. + * 2. Workflow POSTs `{ provider, token, agentId? }` to POST /oidc/token. + * 3. This controller decodes the JWT (MVP: no signature verification — TODO), + * verifies `iss` is the GitHub Actions OIDC issuer, enforces trust policies + * when agentId is provided, and issues a SentryAgent.ai access token. + * + * MVP NOTE: GitHub OIDC JWTs are signed by GitHub's JWKS endpoint at + * https://token.actions.githubusercontent.com/.well-known/jwks + * Full JWKS-based signature verification is deferred to a follow-up spec. + * For now, we decode without verification and validate the `iss` claim. + */ + +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { OIDCTrustPolicyService, TrustPolicyViolationError } from '../services/OIDCTrustPolicyService.js'; +import { ValidationError, SentryAgentError } from '../utils/errors.js'; +import { signToken, getTokenExpiresIn } from '../utils/jwt.js'; +import { v4 as uuidv4 } from 'uuid'; + +/** Expected `iss` claim for all GitHub Actions OIDC tokens. */ +const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; + +/** + * Decoded claims subset from a GitHub Actions OIDC JWT. + * Only the claims used for trust-policy enforcement are typed here. + */ +interface IGitHubOIDCPayload { + /** Token issuer — must equal GITHUB_OIDC_ISSUER. */ + iss: string; + /** GitHub repository in "org/repo" format (e.g. "acme/my-repo"). */ + repository: string; + /** Full Git ref, e.g. "refs/heads/main". Optional in some workflow contexts. */ + ref?: string; +} + +/** Shape of the POST /oidc/token request body. */ +interface IOIDCTokenExchangeRequest { + /** OIDC provider. Only "github" is supported. */ + provider: string; + /** The raw GitHub OIDC JWT obtained via `core.getIDToken()`. */ + token: string; + /** + * UUID of the agent for which to issue a token. + * When omitted, a short-lived bootstrap token with `agents:write` scope is + * returned — this enables the register-agent action to create a new agent. + * When provided, trust-policy enforcement is applied. + */ + agentId?: string; +} + +/** + * Controller for the unauthenticated OIDC token exchange endpoint. + * The GitHub OIDC token IS the authentication credential. + */ +export class OIDCTokenExchangeController { + /** + * @param trustPolicyService - Service for enforcing OIDC trust policies. + * @param privateKey - PEM-encoded RSA private key used to sign access tokens. + */ + constructor( + private readonly trustPolicyService: OIDCTrustPolicyService, + private readonly privateKey: string, + ) {} + + // ───────────────────────────────────────────────────────────────────────── + // Public handlers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token. + * + * When `agentId` is provided, the endpoint enforces registered trust policies + * and returns a token scoped to that agent. + * + * When `agentId` is omitted, the endpoint verifies the GitHub OIDC issuer + * claim and returns a short-lived bootstrap token with `agents:write` scope, + * enabling the workflow to register a new agent. + * + * @param req - Express request. Body must conform to IOIDCTokenExchangeRequest. + * @param res - Express response. + * @param next - Express next — forwards errors to the global error handler. + */ + async exchangeToken(req: Request, res: Response, next: NextFunction): Promise { + try { + const body = req.body as Partial; + + // ── Input validation ──────────────────────────────────────────────── + if (!body.provider || typeof body.provider !== 'string') { + throw new ValidationError('provider is required.', { received: body.provider }); + } + if (body.provider !== 'github') { + throw new ValidationError( + 'Only "github" is supported as an OIDC provider at this time.', + { provider: body.provider }, + ); + } + if (!body.token || typeof body.token !== 'string') { + throw new ValidationError('token is required.', { received: typeof body.token }); + } + + // ── Decode GitHub OIDC JWT ────────────────────────────────────────── + // TODO: Verify JWT signature against GitHub's JWKS endpoint at + // https://token.actions.githubusercontent.com/.well-known/jwks + // For the MVP we decode without verification and rely on the `iss` claim. + let githubClaims: IGitHubOIDCPayload; + try { + const decoded = jwt.decode(body.token, { complete: false }); + if (!decoded || typeof decoded === 'string') { + throw new ValidationError('The provided token could not be decoded as a JWT.'); + } + githubClaims = decoded as IGitHubOIDCPayload; + } catch (err) { + if (err instanceof SentryAgentError) { + throw err; + } + throw new ValidationError('The provided token is not a valid JWT.'); + } + + // ── Issuer verification ───────────────────────────────────────────── + if (githubClaims.iss !== GITHUB_OIDC_ISSUER) { + throw new SentryAgentError( + `Invalid token issuer. Expected "${GITHUB_OIDC_ISSUER}", got "${githubClaims.iss}".`, + 'INVALID_OIDC_ISSUER', + 403, + { expected: GITHUB_OIDC_ISSUER, received: githubClaims.iss }, + ); + } + + // ── Repository claim ──────────────────────────────────────────────── + if (!githubClaims.repository || typeof githubClaims.repository !== 'string') { + throw new SentryAgentError( + 'GitHub OIDC token is missing the required "repository" claim.', + 'INVALID_OIDC_CLAIMS', + 422, + { missingClaim: 'repository' }, + ); + } + + const { repository } = githubClaims; + const ref = githubClaims.ref; + + // ── Trust-policy enforcement or bootstrap mode ─────────────────────── + if (body.agentId) { + // Enforce trust policy for the specific agent + await this.trustPolicyService.enforceTrustPolicy('github', repository, ref, body.agentId); + + const accessToken = this.issueAccessToken(body.agentId, 'agents:read agents:write'); + + res.status(200).json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: getTokenExpiresIn(), + scope: 'agents:read agents:write', + }); + } else { + // Bootstrap mode: no agentId — the caller is about to register a new agent. + // Return a short-lived token with agents:write scope. + // The caller's identity is the GitHub repo (repository claim). + const bootstrapSubject = `github:${repository}`; + const accessToken = this.issueAccessToken(bootstrapSubject, 'agents:write'); + + res.status(200).json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: getTokenExpiresIn(), + scope: 'agents:write', + }); + } + } catch (err) { + if (err instanceof TrustPolicyViolationError) { + next(err); + return; + } + next(err); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Issues a signed RS256 access token for the given subject and scope. + * + * Uses the same `signToken` utility as the standard OAuth2 flow so all + * issued tokens are compatible with the existing `verifyToken` and + * introspection/revocation machinery. + * + * @param sub - The token subject (agentId or "github:" for bootstrap). + * @param scope - Space-separated OAuth 2.0 scopes to embed in the token. + * @returns Signed JWT string. + */ + private issueAccessToken(sub: string, scope: string): string { + return signToken( + { + sub, + client_id: sub, + scope, + jti: uuidv4(), + organization_id: 'org_system', + }, + this.privateKey, + ); + } +} diff --git a/src/controllers/OIDCTrustPolicyController.ts b/src/controllers/OIDCTrustPolicyController.ts new file mode 100644 index 0000000..3bc6a2a --- /dev/null +++ b/src/controllers/OIDCTrustPolicyController.ts @@ -0,0 +1,111 @@ +/** + * OIDCTrustPolicyController — request handlers for OIDC trust policy management. + * + * Handlers: + * POST /oidc/trust-policies → createTrustPolicy + * GET /oidc/trust-policies?agentId= → listTrustPolicies + * DELETE /oidc/trust-policies/:id → deleteTrustPolicy + */ + +import { Request, Response, NextFunction } from 'express'; +import { OIDCTrustPolicyService } from '../services/OIDCTrustPolicyService.js'; +import { ICreateTrustPolicyRequest, OIDCProvider } from '../types/oidc.js'; +import { ValidationError } from '../utils/errors.js'; + +/** + * Controller for OIDC trust policy endpoints. + * Delegates all business logic to OIDCTrustPolicyService. + */ +export class OIDCTrustPolicyController { + /** + * @param trustPolicyService - Service managing OIDC trust policies. + */ + constructor(private readonly trustPolicyService: OIDCTrustPolicyService) {} + + // ───────────────────────────────────────────────────────────────────────── + // Public handlers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Creates a new OIDC trust policy. + * + * Validates the request body and delegates to OIDCTrustPolicyService. + * Responds 201 with the created IOIDCTrustPolicy on success. + * Responds 400 if required fields are missing or invalid. + * Responds 404 if the referenced agent does not exist. + * + * @param req - Express request. Body must conform to ICreateTrustPolicyRequest. + * @param res - Express response. + * @param next - Express next function — forwards errors to the global error handler. + */ + async createTrustPolicy(req: Request, res: Response, next: NextFunction): Promise { + try { + const body = req.body as Partial; + + if (!body.provider || !body.repository || !body.agentId) { + throw new ValidationError( + 'provider, repository, and agentId are required fields.', + { received: Object.keys(body) }, + ); + } + + const request: ICreateTrustPolicyRequest = { + provider: body.provider as OIDCProvider, + repository: body.repository, + branch: body.branch, + agentId: body.agentId, + }; + + const policy = await this.trustPolicyService.createTrustPolicy(request); + res.status(201).json(policy); + } catch (err) { + next(err); + } + } + + /** + * Lists all OIDC trust policies for a given agent. + * + * Requires `agentId` as a query parameter. + * Responds 200 with an array of IOIDCTrustPolicy objects. + * Responds 400 if agentId is missing. + * + * @param req - Express request. `req.query.agentId` must be present. + * @param res - Express response. + * @param next - Express next function — forwards errors to the global error handler. + */ + async listTrustPolicies(req: Request, res: Response, next: NextFunction): Promise { + try { + const { agentId } = req.query; + + if (typeof agentId !== 'string' || agentId.trim().length === 0) { + throw new ValidationError('agentId query parameter is required.'); + } + + const policies = await this.trustPolicyService.listTrustPoliciesForAgent(agentId.trim()); + res.status(200).json(policies); + } catch (err) { + next(err); + } + } + + /** + * Deletes an OIDC trust policy by its UUID. + * + * Responds 204 No Content on success. + * Responds 404 if no policy with the given ID exists. + * + * @param req - Express request. `req.params.id` must be the policy UUID. + * @param res - Express response. + * @param next - Express next function — forwards errors to the global error handler. + */ + async deleteTrustPolicy(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + await this.trustPolicyService.deleteTrustPolicy(id); + res.status(204).send(); + } catch (err) { + next(err); + } + } +} diff --git a/src/db/migrations/022_add_github_oidc_trust_policies.sql b/src/db/migrations/022_add_github_oidc_trust_policies.sql new file mode 100644 index 0000000..7e8727e --- /dev/null +++ b/src/db/migrations/022_add_github_oidc_trust_policies.sql @@ -0,0 +1,22 @@ +-- Migration: 022_add_github_oidc_trust_policies +-- Creates the oidc_trust_policies table for GitHub OIDC token exchange enforcement. +-- Only workflows matching a registered trust policy may exchange a GitHub OIDC token +-- for a SentryAgent.ai agent access token. + +CREATE TABLE IF NOT EXISTS oidc_trust_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider VARCHAR(64) NOT NULL, -- e.g. 'github' + repository VARCHAR(255) NOT NULL, -- e.g. 'org/repo' + branch VARCHAR(255), -- optional branch constraint; NULL = any branch + agent_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for fast trust policy lookup during OIDC token exchange +CREATE INDEX IF NOT EXISTS idx_trust_policies_provider_repo + ON oidc_trust_policies (provider, repository); + +-- Index for looking up all policies for a given agent +CREATE INDEX IF NOT EXISTS idx_trust_policies_agent_id + ON oidc_trust_policies (agent_id); diff --git a/src/routes/oidcTokenExchange.ts b/src/routes/oidcTokenExchange.ts new file mode 100644 index 0000000..c09eea3 --- /dev/null +++ b/src/routes/oidcTokenExchange.ts @@ -0,0 +1,37 @@ +/** + * OIDC Token Exchange routes. + * + * Provides the unauthenticated POST /oidc/token endpoint used by GitHub Actions + * workflows to exchange a GitHub OIDC JWT for a SentryAgent.ai access token. + * + * This route is intentionally unauthenticated — the GitHub OIDC JWT IS the + * authentication credential. Trust-policy enforcement is performed inside the + * controller. + * + * Mount this router at `/api/v1/oidc` in app.ts alongside the trust-policy router. + */ + +import { Router } from 'express'; +import { OIDCTokenExchangeController } from '../controllers/OIDCTokenExchangeController.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; + +/** + * Creates and returns the Express router for the OIDC token exchange endpoint. + * + * @param controller - The OIDC token exchange controller instance. + * @returns Configured Express router with a single POST /token route. + */ +export function createOIDCTokenExchangeRouter( + controller: OIDCTokenExchangeController, +): Router { + const router = Router(); + + // POST /oidc/token — exchange a GitHub OIDC JWT for a SentryAgent.ai access token + // Unauthenticated: the GitHub OIDC token serves as the credential. + router.post( + '/token', + asyncHandler(controller.exchangeToken.bind(controller)), + ); + + return router; +} diff --git a/src/routes/oidcTrustPolicies.ts b/src/routes/oidcTrustPolicies.ts new file mode 100644 index 0000000..85e8636 --- /dev/null +++ b/src/routes/oidcTrustPolicies.ts @@ -0,0 +1,49 @@ +/** + * OIDC Trust Policy routes. + * Provides endpoints for tenants to register and manage GitHub OIDC trust policies. + * + * All routes require Bearer authentication (agents:write scope recommended). + * + * Mount this router at `/api/v1/oidc` in app.ts. + */ + +import { Router, RequestHandler } from 'express'; +import { OIDCTrustPolicyController } from '../controllers/OIDCTrustPolicyController.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; + +/** + * Creates and returns the Express router for OIDC trust policy endpoints. + * + * @param controller - The OIDC trust policy controller instance. + * @param authMiddleware - The JWT authentication middleware for protected endpoints. + * @returns Configured Express router. + */ +export function createOIDCTrustPoliciesRouter( + controller: OIDCTrustPolicyController, + authMiddleware: RequestHandler, +): Router { + const router = Router(); + + // POST /oidc/trust-policies — register a new trust policy (authenticated) + router.post( + '/trust-policies', + authMiddleware, + asyncHandler(controller.createTrustPolicy.bind(controller)), + ); + + // GET /oidc/trust-policies?agentId= — list trust policies for an agent (authenticated) + router.get( + '/trust-policies', + authMiddleware, + asyncHandler(controller.listTrustPolicies.bind(controller)), + ); + + // DELETE /oidc/trust-policies/:id — delete a trust policy (authenticated) + router.delete( + '/trust-policies/:id', + authMiddleware, + asyncHandler(controller.deleteTrustPolicy.bind(controller)), + ); + + return router; +} diff --git a/src/services/OIDCTrustPolicyService.ts b/src/services/OIDCTrustPolicyService.ts new file mode 100644 index 0000000..39d1ab8 --- /dev/null +++ b/src/services/OIDCTrustPolicyService.ts @@ -0,0 +1,287 @@ +/** + * OIDCTrustPolicyService — manages GitHub OIDC trust policies for token exchange. + * + * Trust policies control which GitHub repositories (and optionally branches) are + * permitted to exchange a GitHub OIDC token for a SentryAgent.ai agent access token. + * + * Only workflows matching a registered trust policy are allowed through + * the POST /oidc/token endpoint when provider is "github". + */ + +import { Pool } from 'pg'; +import { SentryAgentError, ValidationError } from '../utils/errors.js'; +import { + ICreateTrustPolicyRequest, + IOIDCTrustPolicy, + OIDCProvider, +} from '../types/oidc.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Error classes +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 404 — No trust policy exists matching the given criteria. + */ +export class TrustPolicyNotFoundError extends SentryAgentError { + constructor(id?: string) { + super( + 'OIDC trust policy not found.', + 'TRUST_POLICY_NOT_FOUND', + 404, + id ? { id } : undefined, + ); + } +} + +/** + * 403 — No trust policy permits the token exchange from the given repository/branch. + */ +export class TrustPolicyViolationError extends SentryAgentError { + constructor(provider: string, repository: string, branch?: string) { + super( + `No trust policy permits OIDC token exchange from provider "${provider}", ` + + `repository "${repository}"` + + (branch ? `, branch "${branch}"` : '') + + '. Register a trust policy via POST /oidc/trust-policies.', + 'TRUST_POLICY_VIOLATION', + 403, + { provider, repository, branch }, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// GitHub OIDC token claims (subset we validate against the trust policy) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Relevant claims from a decoded GitHub OIDC JWT. + * GitHub populates these in the `sub` claim and as standalone claims. + * See: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect + */ +export interface IGitHubOIDCClaims { + /** e.g. "repo:org/repo:ref:refs/heads/main" */ + sub: string; + /** GitHub repository in "org/repo" format */ + repository: string; + /** e.g. "refs/heads/main" */ + ref?: string; + /** The Git ref type, e.g. "branch" */ + ref_type?: string; + /** Workflow run attempt number */ + run_attempt?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Repository row shape from DB +// ───────────────────────────────────────────────────────────────────────────── + +interface ITrustPolicyRow { + id: string; + provider: string; + repository: string; + branch: string | null; + agent_id: string; + created_at: Date; + updated_at: Date; +} + +/** + * Service for creating and enforcing GitHub OIDC trust policies. + */ +export class OIDCTrustPolicyService { + /** + * @param pool - PostgreSQL connection pool. + */ + constructor(private readonly pool: Pool) {} + + // ───────────────────────────────────────────────────────────────────────── + // Public methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Registers a new OIDC trust policy. + * + * Validates that the referenced agent exists before persisting. + * A given repository + branch + agent combination may have at most one policy + * (duplicates are permitted from the DB perspective but lead to redundant entries). + * + * @param request - The trust policy creation request. + * @returns The persisted trust policy. + * @throws ValidationError if the request is invalid. + * @throws Error if the database operation fails. + */ + async createTrustPolicy(request: ICreateTrustPolicyRequest): Promise { + this.validateCreateRequest(request); + + const { provider, repository, branch, agentId } = request; + + // Verify the referenced agent exists + const agentCheck = await this.pool.query<{ agent_id: string }>( + 'SELECT agent_id FROM agents WHERE agent_id = $1 AND status != $2', + [agentId, 'decommissioned'], + ); + if (agentCheck.rowCount === 0) { + throw new ValidationError(`Agent with ID "${agentId}" was not found or is decommissioned.`, { + agentId, + }); + } + + const result = await this.pool.query( + `INSERT INTO oidc_trust_policies (provider, repository, branch, agent_id) + VALUES ($1, $2, $3, $4) + RETURNING id, provider, repository, branch, agent_id, created_at, updated_at`, + [provider, repository, branch ?? null, agentId], + ); + + return this.mapRow(result.rows[0]); + } + + /** + * Lists all trust policies for a given agent. + * + * @param agentId - UUID of the agent. + * @returns Array of trust policies; empty array if none exist. + */ + async listTrustPoliciesForAgent(agentId: string): Promise { + const result = await this.pool.query( + `SELECT id, provider, repository, branch, agent_id, created_at, updated_at + FROM oidc_trust_policies + WHERE agent_id = $1 + ORDER BY created_at DESC`, + [agentId], + ); + return result.rows.map((row) => this.mapRow(row)); + } + + /** + * Deletes a trust policy by its UUID. + * + * @param id - UUID of the trust policy to delete. + * @throws TrustPolicyNotFoundError if no policy with that ID exists. + */ + async deleteTrustPolicy(id: string): Promise { + const result = await this.pool.query( + 'DELETE FROM oidc_trust_policies WHERE id = $1', + [id], + ); + if (result.rowCount === 0) { + throw new TrustPolicyNotFoundError(id); + } + } + + /** + * Enforces trust policy on a GitHub OIDC token exchange request. + * + * Looks up all trust policies for the given (provider, repository) pair. + * If a branch constraint is present on a policy, it must match the token's branch. + * A policy with no branch constraint permits any branch. + * + * @param provider - OIDC provider (e.g. "github"). + * @param repository - Repository from the GitHub OIDC token (e.g. "org/repo"). + * @param branch - Branch from the GitHub OIDC token (e.g. "refs/heads/main"), or undefined. + * @param agentId - UUID of the agent for which the token is being issued. + * @throws TrustPolicyViolationError if no matching trust policy is found. + */ + async enforceTrustPolicy( + provider: OIDCProvider, + repository: string, + branch: string | undefined, + agentId: string, + ): Promise { + const result = await this.pool.query( + `SELECT id, provider, repository, branch, agent_id, created_at, updated_at + FROM oidc_trust_policies + WHERE provider = $1 + AND repository = $2 + AND agent_id = $3 + ORDER BY created_at DESC`, + [provider, repository, agentId], + ); + + if (result.rowCount === 0) { + throw new TrustPolicyViolationError(provider, repository, branch); + } + + // At least one policy must match: either branch is null (any branch) or matches exactly + const normalizedBranch = branch ? this.normalizeBranch(branch) : undefined; + + const matchingPolicy = result.rows.find((row) => { + if (row.branch === null) { + // Wildcard — any branch is permitted + return true; + } + if (normalizedBranch === undefined) { + // Token has no branch claim but policy requires a specific branch + return false; + } + return row.branch === normalizedBranch || row.branch === branch; + }); + + if (!matchingPolicy) { + throw new TrustPolicyViolationError(provider, repository, branch); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Validates a trust policy creation request. + * Throws ValidationError on any invalid field. + * + * @param request - The request to validate. + */ + private validateCreateRequest(request: ICreateTrustPolicyRequest): void { + if (request.provider !== 'github') { + throw new ValidationError( + 'Only "github" is supported as an OIDC provider at this time.', + { provider: request.provider }, + ); + } + + const repoPattern = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; + if (!repoPattern.test(request.repository)) { + throw new ValidationError( + 'repository must be in "org/repo" format (e.g. "acme/my-repo").', + { repository: request.repository }, + ); + } + + if (!request.agentId || request.agentId.trim().length === 0) { + throw new ValidationError('agentId is required.', { agentId: request.agentId }); + } + } + + /** + * Normalises a Git ref to a short branch name. + * "refs/heads/main" → "main" + * "main" → "main" + * + * @param ref - The raw Git ref string. + * @returns Normalized branch name. + */ + private normalizeBranch(ref: string): string { + return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref; + } + + /** + * Maps a database row to an IOIDCTrustPolicy object. + * + * @param row - Raw database row. + * @returns Typed trust policy object. + */ + private mapRow(row: ITrustPolicyRow): IOIDCTrustPolicy { + return { + id: row.id, + provider: row.provider as OIDCProvider, + repository: row.repository, + branch: row.branch, + agentId: row.agent_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/src/types/oidc.ts b/src/types/oidc.ts index 67720c0..f549535 100644 --- a/src/types/oidc.ts +++ b/src/types/oidc.ts @@ -128,6 +128,50 @@ export interface IOIDCDiscoveryDocument { grant_types_supported: string[]; } +// ============================================================================ +// GitHub OIDC Trust Policy +// ============================================================================ + +/** + * Supported OIDC provider identifiers. + * Currently only "github" is supported; the type is extensible. + */ +export type OIDCProvider = 'github'; + +/** + * Request body for registering an OIDC trust policy via POST /oidc/trust-policies. + */ +export interface ICreateTrustPolicyRequest { + /** OIDC provider. Currently only "github" is supported. */ + provider: OIDCProvider; + /** GitHub repository in "org/repo" format. Only workflows in this repo may exchange tokens. */ + repository: string; + /** Optional branch constraint. When omitted, any branch is allowed. */ + branch?: string; + /** UUID of the agent this trust policy grants access to. */ + agentId: string; +} + +/** + * A persisted OIDC trust policy record. + */ +export interface IOIDCTrustPolicy { + /** UUID primary key. */ + id: string; + /** OIDC provider identifier. */ + provider: OIDCProvider; + /** GitHub repository (e.g. "org/repo"). */ + repository: string; + /** Optional branch constraint. Null means any branch is allowed. */ + branch: string | null; + /** UUID of the agent this trust policy grants access to. */ + agentId: string; + /** Timestamp when the policy was created. */ + createdAt: Date; + /** Timestamp when the policy was last updated. */ + updatedAt: Date; +} + // ============================================================================ // Agent Info Response // ============================================================================