Skip to content

Commit fc5eb07

Browse files
authored
ci: acts ci workflows and auth precondition implementation (a2aproject#1157)
feat: acts ci workflows and auth precondition implementation
1 parent ef31c56 commit fc5eb07

7 files changed

Lines changed: 411 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: Nightly ACTS
2+
3+
# Conformance counterpart of `itk-nightly.yaml`, on the same schedule. Separate
4+
# workflows so neither shows up red for the other's failure and each can be
5+
# re-run on its own.
6+
7+
on:
8+
schedule:
9+
- cron: '0 0 * * *'
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: write
14+
15+
jobs:
16+
acts:
17+
name: Nightly ACTS Run
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v7
23+
24+
- name: Set up JDK 17
25+
uses: actions/setup-java@v6
26+
with:
27+
java-version: '17'
28+
distribution: 'temurin'
29+
cache: maven
30+
31+
- name: Clone a2a-itk
32+
uses: actions/checkout@v7
33+
with:
34+
repository: a2aproject/a2a-itk
35+
ref: main
36+
path: itk/a2a-itk
37+
38+
- name: Set up Docker Buildx
39+
uses: docker/setup-buildx-action@v4
40+
41+
- name: Build itk_service image (GHA layer cache)
42+
uses: docker/build-push-action@v7
43+
with:
44+
context: itk/a2a-itk
45+
tags: itk_service:latest
46+
load: true
47+
cache-from: type=gha,scope=itk_service
48+
cache-to: type=gha,mode=max,scope=itk_service
49+
50+
- name: Cache launcher peer-SDK checkouts + build outputs
51+
uses: actions/cache@v6
52+
with:
53+
path: ~/.cache/a2a-itk-launcher
54+
key: itk-launcher-${{ runner.os }}-${{ github.sha }}
55+
restore-keys: |
56+
itk-launcher-${{ runner.os }}-
57+
58+
- name: Run Nightly ACTS Conformance
59+
run: bash run_itk.sh
60+
working-directory: itk
61+
env:
62+
A2A_ITK_REVISION: main
63+
ITK_ACTS_RUN: "1"
64+
ITK_ACTS_TRANSPORTS: jsonrpc,grpc,rest
65+
# Unlike the PR tier this does NOT gate on conformance: a `must`
66+
# failure is the metric being recorded, not a broken run. The step
67+
# fails only if the service errored or the report came back
68+
# malformed, which is exactly when publishing should be skipped.
69+
ITK_NIGHTLY_RUN: "True"
70+
ITK_SKIP_BUILD: "1"
71+
72+
- name: Upload ACTS reports
73+
if: always()
74+
uses: actions/upload-artifact@v7
75+
with:
76+
name: acts-reports-java-nightly
77+
path: itk/acts-report-*.json
78+
if-no-files-found: warn
79+
# Outlives the rolling asset's 7-run window, so every entry it
80+
# carries still has its full report to link to.
81+
retention-days: 30
82+
83+
- name: Upload ACTS Results to Rolling Release
84+
uses: softprops/action-gh-release@v3
85+
with:
86+
# The conformance counterpart of `itk_java.json`, on the same
87+
# permanent tag: `process_acts_results.py` fetches
88+
# `.../download/nightly-metrics/acts_java.json`, appends one entry
89+
# covering all three bindings, and the upload puts it back.
90+
tag_name: "nightly-metrics"
91+
prerelease: true
92+
files: |
93+
itk/acts_java.json
94+
env:
95+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

‎.github/workflows/acts.yaml‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: ACTS
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- '**/docs/**'
8+
pull_request:
9+
paths-ignore:
10+
- '**/docs/**'
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}'
17+
cancel-in-progress: true
18+
19+
jobs:
20+
acts:
21+
name: ACTS Conformance
22+
runs-on: ubuntu-latest
23+
24+
steps:
25+
- name: Checkout code
26+
uses: actions/checkout@v7
27+
28+
- name: Set up JDK 17
29+
uses: actions/setup-java@v6
30+
with:
31+
java-version: '17'
32+
distribution: 'temurin'
33+
cache: maven
34+
35+
- name: Clone a2a-itk
36+
uses: actions/checkout@v7
37+
with:
38+
repository: a2aproject/a2a-itk
39+
ref: main
40+
path: itk/a2a-itk
41+
42+
- name: Set up Docker Buildx
43+
uses: docker/setup-buildx-action@v4
44+
45+
- name: Build itk_service image (GHA layer cache)
46+
uses: docker/build-push-action@v7
47+
with:
48+
context: itk/a2a-itk
49+
tags: itk_service:latest
50+
load: true
51+
cache-from: type=gha,scope=itk_service
52+
cache-to: type=gha,mode=max,scope=itk_service
53+
54+
- name: Cache launcher peer-SDK checkouts + build outputs
55+
uses: actions/cache@v6
56+
with:
57+
path: ~/.cache/a2a-itk-launcher
58+
key: itk-launcher-${{ runner.os }}-${{ github.sha }}
59+
restore-keys: |
60+
itk-launcher-${{ runner.os }}-
61+
62+
- name: Run ACTS Conformance Suite
63+
# Temporarily advisory: the driver gates on `must` conformance here,
64+
# which no SDK passes yet. Delete this line to make it a merge
65+
# requirement.
66+
continue-on-error: true
67+
run: bash run_itk.sh
68+
working-directory: itk
69+
env:
70+
A2A_ITK_REVISION: main
71+
ITK_ACTS_RUN: "1"
72+
ITK_ACTS_TRANSPORTS: jsonrpc,grpc,rest
73+
ITK_SKIP_BUILD: "1"
74+
75+
- name: Upload ACTS reports
76+
if: always()
77+
uses: actions/upload-artifact@v7
78+
with:
79+
name: acts-reports-java
80+
path: itk/acts-report-*.json
81+
if-no-files-found: warn
82+
retention-days: 14

