feat(phase-4): WS5 — GitHub Actions OIDC token exchange and trust policies
- POST /oidc/token: GitHub OIDC JWT exchange (bootstrap + agent-scoped modes) - POST/GET/DELETE /oidc/trust-policies: trust policy CRUD with enforcement - DB migration 022: oidc_trust_policies table with provider/repo/branch/agent_id - GitHub Actions: register-agent and issue-token actions with full READMEs - Trust policy enforcement rejects token exchanges not matching registered policies - Bootstrap mode issues agents:write token for new agent registration without agentId Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
110
.github/actions/issue-token/README.md
vendored
Normal file
110
.github/actions/issue-token/README.md
vendored
Normal file
@@ -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 <your-admin-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"provider": "github",
|
||||
"repository": "org/your-repo",
|
||||
"branch": "main",
|
||||
"agentId": "<agent-uuid>"
|
||||
}'
|
||||
```
|
||||
|
||||
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)
|
||||
153
.github/actions/issue-token/action.js
vendored
Normal file
153
.github/actions/issue-token/action.js
vendored
Normal file
@@ -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": "<agent-id>" }\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<void>}
|
||||
*/
|
||||
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();
|
||||
37
.github/actions/issue-token/action.yml
vendored
Normal file
37
.github/actions/issue-token/action.yml
vendored
Normal file
@@ -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'
|
||||
Reference in New Issue
Block a user