Skip to content

Commit f804807

Browse files
refactor(dgw): split provisioning into credentials and connection options
Separate `provision-credentials` and `provision-connection-options` preflight operations, backed by two independent JTI-keyed stores. The credentials store keeps the encryption boundary; the connection-options store holds plaintext routing metadata with no crypto dependency. The two halves are provisioned, expire and are replaced independently — a caller that wants both sends two operations in one batched preflight request. `ProvisioningStore::get` assembles a combined view for RDP credential injection: credentials required, connection options optional. This lets connection options serve future protocols that do not use credential injection, instead of being welded to a mandatory-credentials operation. Register TargetConnectionOptions in the OpenAPI schema list and regenerate gateway-api.yaml.
1 parent 4302ef4 commit f804807

5 files changed

Lines changed: 150 additions & 59 deletions

File tree

devolutions-gateway/openapi/gateway-api.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ info:
77
email: infos@devolutions.net
88
license:
99
name: MIT/Apache-2.0
10-
version: 2026.1.2
10+
version: 2026.2.4
1111
paths:
1212
/jet/config:
1313
patch:
@@ -1803,15 +1803,15 @@ components:
18031803
description: |-
18041804
Minimum persistence duration in seconds for the data provisioned via this operation.
18051805
1806-
Optional parameter for "provision-token" and "provision-credentials" kinds.
1806+
Optional parameter for "provision-token", "provision-credentials" and "provision-connection-options" kinds.
18071807
nullable: true
18081808
minimum: 0
18091809
token:
18101810
type: string
18111811
description: |-
18121812
The token to be stored on the proxy-side.
18131813
1814-
Required for "provision-token" and "provision-credentials" kinds.
1814+
Required for "provision-token", "provision-credentials" and "provision-connection-options" kinds.
18151815
nullable: true
18161816
PreflightOperationKind:
18171817
type: string
@@ -1822,6 +1822,7 @@ components:
18221822
- get-recording-storage-health
18231823
- provision-token
18241824
- provision-credentials
1825+
- provision-connection-options
18251826
- resolve-host
18261827
PreflightOutput:
18271828
type: object

devolutions-gateway/src/api/preflight.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const OP_GET_RUNNING_SESSION_COUNT: &str = "get-running-session-count";
2222
const OP_GET_RECORDING_STORAGE_HEALTH: &str = "get-recording-storage-health";
2323
const OP_PROVISION_TOKEN: &str = "provision-token";
2424
const OP_PROVISION_CREDENTIALS: &str = "provision-credentials";
25+
const OP_PROVISION_CONNECTION_OPTIONS: &str = "provision-connection-options";
2526
const OP_RESOLVE_HOST: &str = "resolve-host";
2627

2728
const DEFAULT_TTL: Duration = Duration::minutes(15);
@@ -46,7 +47,13 @@ struct ProvisionCredentialsParams {
4647
token: String,
4748
#[serde(flatten)]
4849
credentials: crate::credential::CleartextAppCredentials,
49-
connection_options: Option<crate::target_connection_options::TargetConnectionOptions>,
50+
time_to_live: Option<u32>,
51+
}
52+
53+
#[derive(Debug, Deserialize)]
54+
struct ProvisionConnectionOptionsParams {
55+
token: String,
56+
connection_options: crate::target_connection_options::TargetConnectionOptions,
5057
time_to_live: Option<u32>,
5158
}
5259

@@ -330,7 +337,6 @@ async fn handle_operation(
330337
let ProvisionCredentialsParams {
331338
token,
332339
credentials,
333-
connection_options,
334340
time_to_live,
335341
} = from_params(operation.params).map_err(PreflightError::invalid_params)?;
336342
let time_to_live = validate_time_to_live(time_to_live)?;
@@ -351,7 +357,7 @@ async fn handle_operation(
351357
})?;
352358