‎itk/.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ raw_results.json
55
itk_java.json
66
acts-report-*.json
77
acts_results_*.json
8+
acts_java.json
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package org.a2aproject.sdk.itk;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme;
7+
import org.a2aproject.sdk.spec.SecurityRequirement;
8+
import org.a2aproject.sdk.spec.SecurityScheme;
9+
import org.jspecify.annotations.Nullable;
10+
11+
import io.grpc.Metadata;
12+
import io.grpc.Status;
13+
import io.vertx.core.json.JsonArray;
14+
import io.vertx.core.json.JsonObject;
15+
import io.vertx.ext.web.RoutingContext;
16+
17+
/**
18+
* ACTS authentication support.
19+
*
20+
* <p>Five ACTS tests assert that an agent requiring a credential rejects a request that lacks one.
21+
* A2A conditions that obligation on the agent's own declared requirements, so those tests gate on a
22+
* card declaring {@code securitySchemes} and {@code securityRequirements} — an agent declaring
23+
* neither is not violating anything by serving an unauthenticated request. Every ITK agent declares
24+
* neither by default, because traversal peers dial it with no credential.
25+
*
26+
* <p>Enforcement is therefore opt-in, and the ACTS runner turns it on for a separate pass over just
27+
* those tests. It cannot be on for the main pass: raw steps are sent exactly as written, so an
28+
* absent {@code Authorization} header means "reject me" in {@code SEC-AUTH-001} and "serve me" in
29+
* {@code JSONRPC-ENV-001}, and no server can tell those two requests apart.
30+
*
31+
* <p>The extended card is the exception, guarded in either mode: A2A §13.3 makes its authentication
32+
* unconditional, and no traversal scenario fetches one.
33+
*/
34+
final class ActsAuth {
35+
36+
/**
37+
* Credentials the ACTS runner presents. Not secrets: the runner attaches the valid one to every
38+
* abstract operation and offers the insufficient one from {@code SEC-AUTH-002} and
39+
* {@code SEC-EXTCARD-002}, so a fixture has to recognise both to answer 200 / 403 / 401 as
40+
* those tests require.
41+
*/
42+
private static final String VALID_TOKEN = "itk-valid-token";
43+
44+
private static final String INSUFFICIENT_TOKEN = "itk-insufficient-token";
45+
private static final String SCHEME_ID = "bearerAuth";
46+
47+
/**
48+
* Suffix rather than a whole path: the card is served at the root and under each binding's
49+
* prefix, and requiring a credential to read it would be circular — A2A §8.2 makes the
50+
* well-known URL the discovery mechanism and §7.3 has the client learn its schemes from that
51+
* card. The ITK readiness probe fetches it too.
52+
*/
53+
private static final String CARD_PATH_SUFFIX = ".well-known/agent-card.json";
54+
55+
private static final String EXTENDED_CARD_SUFFIX = "/extendedAgentCard";
56+
57+
private ActsAuth() {
58+
}
59+
60+
static boolean enforced() {
61+
String value = System.getenv("ITK_ACTS_AUTH");
62+
return value != null && !value.isEmpty();
63+
}
64+
65+
/**
66+
* The schemes the card advertises, declared only when the agent actually enforces them: a card
67+
* claiming a scheme it does not check would be a lie, and this is what the ACTS
68+
* {@code authentication} precondition reads to decide whether the {@code SEC-AUTH} tests apply
69+
* at all.
70+
*/
71+
static @Nullable Map<String, SecurityScheme> securitySchemes() {
72+
if (!enforced()) {
73+
return null;
74+
}
75+
return Map.of(
76+
SCHEME_ID,
77+
new HTTPAuthSecurityScheme("opaque", "Bearer", "Bearer token presented by the ACTS runner."));
78+
}
79+
80+
/**
81+
* Separate from the schemes because the two mean different things: schemes are what a client
82+
* <em>may</em> use, requirements are what it <em>must</em>. An agent publishing the first and
83+
* not the second requires nothing.
84+
*/
85+
static @Nullable List<SecurityRequirement> securityRequirements() {
86+
if (!enforced()) {
87+
return null;
88+
}
89+
return List.of(new SecurityRequirement(Map.of(SCHEME_ID, List.of())));
90+
}
91+
92+
/** Three outcomes, because the tests distinguish them. */
93+
private enum Credential {
94+
VALID,
95+
INSUFFICIENT,
96+
UNUSABLE
97+
}
98+
99+
private static Credential presented(@Nullable String header) {
100+
if (header == null) {
101+
return Credential.UNUSABLE;
102+
}
103+
String[] parts = header.split(" ", 2);
104+
if (parts.length != 2 || !parts[0].equalsIgnoreCase("Bearer")) {
105+
return Credential.UNUSABLE;
106+
}
107+
String token = parts[1].trim();
108+
if (VALID_TOKEN.equals(token)) {
109+
return Credential.VALID;
110+
}
111+
if (INSUFFICIENT_TOKEN.equals(token)) {
112+
return Credential.INSUFFICIENT;
113+
}
114+
return Credential.UNUSABLE;
115+
}
116+
117+
/**
118+
* Guards the operation endpoints when enforcement is on, and the extended card always.
119+
*
120+
* <p>Registered ahead of the reroute handlers so it sees the path the client actually asked
121+
* for: {@code /rest/extendedAgentCard} is rerouted to {@code /}, and after that there is no
122+
* longer anything to recognise.
123+
*/
124+
static void guard(RoutingContext ctx) {
125+
String path = ctx.request().path();
126+
if (path.endsWith(CARD_PATH_SUFFIX)) {
127+
ctx.next();
128+
return;
129+
}
130+
if (!enforced() && !path.endsWith(EXTENDED_CARD_SUFFIX)) {
131+
ctx.next();
132+
return;
133+
}
134+
135+
switch (presented(ctx.request().getHeader("Authorization"))) {
136+
case VALID -> ctx.next();
137+
case INSUFFICIENT -> reject(ctx, 403, "PERMISSION_DENIED", "Token lacks the required scope.");
138+
case UNUSABLE -> reject(ctx, 401, "UNAUTHENTICATED", "A bearer token is required.");
139+
}
140+
}
141+
142+
/**
143+
* The same rule over gRPC metadata, so a card claiming an agent-wide requirement is not
144+
* contradicted by one binding that serves anyone. gRPC keys are lowercase by protocol.
145+
*
146+
* <p>Returns the status to fail the call with, or {@code null} to let it through.
147+
*/
148+
static @Nullable Status grpcRejection(Metadata metadata) {
149+
if (!enforced()) {
150+
return null;
151+
}
152+
String header = metadata.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER));
153+
return switch (presented(header)) {
154+
case VALID -> null;
155+
case INSUFFICIENT -> Status.PERMISSION_DENIED.withDescription("Token lacks the required scope.");
156+
case UNUSABLE -> Status.UNAUTHENTICATED.withDescription("A bearer token is required.");
157+
};
158+
}
159+
160+
/** Answers with the {@code google.rpc.Status} shape A2A §11.6 requires of an error. */
161+
private static void reject(RoutingContext ctx, int status, String reason, String message) {
162+
JsonObject errorInfo = new JsonObject()
163+
.put("@type", "type.googleapis.com/google.rpc.ErrorInfo")
164+
.put("reason", reason)
165+
.put("domain", "a2a-protocol.org");
166+
JsonObject body = new JsonObject()
167+
.put(
168+
"error",
169+
new JsonObject()
170+
.put("code", status)
171+
.put("status", reason)
172+
.put("message", message)
173+
.put("details", new JsonArray().add(errorInfo)));
174+
175+
if (status == 401) {
176+
ctx.response().putHeader("WWW-Authenticate", "Bearer realm=\"a2a\", scheme=\"" + SCHEME_ID + "\"");
177+
}
178+
ctx.response()
179+
.setStatusCode(status)
180+
.putHeader("Content-Type", "application/json")
181+
.end(body.encode());
182+
}
183+
}

0 commit comments

Comments
 (0)