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'
|
||||||
96
.github/actions/register-agent/README.md
vendored
Normal file
96
.github/actions/register-agent/README.md
vendored
Normal file
@@ -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 <your-admin-token>" \
|
||||||
|
-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)
|
||||||
200
.github/actions/register-agent/action.js
vendored
Normal file
200
.github/actions/register-agent/action.js
vendored
Normal file
@@ -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<string>} 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": "<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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>} 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<void>}
|
||||||
|
*/
|
||||||
|
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();
|
||||||
39
.github/actions/register-agent/action.yml
vendored
Normal file
39
.github/actions/register-agent/action.yml
vendored
Normal file
@@ -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'
|
||||||
@@ -69,27 +69,27 @@
|
|||||||
|
|
||||||
## 8. WS4: Agent Marketplace
|
## 8. WS4: Agent Marketplace
|
||||||
|
|
||||||
- [ ] 8.1 Add `is_public` boolean column (default false) to `agents` table — create migration `006_add_agent_marketplace.sql`
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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.9 Update OpenAPI spec to document `/marketplace/agents` endpoints
|
||||||
|
|
||||||
## 9. WS5: GitHub Actions
|
## 9. WS5: GitHub Actions
|
||||||
|
|
||||||
- [ ] 9.1 Create `.github/actions/register-agent/action.yml` — inputs: `api-url`, `agent-name`, `agent-description`; outputs: `agent-id`
|
- [x] 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`
|
- [x] 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
|
- [x] 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`
|
- [x] 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()`
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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.9 Write `issue-token/README.md` — same structure as register-agent README
|
||||||
|
|
||||||
## 10. WS6: Billing & Usage Metering
|
## 10. WS6: Billing & Usage Metering
|
||||||
|
|
||||||
|
|||||||
16
src/app.ts
16
src/app.ts
@@ -35,6 +35,8 @@ import { createKafkaProducer } from './adapters/KafkaAdapter.js';
|
|||||||
|
|
||||||
import { AgentController } from './controllers/AgentController.js';
|
import { AgentController } from './controllers/AgentController.js';
|
||||||
import { MarketplaceController } from './controllers/MarketplaceController.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 { TokenController } from './controllers/TokenController.js';
|
||||||
import { CredentialController } from './controllers/CredentialController.js';
|
import { CredentialController } from './controllers/CredentialController.js';
|
||||||
import { AuditController } from './controllers/AuditController.js';
|
import { AuditController } from './controllers/AuditController.js';
|
||||||
@@ -47,6 +49,9 @@ import { ComplianceController } from './controllers/ComplianceController.js';
|
|||||||
|
|
||||||
import { createAgentsRouter } from './routes/agents.js';
|
import { createAgentsRouter } from './routes/agents.js';
|
||||||
import { createMarketplaceRouter } from './routes/marketplace.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 { createTokenRouter } from './routes/token.js';
|
||||||
import { createCredentialsRouter } from './routes/credentials.js';
|
import { createCredentialsRouter } from './routes/credentials.js';
|
||||||
import { createAuditRouter } from './routes/audit.js';
|
import { createAuditRouter } from './routes/audit.js';
|
||||||
@@ -227,6 +232,11 @@ export async function createApp(): Promise<Application> {
|
|||||||
const webhookController = new WebhookController(webhookService);
|
const webhookController = new WebhookController(webhookService);
|
||||||
const marketplaceController = new MarketplaceController(marketplaceService);
|
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)
|
// Compliance services and background jobs (SOC 2 Type II)
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
@@ -277,6 +287,12 @@ export async function createApp(): Promise<Application> {
|
|||||||
app.use(`${API_BASE}`, createComplianceRouter(complianceController));
|
app.use(`${API_BASE}`, createComplianceRouter(complianceController));
|
||||||
app.use(`${API_BASE}/marketplace`, createMarketplaceRouter(marketplaceController));
|
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/)
|
// Dashboard static assets (served from dashboard/dist/)
|
||||||
// Placed after API routes so API routes take precedence.
|
// Placed after API routes so API routes take precedence.
|
||||||
|
|||||||
213
src/controllers/OIDCTokenExchangeController.ts
Normal file
213
src/controllers/OIDCTokenExchangeController.ts
Normal file
@@ -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<void> {
|
||||||
|
try {
|
||||||
|
const body = req.body as Partial<IOIDCTokenExchangeRequest>;
|
||||||
|
|
||||||
|
// ── 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:<repo>" 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/controllers/OIDCTrustPolicyController.ts
Normal file
111
src/controllers/OIDCTrustPolicyController.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* OIDCTrustPolicyController — request handlers for OIDC trust policy management.
|
||||||
|
*
|
||||||
|
* Handlers:
|
||||||
|
* POST /oidc/trust-policies → createTrustPolicy
|
||||||
|
* GET /oidc/trust-policies?agentId=<uuid> → 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<void> {
|
||||||
|
try {
|
||||||
|
const body = req.body as Partial<ICreateTrustPolicyRequest>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await this.trustPolicyService.deleteTrustPolicy(id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/db/migrations/022_add_github_oidc_trust_policies.sql
Normal file
22
src/db/migrations/022_add_github_oidc_trust_policies.sql
Normal file
@@ -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);
|
||||||
37
src/routes/oidcTokenExchange.ts
Normal file
37
src/routes/oidcTokenExchange.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
49
src/routes/oidcTrustPolicies.ts
Normal file
49
src/routes/oidcTrustPolicies.ts
Normal file
@@ -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=<uuid> — 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;
|
||||||
|
}
|
||||||
287
src/services/OIDCTrustPolicyService.ts
Normal file
287
src/services/OIDCTrustPolicyService.ts
Normal file
@@ -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<IOIDCTrustPolicy> {
|
||||||
|
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<ITrustPolicyRow>(
|
||||||
|
`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<IOIDCTrustPolicy[]> {
|
||||||
|
const result = await this.pool.query<ITrustPolicyRow>(
|
||||||
|
`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<void> {
|
||||||
|
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<void> {
|
||||||
|
const result = await this.pool.query<ITrustPolicyRow>(
|
||||||
|
`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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,50 @@ export interface IOIDCDiscoveryDocument {
|
|||||||
grant_types_supported: string[];
|
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
|
// Agent Info Response
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user