353359
let replaced = provisioning
354-
.insert(token_data, credentials, connection_options, time_to_live)
360+
.insert_credentials(token_data, credentials, time_to_live)
355361
.inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials"))
356362
.map_err(|_| {
357363
PreflightError::new(
@@ -365,7 +371,39 @@ async fn handle_operation(
365371
operation_id: operation.id,
366372
kind: PreflightOutputKind::Alert {
367373
status: PreflightAlertStatus::Info,
368-
message: "an existing provisioning entry was replaced".to_owned(),
374+
message: "existing provisioned credentials were replaced".to_owned(),
375+
},
376+
});
377+
}
378+
379+
outputs.push(PreflightOutput {
380+
operation_id: operation.id,
381+
kind: PreflightOutputKind::Ack,
382+
});
383+
}
384+
OP_PROVISION_CONNECTION_OPTIONS => {
385+
let ProvisionConnectionOptionsParams {
386+
token,
387+
connection_options,
388+
time_to_live,
389+
} = from_params(operation.params).map_err(PreflightError::invalid_params)?;
390+
let time_to_live = validate_time_to_live(time_to_live)?;
391+
392+
// Connection options are generic routing metadata, not credential-injection state, so
393+
// they only need a JTI to key by — not the full credential-injection token shape that
394+
// provision-credentials requires.
395+
let jti = crate::token::extract_jti(&token).map_err(|error| {
396+
PreflightError::new(PreflightAlertStatus::InvalidParams, format!("invalid token: {error:#}"))
397+
})?;
398+
399+
let replaced = provisioning.insert_connection_options(jti, connection_options, time_to_live);
400+
401+
if replaced {
402+
outputs.push(PreflightOutput {
403+
operation_id: operation.id,
404+
kind: PreflightOutputKind::Alert {
405+
status: PreflightAlertStatus::Info,
406+
message: "existing provisioned connection options were replaced".to_owned(),
369407
},
370408
});
371409
}

devolutions-gateway/src/credential_injection.rs

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@ impl CredentialInjection {
168168
credentials,
169169
connection_options,
170170
target_hostname,
171-
expires_at: _,
172171
} = provisioned_connection;
173172

174173
let uses_kerberos = if kerberos_enabled {
@@ -548,16 +547,11 @@ mod tests {
548547
.expect("credentials encrypt")
549548
}
550549

551-
fn provisioned(
552-
target_username: &str,
553-
krb_kdc: Option<TargetAddr>,
554-
time_to_live: time::Duration,
555-
) -> ProvisionedConnection {
550+
fn provisioned(target_username: &str, krb_kdc: Option<TargetAddr>) -> ProvisionedConnection {
556551
ProvisionedConnection {
557552
credentials: app_credentials("proxy@example.invalid", target_username),
558553
connection_options: krb_kdc.map(|kdc| TargetConnectionOptions::new(Some(kdc)).expect("connection options")),
559554
target_hostname: "target.example".to_owned(),
560-
expires_at: time::OffsetDateTime::now_utc() + time_to_live,
561555
}
562556
}
563557

@@ -604,11 +598,7 @@ mod tests {
604598
let kdc = target_kdc();
605599
let injection = CredentialInjection::from_provisioned(
606600
Uuid::new_v4(),
607-
provisioned(
608-
"administrator@example.invalid",
609-
Some(kdc.clone()),
610-
time::Duration::minutes(5),
611-
),
601+
provisioned("administrator@example.invalid", Some(kdc.clone())),
612602
"target.example",
613603
true,
614604
)
@@ -624,7 +614,7 @@ mod tests {
624614
fn from_provisioned_hard_errors_when_kerberos_target_has_no_kdc() {
625615
let error = CredentialInjection::from_provisioned(
626616
Uuid::new_v4(),
627-
provisioned("administrator@example.invalid", None, time::Duration::minutes(5)),
617+
provisioned("administrator@example.invalid", None),
628618
"target.example",
629619
true,
630620
)
@@ -637,7 +627,7 @@ mod tests {
637627
fn from_provisioned_selects_ntlm_for_domainless_target() {
638628
let injection = CredentialInjection::from_provisioned(
639629
Uuid::new_v4(),
640-
provisioned("Administrator", None, time::Duration::minutes(5)),
630+
provisioned("Administrator", None),
641631
"target.example",
642632
true,
643633
)
@@ -649,11 +639,7 @@ mod tests {
649639
fn from_provisioned_selects_ntlm_when_kerberos_disabled() {
650640
let injection = CredentialInjection::from_provisioned(
651641
Uuid::new_v4(),
652-
provisioned(
653-
"administrator@example.invalid",
654-
Some(target_kdc()),
655-
time::Duration::minutes(5),
656-
),
642+
provisioned("administrator@example.invalid", Some(target_kdc())),
657643
"target.example",
658644
false,
659645
)
@@ -665,7 +651,7 @@ mod tests {
665651
fn from_provisioned_rejects_target_hostname_mismatch() {
666652
let error = CredentialInjection::from_provisioned(
667653
Uuid::new_v4(),
668-
provisioned("Administrator", None, time::Duration::minutes(5)),
654+
provisioned("Administrator", None),
669655
"other.example",
670656
true,
671657
)

devolutions-gateway/src/openapi.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber};
6868
PreflightOperationKind,
6969
AppCredential,
7070
AppCredentialKind,
71+
TargetConnectionOptions,
7172
PreflightOutput,
7273
PreflightOutputKind,
7374
PreflightAlertStatus,
@@ -372,7 +373,7 @@ struct PreflightOperation {
372373
kind: PreflightOperationKind,
373374
/// The token to be stored on the proxy-side.
374375
///
375-
/// Required for "provision-token" and "provision-credentials" kinds.
376+
/// Required for "provision-token", "provision-credentials" and "provision-connection-options" kinds.
376377
token: Option<String>,
377378
/// The credential to use to authorize the client at the proxy-level.
378379
///
@@ -384,15 +385,15 @@ struct PreflightOperation {
384385
target_credential: Option<AppCredential>,
385386
/// Options used by the Gateway when connecting to the target.
386387
///
387-
/// Optional for "provision-credentials" kind.
388+
/// Required for "provision-connection-options" kind.
388389
connection_options: Option<TargetConnectionOptions>,
389390
/// The hostname to perform DNS resolution on.
390391
///
391392
/// Required for "resolve-host" kind.
392393
host_to_resolve: Option<String>,
393394
/// Minimum persistence duration in seconds for the data provisioned via this operation.
394395
///
395-
/// Optional parameter for "provision-token" and "provision-credentials" kinds.
396+
/// Optional parameter for "provision-token", "provision-credentials" and "provision-connection-options" kinds.
396397
time_to_live: Option<u32>,
397398
}
398399

@@ -419,6 +420,8 @@ enum PreflightOperationKind {
419420
ProvisionToken,
420421
#[serde(rename = "provision-credentials")]
421422
ProvisionCredentials,
423+
#[serde(rename = "provision-connection-options")]
424+
ProvisionConnectionOptions,
422425
#[serde(rename = "resolve-host")]
423426
ResolveHost,
424427
}

0 commit comments

Comments
 (0)