Skip to content

Commit f1dda3f

Browse files
refactor(agent-tunnel): JWT-only enrollment, drop server-side mint
DVLS holds the provisioner private key, the gateway holds the public key and verifies statelessly. The gateway-side enrollment-string mint endpoint was the wrong shape: it required the gateway to hold issuance state (the in-memory `EnrollmentTokenStore`), which broke HA (a token could only be redeemed against the specific gateway node that minted it, and a restart silently invalidated unredeemed tokens). This PR removes that path entirely. DVLS signs an enrollment JWT with its `gateway.agent.enroll` scope; the agent presents the JWT as the Bearer token on `POST /jet/tunnel/enroll`; the gateway verifies the signature against the configured provisioner public key. No state on the gateway, no per-node affinity, no restart hazards. Companion changes: - Token: rename `gateway.tunnel.enroll` to `gateway.agent.enroll` (the scope governs agent management, not the tunnel transport). Add the matching `gateway.agent.read` scope. Both extractors (`AgentManagementReadAccess` / `WriteAccess`) accept the new scopes alongside the existing `Wildcard` / `ConfigWrite` / `DiagnosticsRead` for back-compat with callers that predate the rename. - .NET utils: new `EnrollmentClaims` mirroring the Rust shape so DVLS can sign the JWT directly via `TokenUtils.Sign`. Two new `AccessScope` constants. Round-trip JSON tests for both. NuGet bumped to 2026.4.27.
1 parent 0475494 commit f1dda3f

10 files changed

Lines changed: 149 additions & 182 deletions

File tree

crates/agent-tunnel/src/enrollment_store.rs

Lines changed: 0 additions & 138 deletions
This file was deleted.

crates/agent-tunnel/src/lib.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@
77
extern crate tracing;
88

99
pub mod cert;
10-
pub mod enrollment_store;
1110
pub mod listener;
1211
pub mod registry;
1312
pub mod stream;
1413

15-
pub use enrollment_store::EnrollmentTokenStore;
1614
pub use listener::{AgentTunnelHandle, AgentTunnelListener};
1715
pub use registry::AgentRegistry;
1816
pub use stream::TunnelStream;

crates/agent-tunnel/src/listener.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ use tokio::sync::RwLock;
1616
use uuid::Uuid;
1717

1818
use super::cert::CaManager;
19-
use super::enrollment_store::EnrollmentTokenStore;
2019
use super::registry::{AgentPeer, AgentRegistry};
2120
use super::stream::TunnelStream;
2221

@@ -33,7 +32,6 @@ pub struct AgentTunnelHandle {
3332
/// Map of agent_id → live Quinn connection, used for opening new streams.
3433
agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>>,
3534
ca_manager: Arc<CaManager>,
36-
enrollment_token_store: Arc<EnrollmentTokenStore>,
3735
}
3836

