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:
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user