Repository navigation
feat(agent-service): authenticate requests via JWT and gateway ext_authz - #9043
Alwaysgaurav1 wants to merge 10 commits into
Conversation
…t_authz (apache#5561) Authorize requests reaching the agent service using JWT signature verification and gateway external authorization, providing defense in depth: 1. agent-service: - Added real HS256 signature verification in config/jwt.ts using crypto.createHmac and timingSafeEqual. - Resolves shared secret from AUTH_JWT_SECRET env or auth.conf with 30-second clock skew tolerance. - Added .onBeforeHandle guard on agentsRouter rejecting unauthenticated, expired, or invalid tokens with 401. - Added access-token query param check in WebSocket open() handler. 2. access-control-service: - Added /api/agents routing branch in AccessControlResource. - Verifies JWT validity and enforces REGULAR or ADMIN role. - Forwards trusted identity headers (x-user-id, x-user-name, x-user-email). 3. Gateways & Deployment: - bin/single-node/nginx.conf: Added auth_request /_agent_auth subrequest on /api/agents routing to access-control-service. - bin/k8s/templates/base/gateway/gateway-agent-security-policy.yaml: Added Envoy SecurityPolicy for agent-service-route. - bin/k8s/templates/base/agent-service/agent-service-deployment.yaml: Passed texeraEnvVars into agent-service container. - bin/dockerfiles/agent-service.dockerfile: Bundled auth.conf for container runtime. 4. Frontend: - Attaches Authorization Bearer token via agentHeaders() to REST requests. - Appends ?access-token= query param to WebSocket connection in startStatePolling(). - Added unit tests in agent.service.spec.ts.
Automated Reviewer SuggestionsBased on the
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #9043 +/- ##
=========================================
Coverage 91.87% 91.87%
- Complexity 5048 5049 +1
=========================================
Files 1265 1266 +1
Lines 54915 54974 +59
Branches 6940 6943 +3
=========================================
+ Hits 50452 50508 +56
- Misses 2769 2772 +3
Partials 1694 1694
*This pull request uses carry forward flags. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| config | throughput | MB/s | latency | max Δ latest / 7d | |
|---|---|---|---|---|---|
| 🔴 | bs=10 sw=10 sl=64 | 358 | 0.219 | 26,789/41,434/41,434 us | 🔴 -11.2% / 🔴 +193.4% |
| 🟢 | bs=100 sw=10 sl=64 | 802 | 0.489 | 123,477/139,574/139,574 us | 🟢 -25.7% / 🔴 +50.9% |
| 🔴 | bs=1000 sw=10 sl=64 | 931 | 0.568 | 1,063,249/1,147,336/1,147,336 us | 🔴 +5.1% / 🔴 +35.4% |
Baseline details
Latest main 2407d79 from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 358 tuples/sec | 403 tuples/sec | 968.33 tuples/sec | -11.2% | -63.0% |
| bs=10 sw=10 sl=64 | MB/s | 0.219 MB/s | 0.246 MB/s | 0.591 MB/s | -11.0% | -62.9% |
| bs=10 sw=10 sl=64 | p50 | 26,789 us | 25,259 us | 10,646 us | +6.1% | +151.6% |
| bs=10 sw=10 sl=64 | p95 | 41,434 us | 41,144 us | 14,120 us | +0.7% | +193.4% |
| bs=10 sw=10 sl=64 | p99 | 41,434 us | 41,144 us | 16,498 us | +0.7% | +151.2% |
| bs=100 sw=10 sl=64 | throughput | 802 tuples/sec | 800 tuples/sec | 1,252 tuples/sec | +0.3% | -35.9% |
| bs=100 sw=10 sl=64 | MB/s | 0.489 MB/s | 0.488 MB/s | 0.764 MB/s | +0.2% | -36.0% |
| bs=100 sw=10 sl=64 | p50 | 123,477 us | 119,696 us | 83,736 us | +3.2% | +47.5% |
| bs=100 sw=10 sl=64 | p95 | 139,574 us | 187,894 us | 92,464 us | -25.7% | +50.9% |
| bs=100 sw=10 sl=64 | p99 | 139,574 us | 187,894 us | 108,561 us | -25.7% | +28.6% |
| bs=1000 sw=10 sl=64 | throughput | 931 tuples/sec | 945 tuples/sec | 1,321 tuples/sec | -1.5% | -29.5% |
| bs=1000 sw=10 sl=64 | MB/s | 0.568 MB/s | 0.577 MB/s | 0.806 MB/s | -1.6% | -29.5% |
| bs=1000 sw=10 sl=64 | p50 | 1,063,249 us | 1,059,342 us | 811,260 us | +0.4% | +31.1% |
| bs=1000 sw=10 sl=64 | p95 | 1,147,336 us | 1,091,499 us | 847,636 us | +5.1% | +35.4% |
| bs=1000 sw=10 sl=64 | p99 | 1,147,336 us | 1,091,499 us | 879,277 us | +5.1% | +30.5% |
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,558.50,200,128000,358,0.219,26788.63,41434.40,41434.40
1,100,10,64,20,2494.46,2000,1280000,802,0.489,123476.51,139573.57,139573.57
2,1000,10,64,20,21472.56,20000,12800000,931,0.568,1063248.68,1147336.01,1147336.01
bobbai00
left a comment
There was a problem hiding this comment.
🔴 4 must-fix · 7 advisory · 2 polish: the auth design matches #5561 and works end to end on single-node; CI, k8s PATCH and the path regex need fixes.
Design & architecture (2)
server.ts:177: in-service guard accepts INACTIVE users (advisory, see inline)jwt.ts:36: hard-coded fallback copy of the JWT secret (advisory, see inline)
Correctness (4)
AccessControlResource.scala:89: no PATCH handler, so settings saves get 405 behind Envoy (must-fix, see inline)AccessControlResource.scala:51: unanchored regex authorizes unrelated dynamic-route paths (must-fix, see inline)AgentAccessAuthSpec.scala:46: spec does not compile, failing CI (must-fix, see inline)server.ts:466: rejected socket closes with the normal close code (advisory, see inline)
Simplifications (3)
agent.service.ts:244: duplicates the JwtModule interceptor header (advisory, see inline)agent-service-deployment.yaml:62: injects every texeraEnvVars secret into agent-service (advisory, see inline)server.ts:175: POST /agents token checks are now unreachable (advisory, see inline)
Conventions (2)
jwt.ts:89: title scope is not a module name, and the title is over length (must-fix, see inline)build.yml:249: unrelated sbt bump duplicates #9064 (advisory, see inline)
Polish: 2 quick touch-ups (see inline comments).
Verification trace
Ran this branch end to end on single-node (PR builds of access-control-service, agent-service and frontend, the PR's nginx.conf, a real login token). /api/agents returned 401 without a token, 403 for an INACTIVE user and 200 for REGULAR. The WebSocket upgrade was refused without a token, and an agent created from the UI built and executed a TextInput to Filter workflow. Behind a standalone Envoy ext_authz pointed at this branch's access-control-service, GET/POST/DELETE returned 200 and PATCH /api/agents/:id/settings returned 405. Unit suites: agent-service 327 pass, frontend agent.service.spec 89 pass. The access-control-service tests fail to compile, and pass 30/30 after the two-line fix noted inline.
| // separately in its open() handler via the access-token query param. | ||
| .onBeforeHandle(({ request, set }) => { | ||
| const token = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim(); | ||
| if (!verifyToken(token)) { |
There was a problem hiding this comment.
Advisory:
This guard accepts any role, while the gateway branch requires REGULAR or ADMIN (AccessControlResource.scala:174). An INACTIVE user's unexpired token passes here: a direct GET /api/agents returned 200, and the same request through nginx returned 403. The comment above names direct access as the reason this layer exists, so have verifyToken (or this guard) reject other roles too.
|
|
||
| // auth.conf default, used as the last-resort fallback when neither the env | ||
| // override nor the file is available (matches AuthConfig's literal default). | ||
| const AUTH_CONF_DEFAULT_SECRET = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d"; |
There was a problem hiding this comment.
Advisory:
This literal is a third copy of the auth.conf secret. When neither AUTH_JWT_SECRET nor the file is found, the module logs a warning and silently uses it (line 57). If the auth.conf default changes, an agent-service started elsewhere 401s every request. The image already bundles auth.conf, so throw at startup instead.
| case wsapiWorkflowWebsocket() | apiExecutionsStats() | apiExecutionsResultExport() | | ||
| pveRoute() => | ||
| checkComputingUnitAccess(uriInfo, headers, bodyOpt) | ||
| case apiAgents() => |
There was a problem hiding this comment.
Must fix:
This resource only has GET/POST/PUT/DELETE handlers (lines 328-360), and Envoy's ext_authz forwards the client's method. So every settings Save (PATCH /api/agents/:id/settings, agent.service.ts:1157) gets a 405 on k8s; I reproduced it with Envoy in front of this branch. Add an @PATCH handler mirroring authorizePut, plus a PATCH test (nginx is fine: its subrequests are GET).
| private val apiExecutionsResultExport: Regex = """.*/api/executions/result/export.*""".r | ||
| private val pveRoute: Regex = """^/?(?:auth/)?(?:api/|wsapi/)?pve(?:/.*)?$""".r | ||
| // Agent service: authenticate any /api/agents request (Phase 1 — see #5561). | ||
| private val apiAgents: Regex = """.*/api/agents.*""".r |
There was a problem hiding this comment.
Must fix:
authorize() also backs the dynamic-routes policy (/wsapi, /api/pve), so auth/wsapi/x/api/agents now returns 200 for any REGULAR user instead of 403. It does so without the Host rewrite checkComputingUnitAccess sets (line 264). I confirmed the 403 to 200 flip, not where the dynamic backend then routes it. Anchor it like pveRoute and add a negative /wsapi test.
| private val apiAgents: Regex = """.*/api/agents.*""".r | |
| private val apiAgents: Regex = """^/?(?:auth/)?api/agents(?:/.*)?$""".r |
| u.setUid(7) | ||
| u.setName("agent-user") | ||
| u.setEmail("agent-user@example.com") | ||
| u.setGoogleId(null) |
There was a problem hiding this comment.
Must fix:
This spec does not compile. User has no setGoogleId since #7055, and JwtAuth.jwtClaims has no expireInDays parameter (line 48). That is why build / platform (access-control-service) and build / amber are red, so the testing section's claim does not hold yet. Deleting this line and calling JwtAuth.jwtClaims(u) makes all 30 resource tests pass locally.
| // (defense in depth — the gateway ext_authz also checks it, but this also | ||
| // covers direct access, e.g. bare-metal dev). The WebSocket route is guarded | ||
| // separately in its open() handler via the access-token query param. | ||
| .onBeforeHandle(({ request, set }) => { |
There was a problem hiding this comment.
Advisory:
With this guard in place, the POST handler's missing-token and validateToken branches (lines 199-205) and the two token entries in ERROR_STATUS (lines 151-152) can no longer fire. The updated specs already expect "Unauthorized" for those cases, and the handler now verifies the same token up to three times. Keep extractUserFromToken there and drop the rest.
| * null if the token is missing, malformed, mis-signed, expired, or uses a | ||
| * different algorithm. Mirrors org.apache.texera.auth.JwtParser.parseToken. | ||
| */ | ||
| export function verifyToken(token: string | undefined | null): JwtUser | null { |
There was a problem hiding this comment.
Must fix:
This is what the PR does, but the title scopes it as agent, which is not a module name. AGENTS.md asks for module scopes, and main uses agent-service. The title is also 84 characters (max 75), and (#5561) repeats the Closes link. Suggested: feat(agent-service): authenticate requests via JWT and gateway ext_authz.
| PGPASSWORD: postgres | ||
| - name: Setup sbt launcher | ||
| uses: sbt/setup-sbt@8feba82adc7f01ddcf8165b86f778bdb5b82cebc # v1.5.7 | ||
| uses: sbt/setup-sbt@ecad189dd245444b9ae0a0cd5922fd938b88906f # v1.5.12 |
| # LLM traffic (chat + model list) is handled by the agent-service, which | ||
| # talks to LiteLLM directly; /api/agents below already routes there. | ||
| # Each request is first authenticated by the access-control-service | ||
| # (ext_authz) via the auth_request subrequest below. |
There was a problem hiding this comment.
Polish:
The first two lines contradict the blocks just above: /api/models and /api/chat/ go to access-control-service, not agent-service.
| # LLM traffic (chat + model list) is handled by the agent-service, which | |
| # talks to LiteLLM directly; /api/agents below already routes there. | |
| # Each request is first authenticated by the access-control-service | |
| # (ext_authz) via the auth_request subrequest below. | |
| # Each /api/agents request is first authenticated by the | |
| # access-control-service (ext_authz) via the auth_request subrequest below. |
| // ownership is deferred (see #5302 / #5561). On success, forward the user | ||
| // identity headers so the agent service can trust them. |
There was a problem hiding this comment.
Polish:
agent-service never reads x-user-id, x-user-name or x-user-email, so "can trust them" overstates what happens today.
| // ownership is deferred (see #5302 / #5561). On success, forward the user | |
| // identity headers so the agent service can trust them. | |
| // ownership is deferred (see #5302 / #5561). On success, return the user | |
| // identity headers for future per-agent authorization in agent-service. |
|
Thanks for the thorough review and verification trace! I have addressed all items in commit 🔴 Must-Fix
🟡 Advisory & Simplifications
🟢 Polish
Verification:
Please take another look when you get a chance! |
|
@bobbai00 could you plss review it again. |
What changes were proposed in this PR?
This PR implements Phase 1 authorization for requests reaching the
agent-service, providing defense-in-depth across the API gateways, theaccess-control-service, and theagent-serviceitself:agent-serviceDirect JWT Verification (config/jwt.ts):node:crypto(createHmac,timingSafeEqual).AUTH_JWT_SECRET(falling back to candidateauth.conflocations and built-in defaults) with a 30-second clock skew tolerance..onBeforeHandleguard acrossagentsRouterinserver.tsthat rejects requests missing a valid Bearer token with401 Unauthorized(while preserving unauthenticated probes on/api/healthcheck)./api/agents/:id/reactWebSocket via theaccess-tokenquery parameter during theopen(ws)handshake.access-control-serviceExt-Authz Route (AccessControlResource.scala):.*/api/agents.*toAccessControlResource.authorize().parseToken()and requires aREGULARorADMINuser role.x-user-id,x-user-name,x-user-email) on HTTP 200, returning HTTP 401 for missing/malformed tokens and HTTP 403 for inactive roles.Gateway & Kubernetes Deployment:
bin/single-node/nginx.conf: Configuredupstream access_control_authz, an internal/_agent_authsubrequest location, andauth_request /_agent_auth;on/api/agents.bin/k8s/templates/base/gateway/gateway-agent-security-policy.yaml: Added an Envoy GatewaySecurityPolicyforagent-service-routepointing extAuth toaccess-control-service.bin/k8s/templates/base/agent-service/agent-service-deployment.yaml: Injected.Values.texeraEnvVars(providingAUTH_JWT_SECRET).bin/dockerfiles/agent-service.dockerfile: Bundledauth.confinto the container image.Frontend (
agent.service.ts):agentHeaders()to attachAuthorization: Bearer <token>fromAuthService.getAccessToken().?access-token=${encodeURIComponent(token)}to the WebSocket connection URL instartStatePolling().syncAgentsWithBackend,createAgent,getAllAgents,fetchModelTypes) passagentHeaders().Any related issues, documentation, discussions?
Resolves #5561
How was this PR tested?
agent-serviceUnit & Integration Tests:agent-service/src/config/jwt.spec.ts: Added 9 unit tests testing HS256 signature verification, string coercion, secret mismatches, tampering, expiration within/outside clock skew, non-HS256 algorithms, and malformed tokens.agent-service/src/server.spec.ts: Added unit tests verifying 401 rejection for unauthenticated, invalid, and expired tokens, 200 pass-through for valid tokens, and unauthenticated bypass for/api/healthcheck.agent-service/src/server.ws.spec.ts: Added unit tests verifying WebSocket disconnection withWsServerErrorEvent("Unauthorized")whenaccess-tokenis missing or invalid.tsc --noEmit) passes with 0 errors.access-control-serviceUnit Tests:AgentAccessAuthSpec.scala: Added tests covering 200 OK forREGULARandADMINroles, forwarding ofx-user-*headers, 401 on missing/malformed tokens, and 403 onINACTIVErole.Frontend Unit Tests:
agent.service.spec.ts: Added tests verifying thatAuthorizationis attached when an access token is present and omitted when unauthenticated, and that?access-token=is correctly appended to the WebSocket URL.Was this PR authored or co-authored using generative AI tooling?
Generated-by: Antigravity 2.0 (Gemini 2.5 Pro)