3937
impl AgentTunnelHandle {
@@ -45,10 +43,6 @@ impl AgentTunnelHandle {
4543
&self.ca_manager
4644
}
4745

48-
pub fn enrollment_token_store(&self) -> &EnrollmentTokenStore {
49-
&self.enrollment_token_store
50-
}
51-
5246
/// Open a proxy stream through a connected agent.
5347
// TODO: Emit TrafficEvent for connections routed through the agent tunnel.
5448
pub async fn connect_via_agent(
@@ -148,13 +142,11 @@ impl AgentTunnelListener {
148142

149143
let registry = Arc::new(AgentRegistry::new());
150144
let agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>> = Arc::new(RwLock::new(HashMap::new()));
151-
let enrollment_token_store = Arc::new(EnrollmentTokenStore::new());
152145

153146
let handle = AgentTunnelHandle {
154147
registry: Arc::clone(&registry),
155148
agent_connections: Arc::clone(&agent_connections),
156149
ca_manager,
157-
enrollment_token_store,
158150
};
159151

160152
let listener = Self {

devolutions-gateway/src/api/tunnel.rs

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use axum::extract::{Path, State};
22
use axum::http::HeaderMap;
33
use axum::{Json, Router};
4+
use serde::{Deserialize, Serialize};
45
use uuid::Uuid;
56

67
use crate::DgwState;
@@ -11,7 +12,7 @@ use crate::http::HttpError;
1112
///
1213
/// Returns `true` if the token is a well-formed JWT whose signature verifies
1314
/// against `provisioner_key`, whose `exp` has not passed, and whose `scope`
14-
/// is `TunnelEnroll` (or `Wildcard`). Returns `false` for any failure.
15+
/// is `AgentEnroll` (or `Wildcard`). Returns `false` for any failure.
1516
///
1617
/// The enrollment JWT carries extra claims (`jet_gw_url`, `jet_agent_name`)
1718
/// that the *agent* reads locally from its own copy of the token — the Gateway
@@ -39,7 +40,7 @@ fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey)
3940

4041
matches!(
4142
validated.state.claims.scope,
42-
AccessScope::TunnelEnroll | AccessScope::Wildcard
43+
AccessScope::AgentEnroll | AccessScope::Wildcard
4344
)
4445
}
4546

@@ -96,8 +97,12 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
9697

9798
/// Enroll a new agent.
9899
///
99-
/// Requires a Bearer token matching the configured enrollment secret
100-
/// or a valid one-time enrollment token from the store.
100+
/// Requires a Bearer token that is either:
101+
/// - a JWT signed by the configured provisioner key with `AgentEnroll` /
102+
/// `Wildcard` scope (issued by DVLS — the only authority for agent
103+
/// enrollment tokens), or
104+
/// - the static `enrollment_secret` from the gateway configuration (admin
105+
/// bootstrap fallback for environments without DVLS).
101106
///
102107
/// The agent generates its own key pair and sends a CSR. The gateway signs it
103108
/// and returns the certificate. The private key never leaves the agent.
@@ -135,24 +140,19 @@ async fn enroll_agent(
135140
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
136141

137142
// Token validation order:
138-
// 1. JWT signed by the configured provisioner key (scope == TunnelEnroll)
139-
// 2. One-time enrollment token from the in-memory store
140-
// 3. Static enrollment secret from configuration (constant-time comparison)
143+
// 1. JWT signed by the configured provisioner key (scope == AgentEnroll)
144+
// 2. Static enrollment secret from configuration (constant-time comparison)
141145
let jwt_valid = validate_enrollment_jwt(provided_token, &conf.provisioner_public_key);
142146

143147
if !jwt_valid {
144-
let token_valid = handle.enrollment_token_store().redeem(provided_token).await;
145-
146-
if !token_valid {
147-
let enrollment_secret = conf
148-
.agent_tunnel
149-
.enrollment_secret
150-
.as_deref()
151-
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
152-
153-
if !timing_safe_eq(provided_token.as_bytes(), enrollment_secret.as_bytes()) {
154-
return Err(HttpError::forbidden().msg("invalid enrollment token"));
155-
}
148+
let enrollment_secret = conf
149+
.agent_tunnel
150+
.enrollment_secret
151+
.as_deref()
152+
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
153+
154+
if !timing_safe_eq(provided_token.as_bytes(), enrollment_secret.as_bytes()) {
155+
return Err(HttpError::forbidden().msg("invalid enrollment token"));
156156
}
157157
}
158158

@@ -282,7 +282,7 @@ mod tests {
282282
let (priv_key, pub_key) = keypair();
283283
let token = sign(
284284
json!({
285-
"scope": "gateway.tunnel.enroll",
285+
"scope": "gateway.agent.enroll",
286286
"nbf": now_ts() - 60,
287287
"exp": now_ts() + 3600,
288288
"jti": Uuid::new_v4(),
@@ -334,7 +334,7 @@ mod tests {
334334
let (priv_key, pub_key) = keypair();
335335
let token = sign(
336336
json!({
337-
"scope": "gateway.tunnel.enroll",
337+
"scope": "gateway.agent.enroll",
338338
"nbf": now_ts() - 7200,
339339
"exp": now_ts() - 3600,
340340
"jti": Uuid::new_v4(),
@@ -352,7 +352,7 @@ mod tests {
352352
let (_, gateway_pub) = keypair();
353353
let token = sign(
354354
json!({
355-
"scope": "gateway.tunnel.enroll",
355+
"scope": "gateway.agent.enroll",
356356
"nbf": now_ts() - 60,
357357
"exp": now_ts() + 3600,
358358
"jti": Uuid::new_v4(),
@@ -369,7 +369,7 @@ mod tests {
369369
let (priv_key, pub_key) = keypair();
370370
let token = sign(
371371
json!({
372-
"scope": "gateway.tunnel.enroll",
372+
"scope": "gateway.agent.enroll",
373373
"nbf": now_ts() - 60,
374374
"exp": now_ts() + 3600,
375375
"jti": Uuid::new_v4(),

devolutions-gateway/src/extract.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -424,12 +424,19 @@ where
424424
.map_err(HttpError::internal().err())?
425425
.0;
426426

427-
// DiagnosticsRead is accepted because DVLS maps its AgentRead scope
428-
// to GatewayDiagnosticsRead, which serializes as "gateway.diagnostics.read".
427+
// Accepted scopes:
428+
// - AgentRead: the canonical scope for this endpoint (DVLS uses this).
429+
// - DiagnosticsRead / ConfigWrite: back-compat for older callers that predate
430+
// the dedicated agent scopes; safe because both imply broader privileges.
429431
match claims {
430432
AccessTokenClaims::Scope(scope) => match scope.scope {
431-
AccessScope::Wildcard | AccessScope::DiagnosticsRead | AccessScope::ConfigWrite => Ok(Self),
432-
_ => Err(HttpError::forbidden().msg("invalid scope for agent management read")),
433+
AccessScope::Wildcard
434+
| AccessScope::AgentRead
435+
| AccessScope::DiagnosticsRead
436+
| AccessScope::ConfigWrite => Ok(Self),
437+
_ => Err(HttpError::forbidden().msg(
438+
"invalid scope for agent management read (require one of: gateway.agent.read, gateway.diagnostics.read, gateway.config.write, *)",
439+
)),
433440
},
434441
_ => Err(HttpError::forbidden().msg("scope token required for agent management read")),
435442
}
@@ -438,7 +445,7 @@ where
438445

439446
/// Grants write access to agent management endpoints (e.g. enrollment, delete).
440447
///
441-
/// Accepts scope tokens with `ConfigWrite` (or `Wildcard`) scope only.
448+
/// Accepts scope tokens with `AgentEnroll`, `ConfigWrite`, or `Wildcard` scope.
442449
#[derive(Clone, Copy)]
443450
pub struct AgentManagementWriteAccess;
444451

@@ -454,10 +461,16 @@ where
454461
.map_err(HttpError::internal().err())?
455462
.0;
456463

464+
// Accepted scopes:
465+
// - AgentEnroll: the canonical scope for this endpoint (DVLS uses this).
466+
// - ConfigWrite: back-compat for older callers that predate the
467+
// dedicated agent scope.
457468
match claims {
458469
AccessTokenClaims::Scope(scope) => match scope.scope {
459-
AccessScope::Wildcard | AccessScope::ConfigWrite => Ok(Self),
460-
_ => Err(HttpError::forbidden().msg("invalid scope for agent management write")),
470+
AccessScope::Wildcard | AccessScope::AgentEnroll | AccessScope::ConfigWrite => Ok(Self),
471+
_ => Err(HttpError::forbidden().msg(
472+
"invalid scope for agent management write (require one of: gateway.agent.enroll, gateway.config.write, *)",
473+
)),
461474
},
462475
_ => Err(HttpError::forbidden().msg("scope token required for agent management write")),
463476
}

devolutions-gateway/src/token.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,10 @@ pub enum AccessScope {
472472
NetMonitorConfig,
473473
#[serde(rename = "gateway.net.monitor.drain")]
474474
NetMonitorDrain,
475-
#[serde(rename = "gateway.tunnel.enroll")]
476-
TunnelEnroll,
475+
#[serde(rename = "gateway.agent.enroll")]
476+
AgentEnroll,
477+
#[serde(rename = "gateway.agent.read")]
478+
AgentRead,
477479
}
478480

479481
#[derive(Clone, Serialize, Deserialize)]
@@ -499,7 +501,7 @@ pub struct ScopeTokenClaims {
499501
/// and expiry against the configured provisioner key.
500502
#[derive(Debug, Clone, Serialize, Deserialize)]
501503
pub struct EnrollmentTokenClaims {
502-
/// Must be `AccessScope::TunnelEnroll` (or `Wildcard`).
504+
/// Must be `AccessScope::AgentEnroll` (or `Wildcard`).
503505
pub scope: AccessScope,
504506

505507
/// JWT expiration time claim.

0 commit comments

Comments
 (0)