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:
SentryAgent.ai Developer
2026-04-02 10:37:39 +00:00
parent 89c99b666d
commit fefbf1e3ea
15 changed files with 1432 additions and 18 deletions

View File

@@ -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<Application> {
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<Application> {
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.

View 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,
);
}
}

View 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);
}
}
}

View 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);

View 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;
}

View 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;
}

View 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,
};
}
}

View File

@@ -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
// ============================================================================